From 4ad37abed05f6b3b3cfda7b64ca40cbec46b087e Mon Sep 17 00:00:00 2001 From: Nikita Melnikov Date: Mon, 17 Oct 2022 08:25:38 +0800 Subject: [PATCH] =?UTF-8?q?[Feature]=20Static=20pages=20rendering=20?= =?UTF-8?q?=F0=9F=A4=A9=20(#274)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +- .npmignore | 8 + DEVELOPMENT.md | 6 +- docker-compose.yml | 4 +- docker/Dockerfile.prod | 6 +- app-config.yaml => docs-config.yaml | 2 +- package.json | 14 +- src/backend/app.ts | 115 ++----- src/backend/build-static.ts | 125 ++++++++ src/backend/routes/middlewares/pages.ts | 49 +-- src/backend/server.ts | 210 +++++++++++++ src/backend/utils/appConfig.ts | 13 +- src/backend/utils/banner.ts | 33 ++ src/backend/utils/menu.ts | 49 +++ src/backend/views/pages/index.twig | 4 +- src/backend/views/pages/page.twig | 4 +- src/bin/nvm.sh | 400 ------------------------ src/bin/server.ts | 138 -------- tsconfig.json | 9 +- yarn.lock | 66 +++- 20 files changed, 554 insertions(+), 705 deletions(-) create mode 100644 .npmignore rename app-config.yaml => docs-config.yaml (96%) create mode 100644 src/backend/build-static.ts create mode 100644 src/backend/server.ts create mode 100644 src/backend/utils/banner.ts create mode 100644 src/backend/utils/menu.ts delete mode 100644 src/bin/nvm.sh delete mode 100644 src/bin/server.ts diff --git a/.gitignore b/.gitignore index 0f84a0a..48196e3 100644 --- a/.gitignore +++ b/.gitignore @@ -75,7 +75,7 @@ db/ .DS_Store # Uploads -/public/uploads +/uploads /public/uploads_test # Compiled files @@ -83,3 +83,5 @@ db/ /public/dist/* *.local.yaml + +static-build diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..512a2bc --- /dev/null +++ b/.npmignore @@ -0,0 +1,8 @@ +* +!public/**/* +public/uploads +!dist/**/* +!package.json +!README.md +!yarn.lock +!LICENSE diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 920ef80..4535546 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -18,7 +18,7 @@ yarn install ### 3. Create separate config file for local overrides ```shell -touch app-config.local.yaml +touch docs-config.local.yaml ``` ### 4. Run the application @@ -39,7 +39,7 @@ In order to use MongoDB, follow these steps: docker-compose up mongodb ``` -### 2. Setup MongoDB driver in app-config.local.yaml +### 2. Setup MongoDB driver in docs-config.local.yaml ```yaml database: @@ -73,7 +73,7 @@ By default, the application uses local filesystem to store files, but S3 driver ### 1. Get credentials for S3 bucket Create a S3 bucket and get access key and secret key (or use existing ones) -### 2. Setup S3 driver in app-config.local.yaml +### 2. Setup S3 driver in docs-config.local.yaml ```yaml uploads: diff --git a/docker-compose.yml b/docker-compose.yml index bac8c3b..847a6f6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,8 +9,8 @@ services: volumes: - ./public/uploads:/uploads - ./db:/usr/src/app/db - - ./app-config.yaml:/usr/src/app/app-config.yaml - - ./app-config.local.yaml:/usr/src/app/app-config.local.yaml + - ./docs-config.yaml:/usr/src/app/docs-config.yaml + - ./docs-config.local.yaml:/usr/src/app/docs-config.local.yaml mongodb: image: mongo:6.0.1 ports: diff --git a/docker/Dockerfile.prod b/docker/Dockerfile.prod index 68055b6..b9a2506 100644 --- a/docker/Dockerfile.prod +++ b/docker/Dockerfile.prod @@ -16,9 +16,7 @@ RUN yarn install COPY . . -RUN yarn build-frontend - -RUN yarn compile +RUN yarn build-all # Stage 2 - make final image FROM node:16.14.0-alpine3.15 @@ -32,4 +30,4 @@ COPY --from=build /usr/src/app/public ./public ENV NODE_ENV=production -CMD ["node", "dist/bin/server.js"] +CMD ["node", "dist/backend/server.js"] diff --git a/app-config.yaml b/docs-config.yaml similarity index 96% rename from app-config.yaml rename to docs-config.yaml index 573a981..26de628 100644 --- a/app-config.yaml +++ b/docs-config.yaml @@ -3,7 +3,7 @@ host: "localhost" uploads: driver: "local" local: - path: "./public/uploads" + path: "./uploads" s3: bucket: "my-bucket" region: "eu-central-1" diff --git a/package.json b/package.json index 857c56f..8995853 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,11 @@ { "name": "codex.docs", "license": "Apache-2.0", + "version": "0.0.1-alpha.8", "type": "module", + "bin": { + "codex.docs": "dist/backend/app.js" + }, "browserslist": [ "last 2 versions", "> 1%" @@ -9,8 +13,10 @@ "scripts": { "start": "concurrently \"yarn start-backend\" \"yarn build-frontend\"", "dev": "concurrently \"yarn start-backend\" \"yarn build-frontend:dev\"", - "start-backend": "cross-env NODE_ENV=development npx nodemon --config nodemon.json ./src/bin/server.ts -c app-config.yaml -c app-config.local.yaml", - "compile": "tsc && copyfiles -u 3 ./src/**/*.twig ./dist/backend/views && copyfiles -u 1 ./src/**/*.svg ./dist/", + "build-all": "yarn build-frontend && yarn build-backend", + "build-static": "ts-node src/backend/app.ts build-static -c docs-config.yaml -c docs-config.local.yaml", + "start-backend": "cross-env NODE_ENV=development npx nodemon --config nodemon.json src/backend/app.ts -c docs-config.yaml -c docs-config.local.yaml", + "build-backend": "tsc && copyfiles -u 3 ./src/**/*.twig ./dist/backend/views && copyfiles -u 1 ./src/**/*.svg ./dist/", "build-frontend": "webpack --mode=production", "build-frontend:dev": "webpack --mode=development --watch", "test:js": "cross-env NODE_ENV=testing mocha --recursive ./dist/test --exit", @@ -25,6 +31,7 @@ "@hawk.so/javascript": "^3.0.1", "@hawk.so/nodejs": "^3.1.4", "@types/multer-s3": "^3.0.0", + "@types/yargs": "^17.0.13", "arg": "^5.0.2", "config": "^3.3.6", "cookie-parser": "^1.4.5", @@ -33,6 +40,7 @@ "dotenv": "^16.0.0", "express": "^4.17.1", "file-type": "^16.5.4", + "fs-extra": "^10.1.0", "http-errors": "^2.0.0", "jsonwebtoken": "^8.5.1", "mime": "^3.0.0", @@ -47,6 +55,7 @@ "open-graph-scraper": "^4.9.0", "twig": "^1.15.4", "uuid4": "^2.0.2", + "yargs": "^17.6.0", "zod": "^3.19.1" }, "devDependencies": { @@ -78,6 +87,7 @@ "@types/debug": "^4.1.7", "@types/express": "^4.17.13", "@types/file-type": "^10.9.1", + "@types/fs-extra": "^9.0.13", "@types/jsonwebtoken": "^8.5.4", "@types/mime": "^2.0.3", "@types/mkdirp": "^1.0.2", diff --git a/src/backend/app.ts b/src/backend/app.ts index 313e7df..99d1ef3 100644 --- a/src/backend/app.ts +++ b/src/backend/app.ts @@ -1,99 +1,20 @@ -import express, { NextFunction, Request, Response } from 'express'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import cookieParser from 'cookie-parser'; -import morgan from 'morgan'; -import routes from './routes/index.js'; -import HttpException from './exceptions/httpException.js'; -import * as dotenv from 'dotenv'; -import HawkCatcher from '@hawk.so/nodejs'; -import os from 'os'; -import { downloadFavicon, FaviconData } from './utils/downloadFavicon.js'; -import appConfig from './utils/appConfig.js'; +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; +import runHttpServer from './server.js'; +import buildStatic from './build-static.js'; -/** - * 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)); - -dotenv.config(); -const app = express(); -const localConfig = appConfig.frontend; - -// Initialize the backend error tracking catcher. -if (appConfig.hawk?.backendToken) { - HawkCatcher.init(appConfig.hawk.backendToken); -} - -// Get url to upload favicon from config -const favicon = appConfig.favicon; - -app.locals.config = localConfig; -// Set client error tracking token as app local. -if (appConfig.hawk?.frontendToken) { - app.locals.config.hawkClientToken = appConfig.hawk.frontendToken; -} - -// view engine setup -app.set('views', path.join(__dirname, './', 'views')); -app.set('view engine', 'twig'); -import('./utils/twig.js'); - -const downloadedFaviconFolder = os.tmpdir(); - -// Check if favicon is not empty -if (favicon) { - // Upload favicon by url, it's path on server is '/temp/favicon.{format}' - downloadFavicon(favicon, downloadedFaviconFolder).then((res) => { - app.locals.favicon = res; - console.log('Favicon successfully uploaded'); +yargs(hideBin(process.argv)) + .option('config', { + alias: 'c', + type: 'string', + default: './docs-config.yaml', + description: 'Config files paths', }) - .catch( (err) => { - console.log(err); - console.log('Favicon has not uploaded'); - }); -} else { - console.log('Favicon is empty, using default path'); - app.locals.favicon = { - destination: '/favicon.png', - type: 'image/png', - } as FaviconData; -} - -app.use(morgan('dev')); -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); -app.use(cookieParser()); -app.use(express.static(path.join(__dirname, '../../public'))); - -if (appConfig.uploads.driver === 'local') { - app.use('/uploads', express.static(appConfig.uploads.local.path)); -} - -app.use('/favicon', express.static(downloadedFaviconFolder)); - -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 (appConfig.hawk?.backendToken && err instanceof Error) { - HawkCatcher.send(err); - } - // only send Http based exception to client. - if (err instanceof HttpException) { - // set locals, only providing error in development - res.locals.message = err.message; - res.locals.error = req.app.get('env') === 'development' ? err : {}; - // render the error page - res.status(err.status || 500); - res.render('error'); - } - next(err); -}); - - -export default app; + .help('h') + .alias('h', 'help') + .command('$0', 'start the server', () => {/* empty */}, runHttpServer) + .command('build-static', 'build files from database', () => {/* empty */}, async () => { + await buildStatic(); + process.exit(0); + }) + .parse(); diff --git a/src/backend/build-static.ts b/src/backend/build-static.ts new file mode 100644 index 0000000..d0691e0 --- /dev/null +++ b/src/backend/build-static.ts @@ -0,0 +1,125 @@ +import twig from 'twig'; +import Page from './models/page.js'; +import PagesFlatArray from './models/pagesFlatArray.js'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import('./utils/twig.js'); +import fs from 'fs/promises'; +import mkdirp from 'mkdirp'; +import { createMenuTree } from './utils/menu.js'; +import { EntityId } from './database/types.js'; +import PagesOrder from './controllers/pagesOrder.js'; +import fse from 'fs-extra'; +import appConfig from './utils/appConfig.js'; +import Aliases from './controllers/aliases.js'; +import Pages from './controllers/pages.js'; + +/** + * Build static pages from database + */ +export default async function buildStatic(): Promise { + const config = appConfig.staticBuild; + + if (!config) { + throw new Error('Static build config not found'); + } + + const dirname = path.dirname(fileURLToPath(import.meta.url)); + const cwd = process.cwd(); + const distPath = path.resolve(cwd, config.outputDir); + + /** + * Render template with twig by path + * + * @param filePath - path to template + * @param data - data to render template + */ + function renderTemplate(filePath: string, data: Record): Promise { + return new Promise((resolve, reject) => { + twig.renderFile(path.resolve(dirname, filePath), data, (err, html) => { + if (err) { + reject(err); + } + resolve(html); + }); + }); + } + + console.log('Removing old static files'); + await fse.remove(distPath); + + console.log('Building static files'); + const pagesOrder = await PagesOrder.getAll(); + const allPages = await Page.getAll(); + + await mkdirp(distPath); + + /** + * Renders single page + * + * @param page - page to render + * @param isIndex - is this page index page + */ + async function renderPage(page: Page, isIndex?: boolean): Promise { + console.log(`Rendering page ${page.uri}`); + const pageParent = await page.getParent(); + const pageId = page._id; + + if (!pageId) { + throw new Error('Page id is not defined'); + } + const parentIdOfRootPages = '0' as EntityId; + const previousPage = await PagesFlatArray.getPageBefore(pageId); + const nextPage = await PagesFlatArray.getPageAfter(pageId); + const menu = createMenuTree(parentIdOfRootPages, allPages, pagesOrder, 2); + const result = await renderTemplate('./views/pages/page.twig', { + page, + pageParent, + previousPage, + nextPage, + menu, + config: appConfig.frontend, + }); + + const filename = (isIndex || page.uri === '') ? 'index.html' : `${page.uri}.html`; + + await fs.writeFile(path.resolve(distPath, filename), result); + console.log(`Page ${page.uri} rendered`); + } + + /** + * Render index page + * + * @param indexPageUri - uri of index page + */ + async function renderIndexPage(indexPageUri: string): Promise { + const alias = await Aliases.get(indexPageUri); + + if (!alias.id) { + throw new Error(`Alias ${indexPageUri} not found`); + } + + const page = await Pages.get(alias.id); + + await renderPage(page, true); + } + + /** + * Render all pages + */ + for (const page of allPages) { + await renderPage(page); + } + + await renderIndexPage(config.indexPageUri); + console.log('Static files built'); + + console.log('Copy public directory'); + await fse.copy(path.resolve(dirname, '../../public'), distPath); + + if (appConfig.uploads.driver === 'local') { + console.log('Copy uploads directory'); + await fse.copy(path.resolve(cwd, appConfig.uploads.local.path), path.resolve(distPath, 'uploads')); + } +} + diff --git a/src/backend/routes/middlewares/pages.ts b/src/backend/routes/middlewares/pages.ts index 58917f8..8fafe4b 100644 --- a/src/backend/routes/middlewares/pages.ts +++ b/src/backend/routes/middlewares/pages.ts @@ -1,57 +1,10 @@ import { NextFunction, Request, Response } from 'express'; import Pages from '../../controllers/pages.js'; import PagesOrder from '../../controllers/pagesOrder.js'; -import Page from '../../models/page.js'; import asyncMiddleware from '../../utils/asyncMiddleware.js'; -import PageOrder from '../../models/pageOrder.js'; import { EntityId } from '../../database/types.js'; -import { isEqualIds } from '../../database/index.js'; +import { createMenuTree } from '../../utils/menu.js'; -/** - * Process one-level pages list to parent-children list - * - * @param {string} parentPageId - parent page id - * @param {Page[]} pages - list of all available pages - * @param {PagesOrder[]} pagesOrder - list of pages order - * @param {number} level - max level recursion - * @param {number} currentLevel - current level of element - * @returns {Page[]} - */ -function createMenuTree(parentPageId: EntityId, pages: Page[], pagesOrder: PageOrder[], level = 1, currentLevel = 1): Page[] { - const childrenOrder = pagesOrder.find(order => isEqualIds(order.data.page, parentPageId)); - - /** - * branch is a page children in tree - * if we got some children order on parents tree, then we push found pages in order sequence - * otherwise just find all pages includes parent tree - */ - let ordered: any[] = []; - - if (childrenOrder) { - ordered = childrenOrder.order.map((pageId: EntityId) => { - return pages.find(page => isEqualIds(page._id, pageId)); - }); - } - - const unordered = pages.filter(page => isEqualIds(page._parent, parentPageId)); - const branch = Array.from(new Set([...ordered, ...unordered])); - - /** - * stop recursion when we got the passed max level - */ - if (currentLevel === level + 1) { - return []; - } - - /** - * Each parents children can have subbranches - */ - return branch.filter(page => page && page._id).map(page => { - return Object.assign({ - children: createMenuTree(page._id, pages, pagesOrder, level, currentLevel + 1), - }, page.data); - }); -} /** * Middleware for all /page/... routes diff --git a/src/backend/server.ts b/src/backend/server.ts new file mode 100644 index 0000000..c3331f4 --- /dev/null +++ b/src/backend/server.ts @@ -0,0 +1,210 @@ +#!/usr/bin/env node +/** + * Module dependencies. + */ +import http from 'http'; +import Debug from 'debug'; +import appConfig from './utils/appConfig.js'; +import { drawBanner } from './utils/banner.js'; +import express, { NextFunction, Request, Response } from 'express'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import HawkCatcher from '@hawk.so/nodejs'; +import os from 'os'; +import { downloadFavicon, FaviconData } from './utils/downloadFavicon.js'; +import morgan from 'morgan'; +import cookieParser from 'cookie-parser'; +import routes from './routes/index.js'; +import HttpException from './exceptions/httpException.js'; + +const debug = Debug.debug('codex.docs:server'); + +/** + * Get port from environment and store in Express. + */ +const port = normalizePort(appConfig.port.toString() || '3000'); + +/** + * Create Express server + */ +function createApp(): express.Express { + /** + * 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 app = express(); + const localConfig = appConfig.frontend; + + // Initialize the backend error tracking catcher. + if (appConfig.hawk?.backendToken) { + HawkCatcher.init(appConfig.hawk.backendToken); + } + + // Get url to upload favicon from config + const favicon = appConfig.favicon; + + app.locals.config = localConfig; + // Set client error tracking token as app local. + if (appConfig.hawk?.frontendToken) { + app.locals.config.hawkClientToken = appConfig.hawk.frontendToken; + } + + // view engine setup + app.set('views', path.join(__dirname, './', 'views')); + app.set('view engine', 'twig'); + import('./utils/twig.js'); + + const downloadedFaviconFolder = os.tmpdir(); + + // Check if favicon is not empty + if (favicon) { + // Upload favicon by url, it's path on server is '/temp/favicon.{format}' + downloadFavicon(favicon, downloadedFaviconFolder).then((res) => { + app.locals.favicon = res; + console.log('Favicon successfully uploaded'); + }) + .catch((err) => { + console.log(err); + console.log('Favicon has not uploaded'); + }); + } else { + console.log('Favicon is empty, using default path'); + app.locals.favicon = { + destination: '/favicon.png', + type: 'image/png', + } as FaviconData; + } + + app.use(morgan('dev')); + app.use(express.json()); + app.use(express.urlencoded({ extended: true })); + app.use(cookieParser()); + app.use(express.static(path.join(__dirname, '../../public'))); + + if (appConfig.uploads.driver === 'local') { + app.use('/uploads', express.static(appConfig.uploads.local.path)); + } + + app.use('/favicon', express.static(downloadedFaviconFolder)); + + 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 (appConfig.hawk?.backendToken && err instanceof Error) { + HawkCatcher.send(err); + } + // only send Http based exception to client. + if (err instanceof HttpException) { + // set locals, only providing error in development + res.locals.message = err.message; + res.locals.error = req.app.get('env') === 'development' ? err : {}; + // render the error page + res.status(err.status || 500); + res.render('error'); + } + next(err); + }); + + return app; +} + +/** + * Create and run HTTP server. + */ +export default function runHttpServer(): void { + const app = createApp(); + + app.set('port', port); + + /** + * Create HTTP server. + */ + const server = http.createServer(app); + + /** + * Event listener for HTTP server 'listening' event. + */ + function onListening(): void { + const addr = server.address(); + + if (addr === null) { + debug('Address not found'); + process.exit(1); + } + + const bind = typeof addr === 'string' + ? 'pipe ' + addr + : 'port ' + addr.port; + + debug('Listening on ' + bind); + + drawBanner([ + `CodeX Docs server is running`, + ``, + `Main page: http://localhost:${port}`, + ]); + } + + /** + * Listen on provided port, on all network interfaces. + */ + server.listen(port); + server.on('error', onError); + server.on('listening', onListening); +} + +/** + * Normalize a port into a number, string, or false. + * + * @param val + */ +function normalizePort(val: string): number | string | false { + const value = parseInt(val, 10); + + if (isNaN(value)) { + // named pipe + return val; + } + + if (value >= 0) { + // port number + return value; + } + + return false; +} + +/** + * Event listener for HTTP server 'error' event. + * + * @param error + */ +function onError(error: NodeJS.ErrnoException): void { + if (error.syscall !== 'listen') { + throw error; + } + + const bind = typeof port === 'string' + ? 'Pipe ' + port + : 'Port ' + port; + + // handle specific listen errors with friendly messages + switch (error.code) { + case 'EACCES': + console.error(bind + ' requires elevated privileges'); + process.exit(1); + break; + case 'EADDRINUSE': + console.error(bind + ' is already in use'); + process.exit(1); + break; + default: + throw error; + } +} diff --git a/src/backend/utils/appConfig.ts b/src/backend/utils/appConfig.ts index 1426787..7d90d9e 100644 --- a/src/backend/utils/appConfig.ts +++ b/src/backend/utils/appConfig.ts @@ -84,6 +84,16 @@ const FrontendConfig = z.object({ uri: z.string() })])), // Menu for pages }); +/** + * Static build configuration + */ +const StaticBuildConfig = z.object({ + outputDir: z.string(), // Output directory for static build + indexPageUri: z.string(), // URI for index page to render +}); + +export type StaticBuildConfig = z.infer; + /** * Application configuration */ @@ -97,6 +107,7 @@ const AppConfig = z.object({ frontend: FrontendConfig, // Frontend configuration auth: AuthConfig, // Auth configuration database: z.union([LocalDatabaseConfig, MongoDatabaseConfig]), // Database configuration + staticBuild: StaticBuildConfig.optional(), // Static build configuration }); export type AppConfig = z.infer; @@ -107,7 +118,7 @@ const args = arg({ /* eslint-disable @typescript-eslint/naming-convention */ }); const cwd = process.cwd(); -const paths = (args['--config'] || [ './app-config.yaml' ]).map((configPath) => { +const paths = (args['--config'] || [ './docs-config.yaml' ]).map((configPath) => { if (path.isAbsolute(configPath)) { return configPath; } diff --git a/src/backend/utils/banner.ts b/src/backend/utils/banner.ts new file mode 100644 index 0000000..26ca8cc --- /dev/null +++ b/src/backend/utils/banner.ts @@ -0,0 +1,33 @@ +/** + * Draw banner in console with given text lines + * + * @param lines - data to draw + */ +export function drawBanner(lines: string[]): void { + /** Define banner parts */ + const PARTS = { + TOP_LEFT: '┌', + TOP_RIGHT: '┐', + BOTTOM_LEFT: '└', + BOTTOM_RIGHT: '┘', + HORIZONTAL: '─', + VERTICAL: '│', + SPACE: ' ', + }; + + /** Calculate max line length */ + const maxLength = lines.reduce((max, line) => Math.max(max, line.length), 0); + + /** Prepare top line */ + const top = PARTS.TOP_LEFT + PARTS.HORIZONTAL.repeat(maxLength + 2) + PARTS.TOP_RIGHT; + + /** Compose middle lines */ + const middle = lines.map(line => PARTS.VERTICAL + ' ' + line + PARTS.SPACE.repeat(maxLength - line.length) + ' ' + PARTS.VERTICAL); + + /** Prepare bottom line */ + const bottom = PARTS.BOTTOM_LEFT + PARTS.HORIZONTAL.repeat(maxLength + 2) + PARTS.BOTTOM_RIGHT; + + console.log(top); + console.log(middle.join('\n')); + console.log(bottom); +} diff --git a/src/backend/utils/menu.ts b/src/backend/utils/menu.ts new file mode 100644 index 0000000..1b43417 --- /dev/null +++ b/src/backend/utils/menu.ts @@ -0,0 +1,49 @@ +import { EntityId } from '../database/types.js'; +import Page from '../models/page.js'; +import PageOrder from '../models/pageOrder.js'; +import { isEqualIds } from '../database/index.js'; + +/** + * Process one-level pages list to parent-children list + * + * @param parentPageId - parent page id + * @param pages - list of all available pages + * @param pagesOrder - list of pages order + * @param level - max level recursion + * @param currentLevel - current level of element + */ +export function createMenuTree(parentPageId: EntityId, pages: Page[], pagesOrder: PageOrder[], level = 1, currentLevel = 1): Page[] { + const childrenOrder = pagesOrder.find(order => isEqualIds(order.data.page, parentPageId)); + + /** + * branch is a page children in tree + * if we got some children order on parents tree, then we push found pages in order sequence + * otherwise just find all pages includes parent tree + */ + let ordered: any[] = []; + + if (childrenOrder) { + ordered = childrenOrder.order.map((pageId: EntityId) => { + return pages.find(page => isEqualIds(page._id, pageId)); + }); + } + + const unordered = pages.filter(page => isEqualIds(page._parent, parentPageId)); + const branch = Array.from(new Set([...ordered, ...unordered])); + + /** + * stop recursion when we got the passed max level + */ + if (currentLevel === level + 1) { + return []; + } + + /** + * Each parents children can have subbranches + */ + return branch.filter(page => page && page._id).map(page => { + return Object.assign({ + children: createMenuTree(page._id, pages, pagesOrder, level, currentLevel + 1), + }, page.data); + }); +} diff --git a/src/backend/views/pages/index.twig b/src/backend/views/pages/index.twig index ca975db..cf84415 100644 --- a/src/backend/views/pages/index.twig +++ b/src/backend/views/pages/index.twig @@ -12,13 +12,13 @@ - {% include "components/header.twig" %} + {% include "../components/header.twig" %}
{{ svg('frog') }}

It’s time to create the first page!

- {% include 'components/button.twig' with {label: 'Add page', icon: 'plus', size: 'small', url: '/page/new'} %} + {% include '../components/button.twig' with {label: 'Add page', icon: 'plus', size: 'small', url: '/page/new'} %}
{% if config.yandexMetrikaId is not empty %}