CrazyDoctor 8 months ago
parent
commit
6ffc036eb0
49 changed files with 3930 additions and 930 deletions
  1. 28 0
      config.json
  2. 0 35
      node_scripts/copyStatics.js
  3. 45 0
      node_scripts/copyStatics.mjs
  4. 139 0
      node_scripts/uglifyStatics.mjs
  5. 692 10
      package-lock.json
  6. 16 6
      package.json
  7. 0 23
      sources.config.json
  8. 113 14
      src/index.ts
  9. 10 0
      src/middlewares/CookieParserMiddleware.ts
  10. 12 0
      src/middlewares/NotFoundErrorMiddleware.ts
  11. 27 4
      src/routes/GetClass.ts
  12. 20 0
      src/routes/GetFaq.ts
  13. 10 2
      src/routes/GetIndex.ts
  14. 27 0
      src/routes/GetLogin.ts
  15. 29 0
      src/routes/PostAuthorize.ts
  16. 46 0
      src/routes/PostInheritedComments.ts
  17. 18 0
      src/routes/PostLogout.ts
  18. 65 0
      src/routes/PostUpdateComment.ts
  19. 56 8
      src/routes/PostUpdateSources.ts
  20. 5 0
      src/session/ISession.ts
  21. 163 17
      src/sources/Analyzer.ts
  22. 82 27
      src/sources/Sources.ts
  23. 13 0
      src/util/SHA256.ts
  24. 13 0
      src/util/UpdateSources.ts
  25. 12 0
      src/views/404.pug
  26. 44 31
      src/views/class.pug
  27. 13 0
      src/views/faq.pug
  28. 1 0
      src/views/index.pug
  29. 20 0
      src/views/login.pug
  30. 10 3
      src/views/parts/left-header.part.pug
  31. 13 0
      src/views/sources-update.pug
  32. 30 4
      static/App.js
  33. 303 194
      static/CDClientLib/CDClientLib.js
  34. 17 1
      static/codemirror/codemirror.js
  35. 16 0
      static/img/404.svg
  36. 2 0
      static/img/faq.svg
  37. 6 0
      static/img/logout.svg
  38. 4 0
      static/img/refresh.svg
  39. 2 0
      static/img/user.svg
  40. 247 229
      static/modules/class-list/class-list.js
  41. 5 5
      static/modules/search/search.js
  42. 963 293
      static/page/class/script.js
  43. 157 4
      static/page/class/style.css
  44. 136 0
      static/page/faq/script.js
  45. 52 0
      static/page/faq/style.css
  46. 10 11
      static/page/index/script.js
  47. 49 0
      static/page/login/script.js
  48. 52 0
      static/page/login/style.css
  49. 137 9
      static/style/style.css

+ 28 - 0
config.json

@@ -0,0 +1,28 @@
+{
+	"server": {
+		"adminPassword": "123123"
+	},
+	"sources": {
+		"assetsDir": ".doczilla_js_docs",
+		"repos": [
+			{
+				"name": "org.zenframework.z8",
+				"url": "https://git.doczilla.pro/z8/org.zenframework.z8",
+				"sparseCheckout": "org.zenframework.z8.js",
+				"collectFrom": "org.zenframework.z8.js/src/js"
+			},
+			{
+				"name": "ru.morpher.js",
+				"url": "https://git.doczilla.pro/doczilla/ru.morpher.js",
+				"sparseCheckout": "src/js",
+				"collectFrom": "src/js"
+			},
+			{
+				"name": "pro.doczilla.base.js",
+				"url": "https://git.doczilla.pro/doczilla/pro.doczilla.base.js",
+				"sparseCheckout": "src/js",
+				"collectFrom": "src/js"
+			}
+		]
+	}
+}

+ 0 - 35
node_scripts/copyStatics.js

@@ -1,35 +0,0 @@
-const fs = require('fs');
-const ncp = require('ncp').ncp;
-const { version } = require('../package.json');
-
-const sourcePath = './static';
-const destinationPath = './dist/static'
-const appPath = `${destinationPath}/App.js`;
-
-const replacementMap = [
-  { regexp: /#_version_/g, replacement: version }
-];
-
-ncp(sourcePath, destinationPath, function (err) {
-	if (err) {
-		return console.error(err);
-	}
-
-	applyGlobals();
-});
-
-function applyGlobals() {
-  fs.readFile(appPath, 'utf8', function (err, data) {
-    if (err) {
-      return console.error(`Unable to read file ${filePath}: ` + err);
-    }
-    
-    for(const replacement of replacementMap) {
-      data = data.replace(replacement.regexp, replacement.replacement);
-    }
-
-    fs.writeFile(appPath, data, 'utf8', function (err) {
-      if (err) return console.error('Unable to write file: ' + err);
-    });
-  });
-}

+ 45 - 0
node_scripts/copyStatics.mjs

@@ -0,0 +1,45 @@
+import fs from 'fs';
+import ncp from 'ncp';
+import * as path from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+class CopyStaticsTask {
+  static SourcePath = './static';
+  static DestinationPath = './dist/static'
+  static AppPath = `${CopyStaticsTask.DestinationPath}/App.js`;
+
+  static start() {
+    const pck = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../package.json')).toString());
+    const replacementMap = [
+      { regexp: /#_version_/g, replacement: pck.version }
+    ];
+
+    ncp(CopyStaticsTask.SourcePath, CopyStaticsTask.DestinationPath, function (err) {
+      if (err) {
+        return console.error(err);
+      }
+    
+      CopyStaticsTask.applyGlobals(replacementMap);
+    });
+  }
+
+  static applyGlobals(replacementMap) {
+    fs.readFile(CopyStaticsTask.AppPath, 'utf8', function (err, data) {
+      if (err) {
+        return console.error(`Unable to read file ${CopyStaticsTask.AppPath}: ` + err);
+      }
+      
+      for(const replacement of replacementMap) {
+        data = data.replace(replacement.regexp, replacement.replacement);
+      }
+  
+      fs.writeFile(CopyStaticsTask.AppPath, data, 'utf8', function (err) {
+        if (err) return console.error('Unable to write file: ' + err);
+      });
+    });
+  }
+}
+
+CopyStaticsTask.start();

+ 139 - 0
node_scripts/uglifyStatics.mjs

@@ -0,0 +1,139 @@
+import fs from 'fs';
+import { minify_sync } from 'terser';
+import * as path from 'path';
+
+class Minifier {
+
+	static MinifyList = [
+		'CDClientLib',
+		'modules',
+		'page',
+		'App.js'
+	];
+
+	static combine() {
+		const getAllJSFiles = (dirPath, fileList = []) => {
+			const files = fs.readdirSync(dirPath);
+			files.forEach(file => {
+				const filePath = path.join(dirPath, file);
+				if (fs.statSync(filePath).isDirectory()) {
+					getAllJSFiles(filePath, fileList);
+				} else if (filePath.endsWith('.js')) {
+					fileList.push(filePath);
+				}
+			});
+			return fileList;
+		};
+
+		const mergeFilesWithComments = (fileList) => {
+			let mergedContent = '';
+			for (let i = 0; i < fileList.length; i++) {
+				mergedContent += `\n\n/*! >>> ${fileList[i]} */\n\n`;
+				const filePath = fileList[i];
+				const fileContent = fs.readFileSync(filePath, 'utf8');
+				mergedContent += fileContent;
+			}
+			return mergedContent;
+		}
+		
+		const allJSFiles = this.MinifyList.reduce((accumulator, currentPath) => {
+			currentPath = path.join('./dist/static/', currentPath);
+			if (fs.statSync(currentPath).isDirectory()) {
+				return accumulator.concat(getAllJSFiles(currentPath));
+			} else if (currentPath.endsWith('.js')) {
+				return accumulator.concat(currentPath);
+			}
+			return accumulator;
+		}, []);
+		
+		const mergedContent = mergeFilesWithComments(allJSFiles);
+
+		fs.writeFileSync('./dist/static/merged_statics.js', mergedContent);
+	}
+
+	static uglify() {
+		const mangleProperties = {
+			properties: {
+				reserved: [
+					'$', // jQuery
+					
+					// >>> CodeMirror
+					'CodeMirror',
+					'cmLineCount',
+					'cmEachLine',
+					'cmRefresh',
+					'cmSetValue',
+					'scrollIntoView',
+					'markText',
+					'getSearchCursor',
+					'cmFirstLine',
+					'setSelection',
+					'cmGetValue',
+					'replaceRange',
+					'setCursor',
+					'cmGetLine',
+					'cmFocus',
+					'lineNo',
+					'onClassLinkClick',
+					'onPropertyClick',
+					// <<< CodeMirror
+
+					// >>> Server data
+					'Class',
+					'isAdmin',
+					'ClassList',
+					'ClassSource',
+					'RepoNames',
+					'Z8Locales',
+					'Comments',
+					'LastUpdateTime',
+					'comment',
+					'timestamp',
+					'nearestParent',
+					'type',
+					'value',
+					'key',
+					'inherited',
+					'overridden',
+					'dynamic'
+					// <<< Server data
+				],
+				keep_quoted: true
+			},
+			toplevel: true
+		}
+		const uglified = minify_sync(fs.readFileSync('./dist/static/merged_statics.js', "utf8").toString(), { mangle: mangleProperties });
+		const uglifiedCode = uglified.code.replace(/\/\*\!\s>>>\s(.+)\s\*\/([^\s])/g, '/*! >>> $1 */\n$2');
+		const uglifiedCodeStrings = uglifiedCode.split('\n');
+
+		let fPath = '';
+		let fContents = {};
+		for(const str of uglifiedCodeStrings) {
+			const fnMatch = str.match(/\/\*\!\s>>>\s(.+)\s\*\//);
+			if(fnMatch) {
+				fPath = `./${fnMatch[1]}`;
+				fContents[fPath] = '';
+			} else {
+				fContents[fPath] += str + '\n';
+			}
+		}
+
+		for(const file of Object.keys(fContents)) {
+			fs.writeFileSync(file, fContents[file]);
+		}
+	}
+
+
+	static clean() {
+		fs.rmSync('./dist/static/merged_statics.js');
+	}
+
+	static run() {
+		Minifier.combine();
+		Minifier.uglify();
+		Minifier.clean();
+	}
+
+}
+
+Minifier.run();

File diff suppressed because it is too large
+ 692 - 10
package-lock.json


+ 16 - 6
package.json

@@ -1,22 +1,32 @@
 {
   "name": "doczilla_js_docs",
-  "version": "1.0.0",
+  "version": "1.0.1",
   "dependencies": {
+    "@types/cookie-parser": "^1.4.7",
+    "@types/cron": "^2.4.0",
     "@types/jsdom": "^21.1.6",
     "@types/pug": "^2.0.10",
+    "@types/sqlite3": "^3.1.11",
+    "cookie-parser": "^1.4.6",
+    "cron": "^3.1.6",
     "jsdom": "^24.0.0",
-    "ncp": "^2.0.0",
     "org.crazydoctor.expressts": "git+https://git.crazydoctor.org/expressts/org.crazydoctor.expressts",
-    "pug": "^3.0.2"
+    "pug": "^3.0.2",
+    "sqlite3": "^5.1.7"
   },
   "devDependencies": {
+    "@types/express-session": "^1.18.0",
     "@typescript-eslint/eslint-plugin": "^7.1.0",
     "@typescript-eslint/parser": "^7.1.0",
-    "eslint": "^8.57.0"
+    "eslint": "^8.57.0",
+		"ncp": "^2.0.0",
+		"terser": "^5.29.2"
   },
   "scripts": {
     "lint": "eslint .",
-    "build": "tsc && ncp ./sources.config.json ./dist/sources.config.json && ncp ./src/views/ ./dist/views && node ./node_scripts/copyStatics.js",
-    "start": "npm run build && node ./dist/index.js"
+    "build": "tsc && ncp ./config.json ./dist/config.json && ncp ./src/views/ ./dist/views && node ./node_scripts/copyStatics.mjs",
+    "buildProd": "npm run build && node ./node_scripts/uglifyStatics.mjs",
+		"start": "npm run build && node ./dist/index.js",
+		"startProd": "npm run buildProd && node ./dist/index.js"
   }
 }

+ 0 - 23
sources.config.json

@@ -1,23 +0,0 @@
-{
-	"cloneTo": ".doczilla_js_docs",
-	"repos": [
-		{
-			"name": "org.zenframework.z8",
-			"url": "https://git.doczilla.pro/z8/org.zenframework.z8",
-			"sparseCheckout": "org.zenframework.z8.js",
-			"collectFrom": "org.zenframework.z8.js/src/js"
-		},
-		{
-			"name": "ru.morpher.js",
-			"url": "https://git.doczilla.pro/doczilla/ru.morpher.js",
-			"sparseCheckout": "src/js",
-			"collectFrom": "src/js"
-		},
-		{
-			"name": "pro.doczilla.base.js",
-			"url": "https://git.doczilla.pro/doczilla/pro.doczilla.base.js",
-			"sparseCheckout": "src/js",
-			"collectFrom": "src/js"
-		}
-	]
-}

+ 113 - 14
src/index.ts

@@ -1,18 +1,117 @@
-import {Server} from 'org.crazydoctor.expressts';
+import { Guid, Server } from 'org.crazydoctor.expressts';
 import * as path from 'path';
+import os from 'os';
+import fs from 'fs';
+import { spawn } from 'child_process';
 import { Sources } from './sources/Sources';
+import { CronJob } from 'cron';
+import SHA256 from './util/SHA256';
+import { Database } from 'sqlite3';
 
-new Server({
-	port: 3000,
-	routesPath: path.resolve(__dirname, './routes'),
-	middlewaresPath: path.resolve(__dirname, './middlewares'),
-	viewsPath: path.resolve(__dirname, './views'),
-	viewEngine: 'pug',
-	options: {
-		'static': path.resolve(__dirname, './static')
+class ServerApp {
+	public static SourcesUpdating = false;
+	private static Server: Server | null = null;
+	private static db: Database | null = null;
+
+	private static sourcesUpdateTask(): void {
+		if(ServerApp.SourcesUpdating)
+			return;
+		try {
+			ServerApp.Server?.logInfo('[Cron] Sources update started...');
+			ServerApp.SourcesUpdating = true;
+			const update = spawn('node', [path.resolve(__dirname, 'util/UpdateSources.js')]);
+	
+			update.stdout.on('data', (data) => {
+				console.log(data.toString());
+			});
+
+			update.stderr.on('data', (data) => {
+				console.error(data.toString());
+			});
+
+			update.on('close', (code) => {
+				ServerApp.SourcesUpdating = false;
+				if (code === 0) {
+					Sources.get();
+					ServerApp.Server?.logInfo('[Cron] Sources update finished.');
+				} else {
+					ServerApp.Server?.logError('[Cron] Sources update failed.');
+				}
+			});
+
+			update.on('error', (e) => {
+				ServerApp.SourcesUpdating = false;
+				ServerApp.Server?.logError('[Cron] Sources update failed.');
+				console.error(e);
+			});
+		} catch (e) {
+			ServerApp.SourcesUpdating = false;
+			ServerApp.Server?.logError('[Cron] Sources update failed.');
+			console.error(e);
+		}
+	}
+
+	private static startCronTask(): void {
+		const timeZone = 'Europe/Moscow';
+		const cronSchedule = '0 3 * * *';
+		const job = new CronJob(cronSchedule, ServerApp.sourcesUpdateTask, null, true, timeZone);
+		job.start();
+	}
+
+	private static initDb(assetsDir: string): void {
+		const dbDir = path.resolve(`${os.homedir()}/${assetsDir}/db`);
+		if(!fs.existsSync(dbDir))
+			fs.mkdirSync(dbDir);
+		
+		ServerApp.db = new Database(`${dbDir}/db.db`);
+
+		ServerApp.db.run(`
+			CREATE TABLE IF NOT EXISTS comments (
+				id INTEGER PRIMARY KEY AUTOINCREMENT,
+				class TEXT NOT NULL,
+				property TEXT NOT NULL,
+				comment TEXT,
+				timestamp BIGINT
+			)
+		`);
 	}
-}).init().then((server) => {
-	Sources.get(() => {
-		server.start();
-	});
-});
+
+	public static getDb(): Database {
+		return ServerApp.db!;
+	}
+
+	public static start(): void {
+
+		const Config = JSON.parse(fs.readFileSync(path.resolve(__dirname, 'config.json'), { encoding: 'utf-8' }).toString());
+		const ServerConfig = Config.server;
+		const SourcesConfig = Config.sources;
+
+		ServerApp.initDb(SourcesConfig.assetsDir);
+
+		new Server({
+			port: 3000,
+			routesPath: path.resolve(__dirname, './routes'),
+			middlewaresPath: path.resolve(__dirname, './middlewares'),
+			viewsPath: path.resolve(__dirname, './views'),
+			viewEngine: 'pug',
+			options: {
+				static: path.resolve(__dirname, './static'),
+				sessionSecret: Guid.new(),
+				adminPassword: SHA256.hash(ServerConfig.adminPassword)
+			}
+		}).init().then((server) => {
+			server.start(() => {
+				ServerApp.SourcesUpdating = true;
+				Sources.get(() => {
+					ServerApp.SourcesUpdating = false;
+					ServerApp.startCronTask();
+				});
+			});
+			ServerApp.Server = server;
+		});
+	}
+}
+
+ServerApp.start();
+
+export default ServerApp;

+ 10 - 0
src/middlewares/CookieParserMiddleware.ts

@@ -0,0 +1,10 @@
+import { Middleware } from 'org.crazydoctor.expressts';
+import cookieParser from 'cookie-parser';
+
+class CookieParserMiddleware extends Middleware {
+	protected action = cookieParser();
+	protected order = -99;
+	protected route = null;
+}
+
+export default CookieParserMiddleware;

+ 12 - 0
src/middlewares/NotFoundErrorMiddleware.ts

@@ -0,0 +1,12 @@
+import { Middleware, StatusCodes } from 'org.crazydoctor.expressts';
+import {NextFunction, Request, Response} from 'express';
+
+class NotFoundErrorHandler extends Middleware {
+	protected order = 9998;
+	protected route = null;
+	protected action = (req: Request, res: Response, next: NextFunction): any => {
+		res.status(StatusCodes.NOT_FOUND).render('404', { page: req.url });
+	};
+}
+
+export default NotFoundErrorHandler;

+ 27 - 4
src/routes/GetClass.ts

@@ -2,6 +2,8 @@ import {HttpMethod, Route, StatusCodes} from 'org.crazydoctor.expressts';
 import {Request, Response} from 'express';
 import { ClassMapEntity, Sources } from '../sources/Sources';
 import fs from 'fs';
+import ServerApp from '..';
+import { ISession } from '../session/ISession';
 
 class GetClass extends Route {
 	private processClass(cls: ClassMapEntity): { Class: ClassMapEntity, ClassSource: string } {
@@ -11,12 +13,33 @@ class GetClass extends Route {
 	}
 
 	protected action = (req: Request, res: Response): any => {
+		const session = req.session as ISession;
+		
+		if(ServerApp.SourcesUpdating) {
+			res.status(StatusCodes.ACCEPTED).render('sources-update');
+			return;
+		}
+
 		const className = req.params.className;
 		const cls: ClassMapEntity | null = Sources.findClass(className);
-		if(!cls)
-			res.status(StatusCodes.NOT_FOUND).send(`Class '${className}' not found.`);
-		else
-			res.status(StatusCodes.OK).render('class', Object.assign(this.processClass(cls), { template: 'class', title: `Class: ${className}`, ClassList: Sources.getShortenedClassMap(), RepoNames: Sources.getRepoNames() }));
+		if(!cls) {
+			res.status(StatusCodes.NOT_FOUND).render('class', { isAdmin: session.isAdmin || false, Class: className, ClassSource: '', template: 'class', title: 'Class not found', ClassList: Sources.getShortenedClassMap(), RepoNames: Sources.getRepoNames(), Z8Locales: null, Comments: null });
+		} else {
+			ServerApp.getDb().all('SELECT * FROM comments WHERE class = ?', [cls.name], (err, rows) => {
+				if(err) {
+					res.status(StatusCodes.INTERNAL_SERVER_ERROR).send('Internal server error');
+					return;
+				}
+				const classComments = rows || [];
+				const classCommentsObj: {[key: string] : any} = {};
+
+				classComments.forEach((comment: any) => {
+					classCommentsObj[`${comment.property}`] = comment;
+				});
+
+				res.status(StatusCodes.OK).render('class', Object.assign(this.processClass(cls), { isAdmin: session.isAdmin || false, template: 'class', title: `Class: ${className}`, ClassList: Sources.getShortenedClassMap(), RepoNames: Sources.getRepoNames(), Z8Locales: Sources.Z8Locales, Comments: classCommentsObj }));
+			});
+		}
 	};
 	protected method = HttpMethod.GET;
 	protected order = 1;

+ 20 - 0
src/routes/GetFaq.ts

@@ -0,0 +1,20 @@
+import {HttpMethod, Route, StatusCodes} from 'org.crazydoctor.expressts';
+import {Request, Response} from 'express';
+import ServerApp from '..';
+
+class GetFaq extends Route {
+	protected action = (req: Request, res: Response): any => {
+		if(ServerApp.SourcesUpdating) {
+			res.status(StatusCodes.ACCEPTED).render('sources-update');
+			return;
+		}
+
+		res.status(StatusCodes.OK).render('faq', { template: 'faq', title: 'Doczilla JS Docs - FAQ' });
+	};
+
+	protected method = HttpMethod.GET;
+	protected order = 6;
+	protected route = '/faq';
+}
+
+export default GetFaq;

+ 10 - 2
src/routes/GetIndex.ts

@@ -1,11 +1,19 @@
 import {HttpMethod, Route, StatusCodes} from 'org.crazydoctor.expressts';
 import {Request, Response} from 'express';
 import { Sources } from '../sources/Sources';
+import ServerApp from '..';
+import { ISession } from '../session/ISession';
 
 class GetIndex extends Route {
 	protected action = (req: Request, res: Response): any => {
-		res.status(StatusCodes.OK).render('index', { template: 'index', title: 'Doczilla JS Docs', lastUpdateTime: Sources.time().getTime(), ClassList: Sources.getShortenedClassMap(), RepoNames: Sources.getRepoNames() }, (err, html) => {
-			console.log(this.context.getOption('static'));
+		const session = req.session as ISession;
+
+		if(ServerApp.SourcesUpdating) {
+			res.status(StatusCodes.ACCEPTED).render('sources-update');
+			return;
+		}
+
+		res.status(StatusCodes.OK).render('index', { isAdmin: session.isAdmin || false, template: 'index', title: 'Doczilla JS Docs', lastUpdateTime: Sources.time().getTime(), ClassList: Sources.get(), RepoNames: Sources.getRepoNames() }, (err, html) => {
 			if(err)
 				console.log(err);
 			res.send(html);

+ 27 - 0
src/routes/GetLogin.ts

@@ -0,0 +1,27 @@
+import {HttpMethod, Route, StatusCodes} from 'org.crazydoctor.expressts';
+import {Request, Response} from 'express';
+import ServerApp from '..';
+import { ISession } from '../session/ISession';
+
+class GetLogin extends Route {
+	protected action = (req: Request, res: Response): any => {
+		const session = req.session as ISession;
+    
+		if(ServerApp.SourcesUpdating) {
+			res.status(StatusCodes.ACCEPTED).render('sources-update');
+			return;
+		}
+
+		if(session.isAdmin) {
+			res.redirect('/');
+			return;
+		}
+		res.status(StatusCodes.OK).render('login', { template: 'login', title: 'Doczilla JS Docs - Login' });
+	};
+
+	protected method = HttpMethod.GET;
+	protected order = 3;
+	protected route = '/login';
+}
+
+export default GetLogin;

+ 29 - 0
src/routes/PostAuthorize.ts

@@ -0,0 +1,29 @@
+import { HttpMethod, Route, StatusCodes } from 'org.crazydoctor.expressts';
+import { Request, Response } from 'express';
+import { ISession } from '../session/ISession';
+
+class PostAuthorize extends Route {
+	private AdminLogin = 'Admin';
+
+	protected action = (req: Request, res: Response): any => {
+		const session = req.session as ISession;
+		const params = req.body;
+
+		const login = params.login.trim();
+		const password = params.password.trim(); // SHA256 hashed
+
+		if(login === this.AdminLogin && this.context.options.adminPassword === password) {
+			session.isAdmin = true;
+			res.status(StatusCodes.OK).send('OK');
+			return;
+		}
+
+		res.status(StatusCodes.FORBIDDEN).send('Authentication failed');
+	};
+
+	protected method = HttpMethod.POST;
+	protected order = 2;
+	protected route = '/authorize';
+}
+
+export default PostAuthorize;

+ 46 - 0
src/routes/PostInheritedComments.ts

@@ -0,0 +1,46 @@
+import { HttpMethod, Route, StatusCodes } from 'org.crazydoctor.expressts';
+import { Request, Response } from 'express';
+import ServerApp from '..';
+
+class PostInheritedComments extends Route {
+	protected action = (req: Request, res: Response): any => {
+		const params = req.body;
+		const query = JSON.parse(params.query);
+
+		if(ServerApp.SourcesUpdating) {
+			res.status(StatusCodes.CONFLICT).send('Sources are being updated.');
+			return;
+		}
+
+		const inheritedComments: any = {};
+		const querySize = Object.keys(query).length;
+		let index = 0;
+
+		for(const className of Object.keys(query)) {
+			const props = query[className];
+			ServerApp.getDb().all(`SELECT * FROM comments WHERE class = ? AND property IN (${props.map(() => '?').join(', ')})`, [className, ...props], (err, rows) => {
+				if(err) {
+					res.status(StatusCodes.INTERNAL_SERVER_ERROR).send('Internal server error');
+					return;
+				}
+
+				const commentsObj: any = {};
+
+				rows.forEach((comment: any) => {
+					commentsObj[comment.property] = { comment: comment.comment, timestamp: comment.timestamp };
+				});
+
+				inheritedComments[className] = commentsObj;
+				index++;
+				if(index === querySize)
+					res.status(StatusCodes.OK).send(JSON.stringify(inheritedComments));
+			});
+		}
+	};
+
+	protected method = HttpMethod.POST;
+	protected order = 8;
+	protected route = '/getInheritedComments';
+}
+
+export default PostInheritedComments;

+ 18 - 0
src/routes/PostLogout.ts

@@ -0,0 +1,18 @@
+import { HttpMethod, Route, StatusCodes } from 'org.crazydoctor.expressts';
+import { Request, Response } from 'express';
+import { ISession } from '../session/ISession';
+
+class PostLogout extends Route {
+	protected action = (req: Request, res: Response): any => {
+		const session = req.session as ISession;
+		
+		session.isAdmin = false;
+		res.status(StatusCodes.OK).send('OK');
+	};
+
+	protected method = HttpMethod.POST;
+	protected order = 4;
+	protected route = '/logout';
+}
+
+export default PostLogout;

+ 65 - 0
src/routes/PostUpdateComment.ts

@@ -0,0 +1,65 @@
+import { HttpMethod, Route, StatusCodes } from 'org.crazydoctor.expressts';
+import { Request, Response } from 'express';
+import { ISession } from '../session/ISession';
+import ServerApp from '..';
+
+class PostUpdateComment extends Route {
+	private AdminLogin = 'Admin';
+
+	protected action = (req: Request, res: Response): any => {
+		const session = req.session as ISession;
+		const params = req.body;
+
+		const className = params.class.trim();
+		const propertyName = params.property.trim();
+		const comment = params.comment;
+
+		if(!session.isAdmin) {
+			res.status(StatusCodes.FORBIDDEN).send('Access denied');
+			return;
+		}
+
+		const db = ServerApp.getDb();
+
+		db.get('SELECT * FROM comments WHERE class = ? AND property = ?', [className, propertyName], (err: any, row: any) => {
+			if(err) {
+				res.status(StatusCodes.INTERNAL_SERVER_ERROR).send('Internal server error');
+				return;
+			}
+
+			if(row) {
+				if(comment.length > 0) {
+					db.run('UPDATE comments SET comment = ?, timestamp = ? WHERE class = ? AND property = ?', [comment, new Date().getTime(), className, propertyName], function(err) {
+						if(err) {
+							res.status(StatusCodes.INTERNAL_SERVER_ERROR).send('Internal server error');
+							return;
+						}
+						res.status(StatusCodes.OK).send('UPDATED');
+					});
+				} else {
+					db.run('DELETE FROM comments WHERE class = ? AND property = ?', [className, propertyName], function(err) {
+						if(err) {
+							res.status(StatusCodes.INTERNAL_SERVER_ERROR).send('Internal server error');
+							return;
+						}
+						res.status(StatusCodes.OK).send('DELETED');
+					});
+				}
+			} else {
+				db.run('INSERT INTO comments (class, property, comment, timestamp) VALUES (?, ?, ?, ?)', [className, propertyName, comment, new Date().getTime()], function(err) {
+					if(err) {
+						res.status(StatusCodes.INTERNAL_SERVER_ERROR).send('Internal server error');
+						return;
+					}
+					res.status(StatusCodes.CREATED).send('CREATED');
+				});
+			}
+		});
+	};
+
+	protected method = HttpMethod.POST;
+	protected order = 5;
+	protected route = '/updateComment';
+}
+
+export default PostUpdateComment;

+ 56 - 8
src/routes/PostUpdateSources.ts

@@ -1,22 +1,70 @@
 import {HttpMethod, Route, StatusCodes} from 'org.crazydoctor.expressts';
 import {Request, Response} from 'express';
+import { spawn } from 'child_process';
+import * as path from 'path';
+import ServerApp from '..';
 import { Sources } from '../sources/Sources';
+import { ISession } from '../session/ISession';
 
 class PostUpdateSources extends Route {
+	protected method = HttpMethod.POST;
+	protected order = 1;
+	protected route = '/updateSources';
 
 	protected action = (req: Request, res: Response): any => {
+		const session = req.session as ISession;
+		
+		if(ServerApp.SourcesUpdating) {
+			res.status(StatusCodes.CONFLICT).send('The source update is already in progress.');
+			return;
+		}
+
+		if(session.isAdmin)
+			this.updateSources(res);
+		else
+			res.status(StatusCodes.FORBIDDEN).send('Forbidden.');
+	};
+
+	private updateSources(res: Response): void {
 		try {
-			Sources.update(() => {
-				res.status(StatusCodes.OK).send('OK');
+			this.context.logInfo('[POST /updateSources] Sources update started...');
+			ServerApp.SourcesUpdating = true;
+			const update = spawn('node', [path.resolve(__dirname, '../util/UpdateSources.js')]);
+	
+			update.stdout.on('data', (data) => {
+				console.log(data.toString());
+			});
+
+			update.stderr.on('data', (data) => {
+				console.error(data.toString());
 			});
-		} catch(e) {
+
+			update.on('close', (code) => {
+				ServerApp.SourcesUpdating = false;
+				if (code === 0) {
+					Sources.get(() => {
+						this.context.logInfo('[POST /updateSources] Sources update finished!');
+						res.status(StatusCodes.OK).send('OK');
+					});
+				} else {
+					this.context.logError('[POST /updateSources] Sources update failed.');
+					res.status(StatusCodes.INTERNAL_SERVER_ERROR).send('Internal server error');
+				}
+			});
+	
+			update.on('error', (e) => {
+				ServerApp.SourcesUpdating = false;
+				this.context.logError('[POST /updateSources] Sources update failed.');
+				res.status(StatusCodes.INTERNAL_SERVER_ERROR).send('Internal server error');
+				console.error(e);
+			});
+		} catch (e) {
+			ServerApp.SourcesUpdating = false;
+			this.context.logError('[POST /updateSources] Sources update failed.');
 			res.status(StatusCodes.INTERNAL_SERVER_ERROR).send('Internal server error');
+			console.error(e);
 		}
-	};
-
-	protected method = HttpMethod.POST;
-	protected order = 1;
-	protected route = '/updateSources';
+	}
 }
 
 export default PostUpdateSources;

+ 5 - 0
src/session/ISession.ts

@@ -0,0 +1,5 @@
+import { Session } from 'express-session';
+
+export interface ISession extends Session {
+  isAdmin: boolean;
+}

+ 163 - 17
src/sources/Analyzer.ts

@@ -1,11 +1,14 @@
 import Progress from '../util/Progress';
-import { ClassMap, ClassMapEntity, Sources } from './Sources';
+import { ClassMap, ClassMapEntity, Sources, Z8ClassProperties } from './Sources';
 import fs from 'fs';
 import { JSDOM, VirtualConsole } from 'jsdom';
 
 class Analyzer {
-	public static analyze(classMap: ClassMap, callback?: () => any): void {
-		const script = fs.readFileSync(Sources.dzAppPath, 'utf8').toString();
+	public static analyze(classMap: ClassMap | null, callback?: () => any, silent?: boolean): void {
+		let script = fs.readFileSync(Sources.dzAppPath, 'utf8').toString();
+
+		// replace Z8.$ function to prevent showing messages from Z8.locales in analyzed classes
+		script = script.replace('return i18n.getMessage(key, data);', 'return `Z8.$(\'${key}\'${data ? \', ...\' : \'\'})`;');
 		
 		const virtualConsole = new VirtualConsole();
 		virtualConsole.on('jsdomError', (error) => {});
@@ -18,19 +21,23 @@ class Analyzer {
 
 		dom.window.document.addEventListener('DOMContentLoaded', () => {
 			try {
-				const Z8Classes = (dom.window as any).Z8.classes;
+				if(classMap) {
+					const Z8Classes = (dom.window as any).Z8.classes;
+
+					const progress = silent ? null : Progress.start('Sources update, Step 4 [Analyze]:', Object.keys(Z8Classes).length + 2 * Object.keys(classMap).length + 1);
 
-				const progress = Progress.start('Sources update, Step 4 [Analyze]:', Object.keys(Z8Classes).length + Object.keys(classMap).length + 1);
+					Analyzer.filterZ8Classes(Z8Classes, progress);
+					Analyzer.analyzeZ8Classes(Z8Classes, classMap, progress);
+					Analyzer.postProcessClassMap(classMap, progress);
 
-				Analyzer.filterZ8Classes(Z8Classes, progress);
-				Analyzer.analyzeZ8Classes(Z8Classes, classMap, progress);
+					fs.writeFileSync(Sources.classMapPath, JSON.stringify(classMap), { flag: 'a+', encoding: 'utf8' });
 
-				fs.writeFileSync(Sources.classMapPath, JSON.stringify(classMap), { flag: 'a+', encoding: 'utf8' });
+					progress && progress.finish();
+				}
 
-				progress.finish();
+				Sources.Z8Locales = (dom.window as any).Z8.locales;
 
-				if(callback)
-					callback();
+				callback && callback();
 			} catch (e) {
 				console.log(e);
 			}
@@ -39,24 +46,24 @@ class Analyzer {
 		});
 	}
 
-	private static filterZ8Classes(Z8Classes: any, progress: Progress): any {
+	private static filterZ8Classes(Z8Classes: any, progress: Progress | null): any {
 		const classesToDelete = [];
 		for(const key of Object.keys(Z8Classes)) {
 			const shortClassName = Analyzer.getShortClassName(Z8Classes[key]);
 			if(key !== shortClassName && shortClassName != null)
 				classesToDelete.push(shortClassName);
-			progress.next();
+			progress && progress.next();
 		}
 
-		progress.sumTotal(classesToDelete.length);
+		progress && progress.sumTotal(classesToDelete.length);
 
 		for(const cls of classesToDelete) {
 			delete Z8Classes[cls];
-			progress.next();
+			progress && progress.next();
 		}
 	}
 
-	private static analyzeZ8Classes(Z8Classes: any, classMap: ClassMap, progress: Progress): void {
+	private static analyzeZ8Classes(Z8Classes: any, classMap: ClassMap, progress: Progress | null): void {
 		for(const className of Object.keys(classMap)) {
 			let cls = classMap[className];
 			const z8Cls = Z8Classes[className];
@@ -65,10 +72,78 @@ class Analyzer {
 			cls = Analyzer.setExtends(cls, Analyzer.getSuperclassName(z8Cls));
 			cls = Analyzer.setShortName(cls, Analyzer.getShortClassName(z8Cls));
 			cls = Analyzer.setMixins(cls, Analyzer.getMixins(z8Cls, classMap));
+			cls = Analyzer.setStatics(cls, Analyzer.getStatics(z8Cls));
+			cls = Analyzer.setProperties(cls, Analyzer.getProperties(z8Cls));
 
 			classMap[className] = cls;
 
-			progress.next();
+			progress && progress.next();
+		}
+	}
+
+	private static postProcessClassMap(classMap: ClassMap, progress: Progress | null): void {
+		for(const className of Object.keys(classMap)) {
+			const cls = classMap[className];
+			const parents = cls.parentsBranch;
+			const properties = cls.properties;
+			const selfProperties = properties.map(item => item);
+			let dynamicProperties = cls.dynamicProperties;
+
+			for(let i = parents.length - 1; i >= 0; i--) {
+				const parentName = parents[i];
+				const parent = classMap[parentName];
+				const parentProperties = parent.properties;
+				const parentDynamicProperties = parent.dynamicProperties;
+
+				for(const parentProperty of parentProperties) {
+					const parentPropertyCopy = Object.assign({}, parentProperty);
+					const propIndex = properties.findIndex((item, index) => item.key === parentProperty.key);
+
+					parentPropertyCopy.inherited = true;
+					parentPropertyCopy.nearestParent = parentName;
+					parentPropertyCopy.overridden = false;
+
+					if(propIndex === -1) {
+						properties.push(parentPropertyCopy);
+					} else {
+						parentPropertyCopy.overridden = selfProperties.some((item) => item.key === parentPropertyCopy.key);
+						if(parentPropertyCopy.overridden)
+							parentPropertyCopy.value = selfProperties[propIndex].value;
+						properties[propIndex] = parentPropertyCopy;
+					}
+
+					dynamicProperties = dynamicProperties.filter((item) => item.key !== parentProperty.key);
+				}
+
+				for(const parentProperty of parentDynamicProperties) {
+					const parentPropertyCopy = Object.assign({}, parentProperty);
+					const propIndex = properties.findIndex((item, index) => item.key === parentProperty.key);
+
+					parentPropertyCopy.inherited = true;
+					parentPropertyCopy.dynamic = false;
+					parentPropertyCopy.overridden = false;
+					parentPropertyCopy.nearestParent = parentName;
+					
+					if(propIndex === -1) {
+						properties.push(parentPropertyCopy);
+					} else {
+						parentPropertyCopy.overridden = selfProperties.some((item) => item.key === parentPropertyCopy.key);
+						if(parentPropertyCopy.overridden) {
+							parentPropertyCopy.value = selfProperties[propIndex].value;
+							parentPropertyCopy.type = selfProperties[propIndex].type;
+						}
+						properties[propIndex] = parentPropertyCopy;
+					}
+
+					dynamicProperties = dynamicProperties.filter((item) => item.key !== parentProperty.key);
+				}
+			}
+
+			cls.properties = properties;
+			cls.dynamicProperties = dynamicProperties;
+			classMap[className] = cls;
+
+			progress && progress.next();
 		}
 	}
 
@@ -113,6 +188,66 @@ class Analyzer {
 		});
 	}
 
+	private static getProperties(z8Cls: any): Z8ClassProperties[] {
+		const prototype = z8Cls.prototype;
+		const superclass = z8Cls.superclass;
+		const excludeList = ['self', '$class', '$className', '$shortClassName', '$previous', '$owner', 'superclass'];
+		const notInherited = ['callParent', 'newInstance'];
+
+		const superclassProperties = Object.keys(superclass).filter((key) => {
+			return !excludeList.includes(key);
+		});
+
+		return Object.keys(prototype).filter((key) => {
+			return !excludeList.includes(key);
+		}).map((key) => {
+			const value = prototype[key];
+			const type = Analyzer.propertyType(typeof value);
+			const strValue = Analyzer.propertyValueToString(key, value, type);
+			const overridden = superclassProperties.includes(key) && !notInherited.includes(key);
+
+			return { static: false, type: strValue.indexOf('class__') === 0 ? 'class' : type, key: key, value: strValue.replace('class__', ''), inherited: overridden, dynamic: false, overridden: overridden };
+		});
+	}
+
+	private static getStatics(z8Cls: any): Z8ClassProperties[] {
+		const excludeList = ['self', '$class', '$className', '$shortClassName', '$previous', '$owner', 'superclass'];
+
+		return Object.keys(z8Cls).filter((key) => {
+			return !excludeList.includes(key);
+		}).map((key) => {
+			const value = z8Cls[key];
+			const type = Analyzer.propertyType(typeof value);
+			try {
+				const strValue = Analyzer.propertyValueToString(key, value, type);
+				return { static: true, type: strValue.indexOf('class__') === 0 ? 'class' : type, key: key, value: strValue.replace('class__', ''), inherited: false, dynamic: false, overridden: false };
+			} catch(e) {
+				console.log(Analyzer.getClassName(z8Cls), value, type);
+				throw e;
+			}
+		});
+	}
+
+	private static propertyType(type: string): string {
+		if(type === 'function')
+			return 'method';
+		return type;
+	}
+
+	private static propertyValueToString(key: string, value: any, type: string): string {
+		if(value == null)
+			return 'null';
+
+		switch(type) {
+		case 'method':
+			return value.prototype && value.prototype.$class ? `class__${Analyzer.getShortClassName(value) || Analyzer.getClassName(value)}` : `${key}(${value.toString().split('(')[1].split(')')[0]})`;
+		case 'object':
+			return value.prototype ? Analyzer.getShortClassName(value) || Analyzer.getClassName(value) : (value.$className ? value.$shortClassName || value.$className : JSON.stringify(value));
+		default:
+			return value.toString();
+		}
+	}
+
 	// ClassMapEntity methods
 	private static appendChild(cls: ClassMapEntity, childName: string): ClassMapEntity {
 		if(!cls.children.includes(childName))
@@ -151,6 +286,17 @@ class Analyzer {
 			cls.mixedIn.push(mixedTo);
 		return cls;
 	}
+
+	private static setStatics(cls: ClassMapEntity, statics: Z8ClassProperties[]): ClassMapEntity {
+		cls.statics = statics;
+		return cls;
+	}
+
+	private static setProperties(cls: ClassMapEntity, properties: Z8ClassProperties[]): ClassMapEntity {
+		cls.properties = cls.properties.concat(properties);
+		cls.dynamicProperties = cls.dynamicProperties.filter(item1 => !cls.properties.some(item2 => item2.key === item1.key)).filter(item1 => !cls.statics.some(item2 => item2.key === item1.key));
+		return cls;
+	}
 }
 
 export default Analyzer;

+ 82 - 27
src/sources/Sources.ts

@@ -5,6 +5,17 @@ import * as path from 'path';
 import Progress from '../util/Progress';
 import Analyzer from './Analyzer';
 
+type Z8ClassProperties = {
+	static: boolean,
+	type: string,
+	key: string,
+	value: string,
+	inherited: boolean,
+	dynamic: boolean,
+	overridden: boolean,
+	nearestParent?: string
+};
+
 type ClassMapEntity = {
 	name: string,
 	filePath: string,
@@ -13,7 +24,10 @@ type ClassMapEntity = {
 	children: string[],
 	parentsBranch: string[],
 	mixins: string[],
-	mixedIn: string[]
+	mixedIn: string[],
+	statics: Z8ClassProperties[],
+	properties: Z8ClassProperties[],
+	dynamicProperties: Z8ClassProperties[]
 };
 
 type ClassMap = {
@@ -30,11 +44,12 @@ class Sources {
 	private static repoCollectFromPaths: string[]			= [];
 	private static sourcesPath: string								= `${os.homedir()}/.doczilla_js_docs`;
 
-	public static sourcesCopyPath											= path.resolve(__dirname, '../JsSources');
+	public static sourcesCopyPath											= `${Sources.sourcesPath}/JsSources`;
 	public static dzAppPath														= `${Sources.sourcesCopyPath}/DZApp.js`;
 	public static classMapPath				    						= `${Sources.sourcesCopyPath}/ClassMap.json`;
 
 	public static ClassMap: ClassMap = {};
+	public static Z8Locales: { [key: string]: { [key: string]: string } } = {};
 
 	private static init(): void {
 		// refresh
@@ -46,9 +61,9 @@ class Sources {
 		Sources.repoSparseCheckoutPaths = [];
 		Sources.repoCollectFromPaths = [];
 
-		const SourcesConfig = JSON.parse(fs.readFileSync(path.resolve(__dirname,'../sources.config.json'), { encoding: 'utf-8' }).toString());
+		const SourcesConfig = JSON.parse(fs.readFileSync(path.resolve(__dirname,'../config.json'), { encoding: 'utf-8' }).toString()).sources;
 
-		Sources.sourcesPath = `${os.homedir()}/${SourcesConfig.cloneTo}`;
+		Sources.sourcesPath = `${os.homedir()}/${SourcesConfig.assetsDir}`;
 		for(const repoInfo of SourcesConfig.repos) {
 			Sources.repoNames.push(repoInfo.name);
 			Sources.repoUrls.push(repoInfo.url);
@@ -62,8 +77,8 @@ class Sources {
 	public static get(callback?: () => any): ClassMap {
 		if(fs.existsSync(Sources.classMapPath)) {
 			Sources.ClassMap = JSON.parse(fs.readFileSync(Sources.classMapPath).toString());
-			Sources.repoNames = JSON.parse(fs.readFileSync(path.resolve(__dirname,'../sources.config.json'), { encoding: 'utf-8' }).toString()).repos.map((repo: { name: string; }) => repo.name);
-			callback && callback();
+			Sources.repoNames = JSON.parse(fs.readFileSync(path.resolve(__dirname,'../config.json'), { encoding: 'utf-8' }).toString()).sources.repos.map((repo: { name: string; }) => repo.name);
+			Analyzer.analyze(null, callback, true);
 		} else {
 			Sources.update(callback);
 		}
@@ -100,52 +115,52 @@ class Sources {
 		return null;
 	}
 
-	public static update(callback?: () => any): void {
+	public static update(callback?: () => any, silent?: boolean): void {
 		Sources.init();
 
-		Sources.clone();
-		Sources.copy();
-		Sources.collect();
+		Sources.clone(silent);
+		Sources.copy(silent);
+		Sources.collect(silent);
 
-		Analyzer.analyze(Sources.ClassMap, callback);
+		Analyzer.analyze(Sources.ClassMap, callback, silent);
 	}
 
-	private static clone(): void {
+	private static clone(silent?: boolean): void {
 		if(!fs.existsSync(Sources.sourcesPath))
 			fs.mkdirSync(Sources.sourcesPath);
 
-		const progress = Progress.start('Sources update, Step 1 [Clone/Pull]:', Sources.repoNames.length);
+		const progress = silent ? null : Progress.start('Sources update, Step 1 [Clone/Pull]:', Sources.repoNames.length);
 		for(let i = 0; i < Sources.repoNames.length; i++) {
 			if(fs.existsSync(Sources.repoSourcePaths[i])) {
 				execSync('git pull', { encoding: 'utf8', cwd: Sources.repoSourcePaths[i], stdio: [] });
-				progress.next();
+				progress && progress.next();
 			} else {
 				execSync(Sources.getCloneCommand(Sources.repoUrls[i]), { encoding: 'utf8', cwd: Sources.sourcesPath, stdio: [] });
 				execSync(`git sparse-checkout set ${Sources.repoSparseCheckoutPaths[i]}`, { encoding: 'utf8', cwd: Sources.repoSourcePaths[i], stdio: [] });
-				progress.next();
+				progress && progress.next();
 			}
 		}
 
-		progress.finish();
+		progress && progress.finish();
 	}
 
-	private static copy(): void {
+	private static copy(silent?: boolean): void {
 		if(fs.existsSync(Sources.sourcesCopyPath))
 			fs.rmSync(Sources.sourcesCopyPath, { recursive: true, force: true });
 		fs.mkdirSync(Sources.sourcesCopyPath);
 
-		const progress = Progress.start('Sources update, Step 2 [Copy]:', Sources.repoNames.length);
+		const progress = silent ? null : Progress.start('Sources update, Step 2 [Copy]:', Sources.repoNames.length);
 
 		for(let i = 0; i < Sources.repoNames.length; i++) {
 			fs.mkdirSync(Sources.repoSourceCopyPaths[i]);
 			fs.cpSync(`${Sources.repoSourcePaths[i]}/${Sources.repoCollectFromPaths[i]}`, Sources.repoSourceCopyPaths[i], { recursive: true });
-			progress.next();
+			progress && progress.next();
 		}
 
-		progress.finish();
+		progress && progress.finish();
 	}
 
-	private static collect(): void {
+	private static collect(silent?: boolean): void {
 		if(fs.existsSync(Sources.dzAppPath))
 			fs.rmSync(Sources.dzAppPath, { force: true });
 
@@ -160,32 +175,72 @@ class Sources {
 			totalBuildordersLength += bo.length;
 		}
 
-		const progress = Progress.start('Sources update, Step 3 [Collect]:', totalBuildordersLength);
+		const progress = silent ? null : Progress.start('Sources update, Step 3 [Collect]:', totalBuildordersLength);
 
 		for(let i = 0; i < Sources.repoNames.length; i++) {
 			Sources.processBuildorder(buildorders[i], Sources.repoSourceCopyPaths[i], Sources.repoNames[i], progress);
 		}
 
-		progress.finish();
+		progress && progress.finish();
 	}
 
-	private static processBuildorder(buildorder: string[], sourcesRoot: string, pathPrefix: string, progress: Progress): void {
+	private static processBuildorder(buildorder: string[], sourcesRoot: string, pathPrefix: string, progress: Progress | null): void {
 		const z8DefinePattern: RegExp = /Z8\.define\('([^']+)'/g;
+		const dynamicPropertiesPattern: RegExp = /this\.([\wа-яА-Я_$]+)\s*=;/g;
+		const dynamicConfigPropertiesPattern: RegExp = /this\.([\wа-яА-Я_$]+)/g;
 
 		for(const filePath of buildorder) {
 			const content = fs.readFileSync(`${sourcesRoot}/${filePath}`, 'utf8').toString();
 			const defines: string[] = Array.from(content.matchAll(z8DefinePattern), match => match[1]);
 			for(const define of defines) {
-				Sources.ClassMap[define] = { name: define, filePath: `${pathPrefix}/${filePath}`, children: [], extends: null, parentsBranch: [], mixins: [], mixedIn: [] };
+				const classRx = new RegExp(`Z8\\.define\\(\'${define}\',\\s*\\{(?:.|[\r\n])+?^\\}\\);?`, 'gm');
+				const classContentRxArray = content.match(classRx); // change to `classContent = content.match(classRx)![0]` after `}\n);` issue fixed
+				const classContent = classContentRxArray ? classContentRxArray[0] : '';
+				const dynamicProperties = Sources.cleanDuplicates(Array.from(classContent.matchAll(dynamicPropertiesPattern), (match) => {
+					return {
+						static: false,
+						type: 'undefined',
+						key: match[1],
+						value: '',
+						inherited: false,
+						dynamic: true,
+						overridden: false
+					};
+				}));
+
+				const dynamicConfigProperties = Sources.cleanDuplicates(Array.from(classContent.matchAll(dynamicConfigPropertiesPattern), (match) => {
+					return {
+						static: false,
+						type: 'undefined',
+						key: match[1],
+						value: '',
+						inherited: false,
+						dynamic: true,
+						overridden: false
+					};
+				}).filter(item1 => !dynamicProperties.some(item2 => item2.key === item1.key)));
+
+				Sources.ClassMap[define] = { name: define, filePath: `${pathPrefix}/${filePath}`, children: [], extends: null, parentsBranch: [], mixins: [], mixedIn: [], properties: dynamicProperties, dynamicProperties: dynamicConfigProperties, statics: [] };
 			}
 			fs.writeFileSync(Sources.dzAppPath, content + '\n', { flag: 'a+', encoding: 'utf8' });
-			progress.next();
+			progress && progress.next();
 		}
 	}
 
 	private static getCloneCommand(repoUrl: string): string {
 		return `git clone --depth=1 --single-branch --branch dev --filter=blob:none --sparse ${repoUrl}`;
 	}
+
+	private static cleanDuplicates(array: Z8ClassProperties[]): Z8ClassProperties[] {
+		for(let i = array.length - 1; i >= 0; i--) {
+			const currentItem = array[i];
+			const duplicateIndex = array.findIndex((item, index) => index !== i && item.key === currentItem.key);
+			if (duplicateIndex !== -1) {
+				array.splice(i, 1);
+			}
+		}
+		return array;
+	}
 }
 
-export { Sources, ClassMap, ClassMapEntity };
+export { Sources, ClassMap, ClassMapEntity, Z8ClassProperties };

+ 13 - 0
src/util/SHA256.ts

@@ -0,0 +1,13 @@
+import { createHash } from 'crypto';
+
+class SHA256 {
+	public static hash(string: string): string {
+		return createHash('sha256').update(string).digest('hex');
+	}
+
+	public static check(string: string, hash: string): boolean {
+		return SHA256.hash(string) === hash;
+	}
+}
+
+export default SHA256;

+ 13 - 0
src/util/UpdateSources.ts

@@ -0,0 +1,13 @@
+/**
+ * Run as child process via node
+ */
+
+import { Sources } from '../sources/Sources';
+
+class UpdateSources {
+	public static start(): void {
+		Sources.update(undefined, true);
+	}
+}
+
+UpdateSources.start();

+ 12 - 0
src/views/404.pug

@@ -0,0 +1,12 @@
+html
+	head
+		include imports/meta.import.pug
+		title= 'Doczilla JS Docs - 404'
+		link(rel="stylesheet", href="/style/style.css")
+	body.page-not-found
+		div.message-container
+			div.main-title
+				div.logo
+				div.main-title-text= 'Doczilla JS Docs'
+			div.page-not-found-image
+			div.page-not-found-text= 'Page `' + page + '` not found'

+ 44 - 31
src/views/class.pug

@@ -7,6 +7,9 @@ html
 			const Class = !{JSON.stringify(Class)};
 			const ClassSource = !{JSON.stringify(ClassSource)};
 			const RepoNames = !{JSON.stringify(RepoNames)};
+			const Z8Locales = !{JSON.stringify(Z8Locales)};
+			const Comments = !{JSON.stringify(Comments)};
+			const isAdmin = !{JSON.stringify(isAdmin)};
 		include imports/cdclientlib.import.pug
 		include imports/codemirror.import.pug
 		include imports/codemirror.javascript.import.pug
@@ -18,34 +21,44 @@ html
 			div.left
 				include parts/left-header.part.pug
 			div.right
-				div.right-header
-					div.right-header-top
-						div.class-name
-							div.class-icon
-							span= Class.name
-						div.display-mode-buttons
-							div.display-mode-button.mode-tabs(title='Display mode: Tabs', data-display-mode='mode-tabs')
-							div.display-mode-button.mode-list(title='Display mode: List', data-display-mode='mode-list')
-					div.tabs
-						div.tab.editor(data-tab='Editor')= 'Editor'
-						div.tab.methods(data-tab='Methods')= 'Methods'
-						div.tab.properties(data-tab='Properties')= 'Properties'
-						div.tab.parents(data-tab='Parents')= 'Parents'
-						div.tab.mixins(data-tab='Mixins')= 'Mixins'
-						div.tab.children(data-tab='Children')= 'Children'
-						div.tab.mixedin(data-tab='MixedIn')= 'Mixed in'
-				div.content
-					div.content-tab#parents
-						h3.content-tab-title= 'Parents'
-					div.content-tab#methods
-						h3.content-tab-title= 'Methods'
-					div.content-tab#properties
-						h3.content-tab-title= 'Properties'
-					div.content-tab#mixins
-						h3.content-tab-title= 'Mixins'
-					div.content-tab#mixedin
-						h3.content-tab-title= 'Mixed In'
-					div.content-tab#children
-						h3.content-tab-title= 'Children'
-					div.content-tab#editor
-						h3.content-tab-title= 'Editor'
+				if(Class.name)
+					div.right-header
+						div.right-header-top
+							div.class-name
+								div.class-icon
+								span= Class.name
+								span= ' (Documented: '
+								span.class-documented-percentage= '0%'
+								span= ')'
+							div.display-mode-buttons
+								div.display-mode-button.mode-tabs(title='Display mode: Tabs', data-display-mode='mode-tabs')
+								div.display-mode-button.mode-list(title='Display mode: List', data-display-mode='mode-list')
+						div.tabs
+							div.tab.editor(data-tab='Editor')= 'Editor'
+							div.tab.methods(data-tab='Methods')= 'Methods'
+							div.tab.properties(data-tab='Properties')= 'Properties'
+							div.tab.parents(data-tab='Parents')= 'Parents'
+							div.tab.mixins(data-tab='Mixins')= 'Mixins'
+							div.tab.children(data-tab='Children')= 'Children'
+							div.tab.mixedin(data-tab='MixedIn')= 'Mixed in'
+					div.content
+						div.content-tab#parents
+							h3.content-tab-title= 'Parents'
+						div.content-tab#methods
+							h3.content-tab-title= 'Methods'
+						div.content-tab#properties
+							h3.content-tab-title= 'Properties'
+						div.content-tab#mixins
+							h3.content-tab-title= 'Mixins'
+						div.content-tab#mixedin
+							h3.content-tab-title= 'Mixed In'
+						div.content-tab#children
+							h3.content-tab-title= 'Children'
+						div.content-tab#editor
+							h3.content-tab-title= 'Editor'
+				else
+					div.right-header.class-not-found
+						div.class-not-found-image
+						div.class-not-found-text= 'Class `' + Class + '` not found'
+			div.context-menu.hidden
+			div.context-menu-overlay.hidden

+ 13 - 0
src/views/faq.pug

@@ -0,0 +1,13 @@
+html
+	head
+		include imports/meta.import.pug
+		title= title
+		include imports/cdclientlib.import.pug
+		include imports/global.import.pug 
+		include imports/page.import.pug
+	body
+		div.main
+			div.main-title
+				div.logo
+				div.title-text= 'Doczilla JS Docs: FAQ'
+			div.faq-content

+ 1 - 0
src/views/index.pug

@@ -7,6 +7,7 @@ html
 			const Class = null;
 			const LastUpdateTime = !{JSON.stringify(lastUpdateTime)};
 			const RepoNames = !{JSON.stringify(RepoNames)};
+			const isAdmin = !{JSON.stringify(isAdmin)};
 		include imports/cdclientlib.import.pug
 		include imports/codemirror.import.pug
 		include imports/jquery.import.pug

+ 20 - 0
src/views/login.pug

@@ -0,0 +1,20 @@
+html
+	head
+		include imports/meta.import.pug
+		title= title
+		include imports/cdclientlib.import.pug
+		include imports/global.import.pug 
+		include imports/page.import.pug
+	body
+		div.main
+			form.login-form
+				div.login-form-header
+					div.main-title
+						div.logo
+						div.title-text= 'Doczilla JS Docs'
+				div.login-form-failure
+				div.login-form-login
+					input.login-form-login-input(type="text", autocomplete="login", placeholder="Login")
+				div.login-form-password
+					input.login-form-password-input(type="password", autocomplete="password", placeholder="Password")
+				div.login-form-button= 'Login'

+ 10 - 3
src/views/parts/left-header.part.pug

@@ -1,10 +1,17 @@
 div.left-header
-	div.title
+	div.main-title
 		div.logo
-		div.title-text= 'Doczilla JS Docs'
-	div.class-list-mode
+		div.main-title-text= 'Doczilla JS Docs'
+	div.left-header-toolbar
+		if(isAdmin)
+			div.auth-button.logout-button
+		else
+			div.auth-button.login-button
 		div.class-list-mode-text= 'List display mode:'
 		div.class-list-mode-button.structurized(data-mode='structurized')
 		div.class-list-mode-button.unstructurized(data-mode='unstructurized')
+		if(isAdmin)
+			div.refresh-button
+		div.faq-button
 	include ../modules/search.module.pug
 include ../modules/class-list.module.pug

+ 13 - 0
src/views/sources-update.pug

@@ -0,0 +1,13 @@
+html
+	head
+		include imports/meta.import.pug
+		title= 'Doczilla JS Docs - Updating...'
+		script.
+			setTimeout(() => { location.reload(); }, 10000);
+		link(rel="stylesheet", href="/style/style.css")
+	body.sources-updating
+		div.message-container
+			div.main-title
+				div.logo
+				div.main-title-text= 'Doczilla JS Docs'
+			div.message= 'Sources are being updated...'

+ 30 - 4
static/App.js

@@ -2,14 +2,40 @@ class App {
   static Version = '#_version_';
   static CookieName = 'doczilla_js_docs_cookie';
 
+  static CodeMirrorProperties = {
+    Value: 'value',
+    Mode: 'mode',
+    Readonly: 'readOnly',
+    LineNumbers: 'lineNumbers',
+    MatchBrackets: 'matchBrackets',
+    ScrollbarStyle: 'scrollbarStyle',
+    Theme: 'theme',
+    ConfigureMouse: 'configureMouse'
+  };
+
   static start() {
-    $('.left').resizable({ handles: 'e' });
-    DOM.get('.left>.left-header>.title').on('click', (e) => {
-      Url.goTo('/');
+    typeof $ !== 'undefined' && $('.left').length > 0 && $('.left').resizable({ 'handles': 'e' });
+
+    const mainTitle = DOM.get('.main-title');
+    mainTitle && mainTitle.on('click', (e) => Url.goTo('/'));
+
+    const loginButton = DOM.get('.login-button');
+    loginButton && loginButton.on('click', (e) => Url.goTo('/login'));
+
+    const logoutButton = DOM.get('.logout-button');
+    logoutButton && logoutButton.on('click', (e) => {
+      if(confirm('Do you really want to logout?'))
+        fetch('/logout', {
+          method: 'POST'
+        }).then(() => {
+          Url.reload();
+        });
     });
   }
 }
 
 window_.on(DOM.Events.Load, (e) => {
   App.start();
-});
+});
+
+DOM.documentOn(DOM.Events.ContextMenu, e => e.preventDefault());

+ 303 - 194
static/CDClientLib/CDClientLib.js

@@ -3,37 +3,29 @@
 	Author: CrazyDoctor (Oleg Karataev)
 */
 
-function isEmpty(val) {
-	return val === null || val === undefined ||
-				val === '' || val.length === 0;
-}
-
-function isJsonString(str) {
-	try {
-		JSON.parse(str);
-	} catch (e) {
-		return false;
-	}
-	return true;
-}
-
 class CDElement {
 
 	constructor(el) {
 		this.element = el;
-    el.cdelement = this;
-    try {
-      this.events = Object.keys(getEventListeners(el));
-    } catch(e) {
-      this.events = [];
-    }
-  }
+		el.cdelement = this;
+		try {
+			this.events = Object.keys(getEventListeners(el));
+		} catch(e) {
+			this.events = [];
+		}
+	}
 
-  static get(el) {
-    if(el.cdelement)
-      return el.cdelement;
-    return el.cdelement = new CDElement(el);
-  }
+	static get(el) {
+    if(el == null)
+      return null;
+		if(el instanceof CDElement)
+			return el;
+		if(el.cdelement)
+			return el.cdelement;
+		if(el instanceof Element || el instanceof Window)
+			return el.cdelement = new CDElement(el);
+		throw 'CDElement.get() error';
+	}
 
 	get() {
 		return this.element;
@@ -44,43 +36,48 @@ class CDElement {
 	}
 
 	getFirstChild(selector) {
-    const children = Array.from(this.get().children);
-    if (children.length == 0)
-      return null;
-		if (isEmpty(selector))
+		const children = Array.from(this.get().children);
+		if (children.length == 0)
+			return null;
+		if (CDUtils.isEmpty(selector))
 			return CDElement.get(children[0]);
-    const child = this.get().querySelector(selector);
-    if(child)
-		  return CDElement.get(child);
-    return null;
+		const child = this.get().querySelector(selector);
+		if(child)
+			return CDElement.get(child);
+		return null;
 	}
 
-  hasChildren() {
-    return Array.from(this.get().children).length > 0;
-  }
+	hasChildren() {
+		return Array.from(this.get().children).length > 0;
+	}
 
-  getChildren(selector) {
-    if (isEmpty(selector))
+	getChildren(selector) {
+		if (CDUtils.isEmpty(selector))
 			return Array.from(this.get().children).map((element) => CDElement.get(element));
-    return Array.from(this.get().querySelectorAll(selector)).map((element) => CDElement.get(element));
-  }
+		return Array.from(this.get().querySelectorAll(selector)).map((element) => CDElement.get(element));
+	}
 
-  getChildrenRecursive() {
-    let children = this.getChildren();
-    for(const child of children) {
-      if(this.hasChildren())
-        children = children.concat(child.getChildrenRecursive());
-    }
-    return children;
-  }
+	getChildrenRecursive() {
+		let children = this.getChildren();
+		for(const child of children) {
+			if(this.hasChildren())
+				children = children.concat(child.getChildrenRecursive());
+		}
+		return children;
+	}
 
-  getParent() {
-    return CDElement.get(this.get().parentElement);
-  }
+	getParent() {
+		return CDElement.get(this.get().parentElement);
+	}
 
-  getValue() {
-    return this.get().value || this.getInnerHTML();
-  }
+	getValue() {
+		return this.get().value || this.getInnerHTML();
+	}
+
+	setValue(value) {
+		this.get().value = value;
+		return this;
+	}
 
 	append(element) {
 		this.get().append(element.get());
@@ -102,7 +99,7 @@ class CDElement {
 
 	enable(display) {
 		this.removeClass('disabled');
-		this.get().style.display = isEmpty(display) ? 'block' : display;
+		this.get().style.display = CDUtils.isEmpty(display) ? 'block' : display;
 		return this;
 	}
 
@@ -117,50 +114,54 @@ class CDElement {
 	}
 
 	expandHeight(size) {
-		if (isEmpty(size))
+		if (CDUtils.isEmpty(size))
 			return this;
 		this.get().style.height = size + 'px';
 		return this;
 	}
 
 	expandWidth(size){
-		if (isEmpty(size))
+		if (CDUtils.isEmpty(size))
 			return this;
 		this.get().style.width = size + 'px';
 		return this;
 	}
 
 	isMinimized(el) {
-		return isEmpty(this.get().style.height);
+		return CDUtils.isEmpty(this.get().style.height);
 	}
 
 	isDisabled(el) {
-		return isEmpty(this.get().style.display);
+		return CDUtils.isEmpty(this.get().style.display);
 	}
 
 	setId(id) {
-		if (isEmpty(id))
+		if (CDUtils.isEmpty(id))
 			return this;
 		this.get().id = id;
 		return id;
 	}
 
-  nextSibling() {
-    return CDElement.get(this.get().nextElementSibling);
+  previousSibling() {
+    return CDElement.get(this.get().previousElementSibling);
   }
 
+	nextSibling() {
+		return CDElement.get(this.get().nextElementSibling);
+	}
+
 	addClass(cls) {
-		if (isEmpty(cls))
+		if (CDUtils.isEmpty(cls))
 			return this;
 		cls.split(' ').forEach((c) => {
-      if(c.length > 0)
-			  this.get().classList.add(c);
+			if(c.length > 0 && !this.hasClass(c))
+				this.get().classList.add(c);
 		});
 		return this;
 	}
 
 	getClass() {
-		if (isEmpty(this.get().classList))
+		if (CDUtils.isEmpty(this.get().classList))
 			return '';
 		let classList = '';
 		this.get().classList.forEach((cls) => {
@@ -170,14 +171,14 @@ class CDElement {
 	}
 
 	removeClass(cls) {
-		if (isEmpty(cls))
+		if (CDUtils.isEmpty(cls))
 			return this;
 		this.get().classList.remove(cls);
 		return this;
 	}
 
 	hasClass(cls) {
-		if (isEmpty(this.get().classList))
+		if (CDUtils.isEmpty(this.get().classList))
 			return false;
 		let has = false;
 		this.get().classList.forEach((c) => {
@@ -187,36 +188,36 @@ class CDElement {
 	}
 
 	removeClass(cls) {
-		if (isEmpty(cls))
+		if (CDUtils.isEmpty(cls))
 			return this;
 		this.get().classList.remove(cls);
 		return this;
 	}
 
 	switchClass(cls, condition) {
-    if(condition != null)
-      return condition ? this.addClass(cls) : this.removeClass(cls);
+		if(condition != null)
+			return condition ? this.addClass(cls) : this.removeClass(cls);
 		return this.hasClass(cls) ? this.removeClass(cls) : this.addClass(cls);
 	}
 
 	removeCssProperty(prop) {
-		if (isEmpty(prop))
+		if (CDUtils.isEmpty(prop))
 			return this;
 		this.get().style.setProperty(prop, '');
 		return this;
 	}
 
 	setAttribute(attr, value) {
-		this.get().setAttribute(attr, isEmpty(value) ? '' : value);
+		this.get().setAttribute(attr, CDUtils.isEmpty(value) ? '' : value);
 		return this;
 	}
 
-  getAttribute(attr) {
-    return this.get().getAttribute(attr);
-  }
+	getAttribute(attr) {
+		return this.get().getAttribute(attr);
+	}
 
 	setInnerHTML(value) {
-		this.get().innerHTML = isEmpty(value) ? '' : value;
+		this.get().innerHTML = CDUtils.isEmpty(value) ? '' : value;
 		return this;
 	}
 
@@ -232,30 +233,45 @@ class CDElement {
 		return this.get().innerHeight;
 	}
 
-  getOffsetTop() {
-    return this.get().offsetTop;
-  }
+	getOffsetTop() {
+		return this.get().offsetTop;
+	}
 
-  scrollTo(selector) {
-    const child = this.getFirstChild(selector);
-    if(child)
-      this.get().scrollTop = child.getOffsetTop() - this.getOffsetTop();
-  }
+	scrollTo(selector) {
+		const child = this.getFirstChild(selector);
+		if(child)
+			this.get().scrollTop = child.getOffsetTop() - this.getOffsetTop();
+	}
+
+	scrollIntoView() {
+		this.get().scrollIntoView();
+		return this;
+	}
+
+	style(property, value, priority) {
+		this.get().style.setProperty(property, value, priority);
+		return this;
+	}
+
+	focus() {
+		this.get().focus();
+		return this;
+	}
 
-  style(property, value, priority) {
-    this.get().style.setProperty(property, value, priority);
+  click() {
+    this.get().click();
     return this;
   }
 
 	on(event, callback) {
-		if (isEmpty(event) || isEmpty(callback))
+		if (CDUtils.isEmpty(event) || CDUtils.isEmpty(callback))
 			return this;
 		this.get().addEventListener(event, callback);
 		return this;
 	}
 
 	un(event, callback) {
-		if (isEmpty(event))
+		if (CDUtils.isEmpty(event))
 			return this;
 		this.get().removeEventListener(event, callback);
 		return this;
@@ -263,34 +279,51 @@ class CDElement {
 }
 
 class Url {
-  constructor(url) {
-    this.url = new URL(url || location);
-    this.urlSearch = new URLSearchParams(this.url.search);
-  }
+	constructor(url) {
+		this.url = new URL(url || location);
+		this.urlSearch = new URLSearchParams(this.url.search);
+	}
 
-  static getHash() {
-    return new Url().getHash();
-  }
+	static getHash() {
+		return new Url().getHash();
+	}
 
-  static setHash(hash) {
-    return new Url().setHash(hash);
-  }
+	static setHash(hash) {
+		return new Url().setHash(hash);
+	}
 
-  static goTo(url) {
-    window.location = url;
+  static getOrigin() {
+    return new Url().getOrigin();
   }
 
-  getHash() {
-    const hash = this.url.hash.substring(1);
-    return hash.length > 0 ? hash : null;
-  }
+	static goTo(url, blank) {
+		window.open(url, blank ? '_blank' : '_self');
+	}
 
-  setHash(hash) {
-    this.url.hash = !hash || hash.length == 0 ? '' : `#${hash}`;
-    return this;
+	static reload() {
+		location.reload();
+	}
+
+	static getFullPath() {
+		const url = new Url().url;
+		return `${url.origin}${url.pathname}`;
+	}
+
+	getHash() {
+		const hash = this.url.hash.substring(1);
+		return hash.length > 0 ? hash : null;
+	}
+
+	setHash(hash) {
+		this.url.hash = !hash || hash.length == 0 ? '' : `#${hash}`;
+		return this;
+	}
+
+  getOrigin() {
+    return this.url.origin;
   }
 
-  toString() {
+	toString() {
 		this.url.search = this.urlSearch.toString();
 		return this.url.toString();
 	}
@@ -301,86 +334,115 @@ class Url {
 	}
 
 	updateLocation() {
+		const hashChanged = Url.getHash() !== this.getHash();
+		
 		history.replaceState(null, null, this.toLocalString());
-  }
+		hashChanged && window.dispatchEvent(new HashChangeEvent('hashchange'));
+	}
 }
 
 class Style {
-  static apply(element, styleStr) {
-    element = element instanceof CDElement ? element : CDElement.get(element);
-    const propertiesMap = this.getCssPropertiesMap(styleStr);
-    for(const prop of Object.keys(propertiesMap))
-      element.style(prop, propertiesMap[prop].value, propertiesMap[prop].priority);
-  }
-
-  static getCssPropertiesMap(styleStr) {
-    const parts = styleStr.split(';');
-    const map = {};
-    for(let part of parts) {
-      part = part.trim();
-      if(part.length == 0)
-        continue;
-      const propVal = part.split(':');
-      const property = propVal[0].trim();
-      const value = propVal[1].trim().split('!');
-      map[property] = { value: value[0], priority: value.length > 1 ? value[1] : '' };
-    }
-    return map;
-  }
+	static apply(element, styleStr) {
+		element = element instanceof CDElement ? element : CDElement.get(element);
+		const propertiesMap = this.getCssPropertiesMap(styleStr);
+		for(const prop of Object.keys(propertiesMap))
+			element.style(prop, propertiesMap[prop].value, propertiesMap[prop].priority);
+	}
+
+	static getCssPropertiesMap(styleStr) {
+		const parts = styleStr.split(';');
+		const map = {};
+		for(let part of parts) {
+			part = part.trim();
+			if(part.length == 0)
+				continue;
+			const propVal = part.split(':');
+			const property = propVal[0].trim();
+			const value = propVal[1].trim().split('!');
+			map[property] = { value: value[0], priority: value.length > 1 ? value[1] : '' };
+		}
+		return map;
+	}
 }
 
 class DOM {
 
-  static Events = {
-    Click: 'click',
-    Load: 'load',
-    KeyDown: 'keydown',
-    KeyUp: 'keyup'
-  };
-
-  static Tags = {
-    Div: 'div',
-    Span: 'span',
-    H1: 'h1',
-    H2: 'h2',
-    H3: 'h3',
-    P: 'p'
-  };
+	static Events = {
+		Click: 'click',
+		Load: 'load',
+		KeyDown: 'keydown',
+		KeyUp: 'keyup',
+		KeyPress: 'keypress',
+		Change: 'change',
+		Cut: 'cut',
+		Drop: 'drop',
+		Paste: 'paste',
+		Input: 'input',
+		HashChange: 'hashchange',
+		MouseDown: 'mousedown',
+		ContextMenu: 'contextmenu',
+    Blur: 'blur'
+	};
+
+	static Tags = {
+		Div: 'div',
+		Span: 'span',
+		H1: 'h1',
+		H2: 'h2',
+		H3: 'h3',
+		P: 'p',
+		Textarea: 'textarea',
+    Input: 'input'
+	};
+
+	static Keys = {
+		Enter: 'Enter',
+		Escape: 'Escape',
+		Control: 'Control',
+		Shift: 'Shift',
+		Backspace: 'Backspace'
+	};
+
+	static MouseButtons = {
+		Left: 1,
+		Right: 2,
+		Middle: 4
+	};
 
 	static get(selector) {
-		if (isEmpty(selector))
+		if (CDUtils.isEmpty(selector))
 			throw "DOM.get() invalid selector.";
 		const element = document.querySelector(selector);
-		if (isEmpty(element))
+		if (CDUtils.isEmpty(element))
 			return null;
 		return CDElement.get(element);
 	}
 
-  static getAll(selector) {
-    if (isEmpty(selector))
-      throw "DOM.getAll() invalid selector.";
-    const elements = document.querySelectorAll(selector);
-    if(isEmpty(elements))
-      return [];
-    return Array.from(elements).map((element) => CDElement.get(element));
-  }
+	static getAll(selector) {
+		if (CDUtils.isEmpty(selector))
+			throw "DOM.getAll() invalid selector.";
+		const elements = document.querySelectorAll(selector);
+		if(CDUtils.isEmpty(elements))
+			return [];
+		return Array.from(elements).map((element) => CDElement.get(element));
+	}
 
 	static create(config, container, prepend) {
-		if (isEmpty(config) || isEmpty(config.tag))
+		if (CDUtils.isEmpty(config) || CDUtils.isEmpty(config.tag))
 			return;
 		const element = CDElement.get(document.createElement(config.tag));
 
-		if (!isEmpty(config.attr)) {
+		if (!CDUtils.isEmpty(config.attr)) {
 			Object.keys(config.attr).forEach((name) => {
 				element.setAttribute(name, config.attr[name]);
 			});
 		}
 
-		if (!isEmpty(config.cls)) element.addClass(config.cls);
-		if (!isEmpty(config.id)) element.setId(config.id);
-    if (!isEmpty(config.style)) Style.apply(element, config.style);
+		if (!CDUtils.isEmpty(config.cls)) element.addClass(config.cls);
+		if (!CDUtils.isEmpty(config.id)) element.setId(config.id);
+		if (!CDUtils.isEmpty(config.style)) Style.apply(element, config.style);
 
-		if (!isEmpty(config.cn)) {
+		if (!CDUtils.isEmpty(config.cn)) {
 			config.cn.forEach((el) => {
 				if (el instanceof Element) {
 					element.append(CDElement.get(el));
@@ -390,16 +452,16 @@ class DOM {
 			});
 		}
 
-    // innerHTML appends after cn
-    if (!isEmpty(config.innerHTML)) element.setInnerHTML(element.getInnerHTML() + config.innerHTML);
+		// innerHTML appends after cn
+		if (!CDUtils.isEmpty(config.innerHTML)) element.setInnerHTML(element.getInnerHTML() + config.innerHTML);
 
-		if (!isEmpty(container))
+		if (!CDUtils.isEmpty(container))
 			(prepend === true ? container.prepend(element) : container.append(element));
 		return element;
 	}
 
 	static append(container, element) {
-		if (isEmpty(element) || isEmpty(container) ||
+		if (CDUtils.isEmpty(element) || CDUtils.isEmpty(container) ||
 						(!(element instanceof Element) && !(element instanceof CDElement)) ||
 						(!(container instanceof Element) && !(container instanceof CDElement)))
 			return;
@@ -421,38 +483,89 @@ class DOM {
 			cookies[key.trim()] = value;
 		});
 
-    const cookieValue = cookies[name];
-    if(isEmpty(cookieValue))
-      return null;
-    if(isJsonString(cookieValue))
-      return JSON.parse(cookieValue);
-    else
-      return cookieValue;
+		const cookieValue = cookies[name];
+		if(CDUtils.isEmpty(cookieValue))
+			return null;
+		if(CDUtils.isJsonString(cookieValue))
+			return JSON.parse(cookieValue);
+		else
+			return cookieValue;
 	}
 
-  static getCookieProperty(name, property) {
-    const cookie = DOM.getCookie(name);
-    if(cookie && !(cookie instanceof Object))
-      throw 'DOM.getCookieProperty(): cookie value is not a JSON';
-    return cookie ? cookie[property] : null;
-  }
+	static getCookieProperty(name, property) {
+		const cookie = DOM.getCookie(name);
+		if(cookie && !(cookie instanceof Object))
+			throw 'DOM.getCookieProperty(): cookie value is not a JSON';
+		return cookie ? cookie[property] : null;
+	}
+
+	static setCookieProperty(name, property, value, hours) {
+		const cookie = DOM.getCookie(name);
+
+		if(cookie) {
+			if(!(cookie instanceof Object))
+				throw 'DOM.setCookieProperty(): initial cookie value is not a JSON';
+
+			cookie[property] = value;
+			DOM.setCookie(name, cookie, hours || 24);
+		} else {
+			DOM.setCookie(name, { [property]: value }, hours || 24);
+		}
+	}
 
-  static setCookieProperty(name, property, value, hours) {
-    const cookie = DOM.getCookie(name);
+	static documentOn(event, callback) {
+		if (CDUtils.isEmpty(event) || CDUtils.isEmpty(callback))
+			return;
+		document.addEventListener(event, callback);
+	}
+
+  static copyToClipboard(str) {
+    const body = DOM.get('body');
+    const input = DOM.create({ tag: DOM.Tags.Input, style: 'display: none;' }, body);
 
-    if(cookie) {
-      if(!(cookie instanceof Object))
-        throw 'DOM.setCookieProperty(): initial cookie value is not a JSON';
+    input.setValue(str);
+    input.get().select();
+    input.get().setSelectionRange(0, 99999);
 
-      cookie[property] = value;
-      DOM.setCookie(name, cookie, hours || 24);
-    } else {
-      DOM.setCookie(name, { [property]: value }, hours || 24);
-    }
+    navigator.clipboard.writeText(input.get().value).then(() => {
+      input.remove();
+    });
   }
 }
 
 class CDUtils {
+
+	static isEmpty(val) {
+		return val === null || val === undefined ||
+					val === '' || val.length === 0;
+	}
+	
+	static isJsonString(str) {
+		try {
+			JSON.parse(str);
+		} catch (e) {
+			return false;
+		}
+		return true;
+	}
+
+	static nl2br(str) {
+		return str.replaceAll(/(?:\r\n|\r|\n)/g, '<br/>');
+	}
+
+	static br2nl(str) {
+		return str.replaceAll(/<br\/?>/g, '\n');
+	}
+
+	static async SHA256(input) {
+		return crypto.subtle.digest('SHA-256', new TextEncoder('utf8').encode(input)).then(h => {
+			const hexes = [], view = new DataView(h);
+			for (let i = 0; i < view.byteLength; i += 4)
+				hexes.push(('00000000' + view.getUint32(i).toString(16)).slice(-8));
+			return hexes.join('');
+		});
+	}
+
 	/**
 		 * Returns string representation of <b>x</b> with leading zeros.<br/>
 		 * Length of the resulting string will be equal to <b>length</b> or <b>2</b> if <b>length</b> was not specified.<br/>
@@ -536,10 +649,6 @@ class CDUtils {
 			.sort((a, b) => a.sort - b.sort)
 			.map(({ value }) => value);
 	}
-
-	static toHtml(str) {
-		return str.replace(/(?:\r\n|\r|\n)/g, '<br>');
-	}
 }
 
 const window_ = CDElement.get(window);

+ 17 - 1
static/codemirror/codemirror.js

@@ -6224,6 +6224,16 @@
     firstLine: function() {return this.first},
     lastLine: function() {return this.first + this.size - 1},
 
+    cmGetLine: function(line) {return this.getLine(line)},
+    cmLineCount: function() {return this.lineCount()},
+    cmFirstLine: function() {return this.firstLine()},
+    cmLastLine: function() {return this.lastLine()},
+
+    cmGetValue: function(lineSep) {return this.getValue(lineSep)},
+    cmSetValue: function(code) {
+      return this.setValue(code);
+    },
+
     clipPos: function(pos) {return clipPos(this, pos)},
 
     getCursor: function(start) {
@@ -6570,7 +6580,7 @@
   });
 
   // Public alias.
-  Doc.prototype.eachLine = Doc.prototype.iter;
+  Doc.prototype.eachLine = Doc.prototype.cmEachLine = Doc.prototype.iter;
 
   // Kludge to work around strange IE behavior where it'll sometimes
   // re-fire a series of drag-related events right after the drop (#1551)
@@ -8320,6 +8330,8 @@
       constructor: CodeMirror,
       focus: function(){win(this).focus(); this.display.input.focus();},
 
+      cmFocus: function() { this.focus() },
+
       setOption: function(option, value) {
         var options = this.options, old = options[option];
         if (options[option] == value && option != "mode") { return }
@@ -8706,6 +8718,10 @@
         signal(this, "refresh", this);
       }),
 
+      cmRefresh: function() {
+        return this.refresh();
+      },
+
       swapDoc: methodOp(function(doc) {
         var old = this.doc;
         old.cm = null;

+ 16 - 0
static/img/404.svg

@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="iso-8859-1"?>
+<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
+<svg fill="#d1d1d1" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" 
+	 viewBox="0 0 60 60" xml:space="preserve">
+<g>
+	<path d="M0,0v12v2v46h60V14v-2V0H0z M20,39h-3v8c0,0.552-0.448,1-1,1s-1-0.448-1-1v-8H9c-0.552,0-1-0.448-1-1V27
+		c0-0.552,0.448-1,1-1s1,0.448,1,1v10h5v-2c0-0.552,0.448-1,1-1s1,0.448,1,1v2h3c0.552,0,1,0.448,1,1S20.552,39,20,39z M36,41.5
+		c0,3.584-2.916,6.5-6.5,6.5S23,45.084,23,41.5v-9c0-3.584,2.916-6.5,6.5-6.5s6.5,2.916,6.5,6.5V41.5z M51,39h-3v8
+		c0,0.552-0.448,1-1,1s-1-0.448-1-1v-8h-6c-0.552,0-1-0.448-1-1V27c0-0.552,0.448-1,1-1s1,0.448,1,1v10h5v-2c0-0.552,0.448-1,1-1
+		s1,0.448,1,1v2h3c0.552,0,1,0.448,1,1S51.552,39,51,39z M2,12V2h56v10H2z"/>
+	<polygon points="54.293,3.293 52,5.586 49.707,3.293 48.293,4.707 50.586,7 48.293,9.293 49.707,10.707 52,8.414 54.293,10.707 
+		55.707,9.293 53.414,7 55.707,4.707 	"/>
+	<rect x="3" y="3" width="39" height="8"/>
+	<path d="M29.5,28c-2.481,0-4.5,2.019-4.5,4.5v9c0,2.481,2.019,4.5,4.5,4.5s4.5-2.019,4.5-4.5v-9C34,30.019,31.981,28,29.5,28z"/>
+</g>
+</svg>

+ 2 - 0
static/img/faq.svg

@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg fill="#d1d1d1" width="24px" height="24px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12,22A10,10,0,1,0,2,12,10,10,0,0,0,12,22Zm0-2a1.5,1.5,0,1,1,1.5-1.5A1.5,1.5,0,0,1,12,20ZM8,8.994a3.907,3.907,0,0,1,2.319-3.645,4.061,4.061,0,0,1,3.889.316,4,4,0,0,1,.294,6.456,3.972,3.972,0,0,0-1.411,2.114,1,1,0,0,1-1.944-.47,5.908,5.908,0,0,1,2.1-3.2,2,2,0,0,0-.146-3.23,2.06,2.06,0,0,0-2.006-.14,1.937,1.937,0,0,0-1.1,1.8A1,1,0,0,1,8,9Z"/></svg>

+ 6 - 0
static/img/logout.svg

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M21 12L13 12" stroke="#d1d1d1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M18 15L20.913 12.087V12.087C20.961 12.039 20.961 11.961 20.913 11.913V11.913L18 9" stroke="#d1d1d1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M16 5V4.5V4.5C16 3.67157 15.3284 3 14.5 3H5C3.89543 3 3 3.89543 3 5V19C3 20.1046 3.89543 21 5 21H14.5C15.3284 21 16 20.3284 16 19.5V19.5V19" stroke="#d1d1d1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 4 - 0
static/img/refresh.svg

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M21 12C21 16.9706 16.9706 21 12 21C9.69494 21 7.59227 20.1334 6 18.7083L3 16M3 12C3 7.02944 7.02944 3 12 3C14.3051 3 16.4077 3.86656 18 5.29168L21 8M3 21V16M3 16H8M21 3V8M21 8H16" stroke="#d1d1d1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 2 - 0
static/img/user.svg

@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
+<svg fill="#d1d1d1" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Account Settings</title><path d="M9.6,3.32a3.86,3.86,0,1,0,3.86,3.85A3.85,3.85,0,0,0,9.6,3.32M16.35,11a.26.26,0,0,0-.25.21l-.18,1.27a4.63,4.63,0,0,0-.82.45l-1.2-.48a.3.3,0,0,0-.3.13l-1,1.66a.24.24,0,0,0,.06.31l1,.79a3.94,3.94,0,0,0,0,1l-1,.79a.23.23,0,0,0-.06.3l1,1.67c.06.13.19.13.3.13l1.2-.49a3.85,3.85,0,0,0,.82.46l.18,1.27a.24.24,0,0,0,.25.2h1.93a.24.24,0,0,0,.23-.2l.18-1.27a5,5,0,0,0,.81-.46l1.19.49c.12,0,.25,0,.32-.13l1-1.67a.23.23,0,0,0-.06-.3l-1-.79a4,4,0,0,0,0-.49,2.67,2.67,0,0,0,0-.48l1-.79a.25.25,0,0,0,.06-.31l-1-1.66c-.06-.13-.19-.13-.31-.13L19.5,13a4.07,4.07,0,0,0-.82-.45l-.18-1.27a.23.23,0,0,0-.22-.21H16.46M9.71,13C5.45,13,2,14.7,2,16.83v1.92h9.33a6.65,6.65,0,0,1,0-5.69A13.56,13.56,0,0,0,9.71,13m7.6,1.43a1.45,1.45,0,1,1,0,2.89,1.45,1.45,0,0,1,0-2.89Z"/></svg>

+ 247 - 229
static/modules/class-list/class-list.js

@@ -1,234 +1,252 @@
 class ClassListModule {
-  static SearchQuery = '';
-  static ModeCookieName = 'doczilla-js-docs-class-list-mode';
-  static OpenedFoldersCookieName = 'doczilla-js-docs-class-list-opened-folders';
-  static Mode = {
-    Structurized:   'structurized',
-    Unstructurized: 'unstructurized'
-  };
-  static _Mode = ClassListModule.Mode.Structurized;
-
-  static setQuery(query, reload) {
-    ClassListModule.SearchQuery = query.toLowerCase();
-    if(reload)
-      ClassListModule.reload();
-  }
-
-  static load() {
-    ClassListModule.classListElement = ClassListModule.classListElement ? ClassListModule.classListElement : DOM.get('.class-list');
-
-    DOM.get('.class-list-mode-button.structurized').switchClass('selected', ClassListModule._Mode === ClassListModule.Mode.Structurized);
-    DOM.get('.class-list-mode-button.unstructurized').switchClass('selected', ClassListModule._Mode === ClassListModule.Mode.Unstructurized);
-
-    ClassListModule.loadClasses(ClassListModule._Mode);
-  }
-
-  static loadClasses(mode) {
-    let classList = {};
-
-    if(mode === ClassListModule.Mode.Structurized) {
-      for(const root of RepoNames) {
-        classList[root] = { type: 'dir', contents: {} };
-        Object.keys(ClassList).filter((key) => {
-          return ClassList[key].filePath.indexOf(root) === 0 && (ClassListModule.SearchQuery.length == 0 || key.toLowerCase().includes(ClassListModule.SearchQuery) || (ClassList[key].shortName || '').toLowerCase().includes(ClassListModule.SearchQuery));
-        }).sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())).map((key) => {
-          classList[root].contents[key] = { type: 'cls', class: ClassList[key] };
-        });
-      }
-
-      classList = ClassListModule.transformStructure(classList);
-
-      Object.keys(classList).forEach((key) => {
-        if(Object.keys(classList[key].contents).length > 0)
-          ClassListModule.renderDir(key, key, classList[key].contents, ClassListModule.classListElement);
-      });
-    } else {
-      Object.keys(ClassList).sort((a, b) => {
-        return a.toLowerCase().localeCompare(b.toLowerCase());
-      }).filter((key) => {
-        return ClassListModule.SearchQuery.length == 0 || key.toLowerCase().includes(ClassListModule.SearchQuery) || (ClassList[key].shortName || '').toLowerCase().includes(ClassListModule.SearchQuery);
-      }).map((key) => { classList[key] = ClassList[key]; });
-
-      for(const className of Object.keys(classList)) {
-        const icon = DOM.create({ tag: 'div', cls: 'class-icon' });
-        const name = DOM.create({ tag: 'div', cls: 'class-name', innerHTML: className });
-        DOM.create({
-          tag: 'div',
-          cls: `class-item ${ Class ? (Class.name === className ? 'selected' : '') : '' }`,
-          cn: [icon, name],
-          attr: {
-            'data-class-name': className,
-            'data-class-shortname': classList[className].shortName,
-            'title': className
-          }
-        }, ClassListModule.classListElement).on('click', ClassListModule.onListItemClick);
-      }
-    }
-  }
-
-  static renderDir(dirName, dirPath, dirContents, parentContainer) {
-    if(ClassListModule.SearchQuery.length > 0 && Object.keys(dirContents).length === 0)
-      return;
-
-    const dirCollapsedIconEl = DOM.create({ tag: 'div', cls: 'dir-collapsed-icon' });
-    const dirIconEl = DOM.create({ tag: 'div', cls: 'dir-icon' });
-    const dirNameEl = DOM.create({ tag: 'div', cls: 'dir-name', cn: [dirCollapsedIconEl, dirIconEl], innerHTML: dirName }).on('click', ClassListModule.onListItemClick);
-    const dirContentEl = DOM.create({ tag: 'div', cls: 'dir-content' });
-    
-    const dirItem = DOM.create({ tag: 'div', cls: 'dir-item', cn: [dirNameEl, dirContentEl], attr: { 'data-dir-path': dirPath } }, parentContainer);
-
-    const opened = (DOM.getCookieProperty(App.CookieName, ClassListModule.OpenedFoldersCookieName) || []).indexOf(dirPath) > -1;
-
-    if(isEmpty(ClassListModule.SearchQuery) && !opened)
-      dirItem.addClass('collapsed');
-
-    Object.keys(dirContents).sort((a, b) => {
-      return -dirContents[a].type.localeCompare(dirContents[b].type); // 'dir' > 'cls'
-    }).forEach((key) => {
-      const item = dirContents[key];
-      if(item.type === 'cls') {
-        ClassListModule.renderClass(item.class, dirContentEl);
-      } else {
-        ClassListModule.renderDir(key, `${dirPath}/${key}`, item.contents, dirContentEl);
-      }
-    });
-  }
-
-  static renderClass(cls, container) {
-    const className = cls.name;
-    const classShortName = cls.shortName;
-    const icon = DOM.create({ tag: 'div', cls: 'class-icon' });
-    const name = DOM.create({ tag: 'div', cls: 'class-name', innerHTML: className });
-    DOM.create({
-      tag: 'div',
-      cls: `class-item ${ Class ? (Class.name === className ? 'selected' : '') : '' }`,
-      cn: [icon, name],
-      attr: {
-        'data-class-name': className,
-        'data-class-shortname': classShortName,
-        'title': className
-      }
-    }, container).on('click', ClassListModule.onListItemClick);
-  }
-
-  static transformStructure(obj) {
-    const newObj = {};
-
-    function recursiveTransform(parent, path, type, cls) {
-      const [currentKey, ...rest] = path.split('.');
-      if (!currentKey) return;
-
-      if (!parent[currentKey]) {
-        parent[currentKey] = {};
-        parent[currentKey].type = rest.length === 0 ? type : 'dir';
-        parent[currentKey].contents = {};
-      }
-
-      if (rest.length === 0) {
-        parent[currentKey].type = type;
-        parent[currentKey].class = cls;
-      } else {
-        recursiveTransform(parent[currentKey].contents, rest.join('.'), type, cls);
-      }
-    }
-
-    function sortContents(contents) {
-      const sortedKeys = Object.keys(contents).sort((a, b) => {
-        const typeA = contents[a].type === 'dir' ? 0 : 1;
-        const typeB = contents[b].type === 'dir' ? 0 : 1;
-        if (typeA !== typeB) return typeA - typeB;
-          return a.localeCompare(b);
-      });
-
-      const sortedContents = {};
-      sortedKeys.forEach(key => {
-        sortedContents[key] = contents[key];
-      });
-      return sortedContents;
-    }
-
-    for (const rootKey in obj) {
-      if (obj.hasOwnProperty(rootKey)) {
-        const { type, contents } = obj[rootKey];
-        newObj[rootKey] = { type: 'dir', contents: sortContents({}) };
-
-        for (const classKey in contents) {
-            if (contents.hasOwnProperty(classKey)) {
-              const { type, class: cls } = contents[classKey];
-              recursiveTransform(newObj[rootKey].contents, classKey, type, cls);
-            }
-        }
-      }
-    }
-
-    return newObj;
-  }
-
-  static onListItemClick(e) {
-    let target = CDElement.get(e.target);
-    while(!target.hasClass('class-item') && !target.hasClass('dir-name'))
-      target = target.getParent();
-
-    if(target.hasClass('class-item')) {
-      Url.goTo(`/class/${target.getAttribute('data-class-name')}`);
-    } else if(target.hasClass('dir-name')) { 
-      const parent = target.getParent();
-      const dataDirPath = parent.getAttribute('data-dir-path');
-      parent.switchClass('collapsed');
-
-      if(!isEmpty(ClassListModule.SearchQuery))
-        return;
-
-      let openedFoldersCookieValue = DOM.getCookieProperty(App.CookieName, ClassListModule.OpenedFoldersCookieName) || [];
-
-      if(parent.hasClass('collapsed')) {
-        openedFoldersCookieValue.splice(openedFoldersCookieValue.indexOf(dataDirPath), 1);
-      } else {
-        openedFoldersCookieValue.push(dataDirPath);
-      }
-
-      DOM.setCookieProperty(App.CookieName, ClassListModule.OpenedFoldersCookieName, openedFoldersCookieValue, 24);
-    }
-  }
-
-  static onModeButtonClick(e) {
-    const target = CDElement.get(e.target);
-    const mode = target.getAttribute('data-mode');
-    
-    ClassListModule._Mode = mode;
-
-    DOM.setCookieProperty(App.CookieName, ClassListModule.ModeCookieName, mode);
-    ClassListModule.reload();
-  }
-
-  static clear() {
-    DOM.get('.class-list').getChildrenRecursive().forEach((item) => {
-      if(item.hasClass('class-item') || item.hasClass('dir-name'))
-        item.un('click', ClassListModule.onListItemClick);
-      item.remove();
-    });
-  }
-
-  static reload() {
-    ClassListModule.clear();
-    ClassListModule.load();
-  }
-
-  static init() {
-    DOM.get('.class-list-mode-button.structurized').on('click', ClassListModule.onModeButtonClick);
-    DOM.get('.class-list-mode-button.unstructurized').on('click', ClassListModule.onModeButtonClick);
-    
-    const modeCookieValue = DOM.getCookieProperty(App.CookieName, ClassListModule.ModeCookieName);
-    
-    ClassListModule._Mode = modeCookieValue || ClassListModule.Mode.Structurized;
-    if(!modeCookieValue)
-      DOM.setCookieProperty(App.CookieName, ClassListModule.ModeCookieName, ClassListModule.Mode.Structurized);
-
-    ClassListModule.load();
-    ClassListModule.classListElement.scrollTo('.class-item.selected');
-  }
+	static SearchQuery = '';
+	static ModeCookieName = 'doczilla-js-docs-class-list-mode';
+	static OpenedFoldersCookieName = 'doczilla-js-docs-class-list-opened-folders';
+	static Mode = {
+		Structurized:   'structurized',
+		Unstructurized: 'unstructurized'
+	};
+	static _Mode = ClassListModule.Mode.Structurized;
+
+	static setQuery(query, reload) {
+		ClassListModule.SearchQuery = query.toLowerCase();
+		if(reload)
+			ClassListModule.reload();
+	}
+
+	static load() {
+		ClassListModule.classListElement = ClassListModule.classListElement ? ClassListModule.classListElement : DOM.get('.class-list');
+
+		DOM.get('.class-list-mode-button.structurized').switchClass('selected', ClassListModule._Mode === ClassListModule.Mode.Structurized);
+		DOM.get('.class-list-mode-button.unstructurized').switchClass('selected', ClassListModule._Mode === ClassListModule.Mode.Unstructurized);
+
+		ClassListModule.loadClasses(ClassListModule._Mode);
+	}
+
+	static loadClasses(mode) {
+		let classList = {};
+
+		if(mode === ClassListModule.Mode.Structurized) {
+			for(const root of RepoNames) {
+				classList[root] = { type: 'dir', contents: {} };
+				Object.keys(ClassList).filter((key) => {
+					return ClassList[key].filePath.indexOf(root) === 0 && (ClassListModule.SearchQuery.length == 0 || key.toLowerCase().includes(ClassListModule.SearchQuery) || (ClassList[key].shortName || '').toLowerCase().includes(ClassListModule.SearchQuery));
+				}).sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())).map((key) => {
+					classList[root].contents[key] = { type: 'cls', class: ClassList[key] };
+				});
+			}
+
+			classList = ClassListModule.transformStructure(classList);
+
+			Object.keys(classList).forEach((key) => {
+				if(Object.keys(classList[key].contents).length > 0)
+					ClassListModule.renderDir(key, key, classList[key].contents, ClassListModule.classListElement);
+			});
+		} else {
+			Object.keys(ClassList).sort((a, b) => {
+				return a.toLowerCase().localeCompare(b.toLowerCase());
+			}).filter((key) => {
+				return ClassListModule.SearchQuery.length == 0 || key.toLowerCase().includes(ClassListModule.SearchQuery) || (ClassList[key].shortName || '').toLowerCase().includes(ClassListModule.SearchQuery);
+			}).map((key) => { classList[key] = ClassList[key]; });
+
+			for(const className of Object.keys(classList)) {
+				const icon = DOM.create({ tag: 'div', cls: 'class-icon' });
+				const name = DOM.create({ tag: 'div', cls: 'class-name', innerHTML: className });
+				DOM.create({
+					tag: 'div',
+					cls: `class-item ${ Class ? (Class.name === className ? 'selected' : '') : '' }`,
+					cn: [icon, name],
+					attr: {
+						'data-class-name': className,
+						'data-class-shortname': classList[className].shortName,
+						'title': className
+					}
+				}, ClassListModule.classListElement).on('click', ClassListModule.onListItemClick);
+			}
+		}
+	}
+
+	static renderDir(dirName, dirPath, dirContents, parentContainer) {
+		if(ClassListModule.SearchQuery.length > 0 && Object.keys(dirContents).length === 0)
+			return;
+
+		const dirCollapsedIconEl = DOM.create({ tag: 'div', cls: 'dir-collapsed-icon' });
+		const dirIconEl = DOM.create({ tag: 'div', cls: 'dir-icon' });
+		const dirNameEl = DOM.create({ tag: 'div', cls: 'dir-name', cn: [dirCollapsedIconEl, dirIconEl], innerHTML: dirName }).on('click', ClassListModule.onListItemClick);
+		const dirContentEl = DOM.create({ tag: 'div', cls: 'dir-content' });
+		
+		const dirItem = DOM.create({ tag: 'div', cls: 'dir-item', cn: [dirNameEl, dirContentEl], attr: { 'data-dir-path': dirPath } }, parentContainer);
+
+		const opened = (DOM.getCookieProperty(App.CookieName, ClassListModule.OpenedFoldersCookieName) || []).indexOf(dirPath) > -1;
+
+		if(CDUtils.isEmpty(ClassListModule.SearchQuery) && !opened)
+			dirItem.addClass('collapsed');
+
+		Object.keys(dirContents).sort((a, b) => {
+			return -dirContents[a].type.localeCompare(dirContents[b].type); // 'dir' > 'cls'
+		}).forEach((key) => {
+			const item = dirContents[key];
+			if(item.type === 'cls') {
+				ClassListModule.renderClass(item.class, dirContentEl);
+			} else {
+				ClassListModule.renderDir(key, `${dirPath}/${key}`, item.contents, dirContentEl);
+			}
+		});
+	}
+
+	static renderClass(cls, container) {
+		const className = cls.name;
+		const classShortName = cls.shortName;
+		const icon = DOM.create({ tag: 'div', cls: 'class-icon' });
+		const name = DOM.create({ tag: 'div', cls: 'class-name', innerHTML: className });
+		DOM.create({
+			tag: 'div',
+			cls: `class-item ${ Class ? (Class.name === className ? 'selected' : '') : '' }`,
+			cn: [icon, name],
+			attr: {
+				'data-class-name': className,
+				'data-class-shortname': classShortName,
+				'title': className
+			}
+		}, container).on('click', ClassListModule.onListItemClick);
+	}
+
+	static transformStructure(obj) {
+		const newObj = {};
+
+		function recursiveTransform(parent, path, type, cls) {
+			const [currentKey, ...rest] = path.split('.');
+			if (!currentKey) return;
+
+			if (!parent[currentKey]) {
+				parent[currentKey] = {};
+				parent[currentKey].type = rest.length === 0 ? type : 'dir';
+				parent[currentKey].contents = {};
+			}
+
+			if (rest.length === 0) {
+				parent[currentKey].type = type;
+				parent[currentKey].class = cls;
+			} else {
+				recursiveTransform(parent[currentKey].contents, rest.join('.'), type, cls);
+			}
+		}
+
+		function sortContents(contents) {
+			const sortedKeys = Object.keys(contents).sort((a, b) => {
+				const typeA = contents[a].type === 'dir' ? 0 : 1;
+				const typeB = contents[b].type === 'dir' ? 0 : 1;
+				if (typeA !== typeB) return typeA - typeB;
+					return a.localeCompare(b);
+			});
+
+			const sortedContents = {};
+			sortedKeys.forEach(key => {
+				sortedContents[key] = contents[key];
+			});
+			return sortedContents;
+		}
+
+		for (const rootKey in obj) {
+			if (obj.hasOwnProperty(rootKey)) {
+				const { type, contents } = obj[rootKey];
+				newObj[rootKey] = { type: 'dir', contents: sortContents({}) };
+
+				for (const classKey in contents) {
+						if (contents.hasOwnProperty(classKey)) {
+							const { type, class: cls } = contents[classKey];
+							recursiveTransform(newObj[rootKey].contents, classKey, type, cls);
+						}
+				}
+			}
+		}
+
+		return newObj;
+	}
+
+	static onListItemClick(e) {
+		let target = CDElement.get(e.target);
+		while(!target.hasClass('class-item') && !target.hasClass('dir-name'))
+			target = target.getParent();
+
+		if(target.hasClass('class-item')) {
+			Url.goTo(`/class/${target.getAttribute('data-class-name')}`);
+		} else if(target.hasClass('dir-name')) { 
+			const parent = target.getParent();
+			const dataDirPath = parent.getAttribute('data-dir-path');
+			parent.switchClass('collapsed');
+
+			if(!CDUtils.isEmpty(ClassListModule.SearchQuery))
+				return;
+
+			let openedFoldersCookieValue = DOM.getCookieProperty(App.CookieName, ClassListModule.OpenedFoldersCookieName) || [];
+
+			if(parent.hasClass('collapsed')) {
+				openedFoldersCookieValue.splice(openedFoldersCookieValue.indexOf(dataDirPath), 1);
+			} else {
+				openedFoldersCookieValue.push(dataDirPath);
+			}
+
+			DOM.setCookieProperty(App.CookieName, ClassListModule.OpenedFoldersCookieName, openedFoldersCookieValue, 24);
+		}
+	}
+
+	static onModeButtonClick(e) {
+		const target = CDElement.get(e.target);
+		const mode = target.getAttribute('data-mode');
+		
+		ClassListModule._Mode = mode;
+
+		DOM.setCookieProperty(App.CookieName, ClassListModule.ModeCookieName, mode);
+		ClassListModule.reload();
+	}
+
+	static clear() {
+		DOM.get('.class-list').getChildrenRecursive().forEach((item) => {
+			if(item.hasClass('class-item') || item.hasClass('dir-name'))
+				item.un('click', ClassListModule.onListItemClick);
+			item.remove();
+		});
+	}
+
+	static reload() {
+		ClassListModule.clear();
+		ClassListModule.load();
+	}
+
+	static updateSources() {
+		fetch('/updateSources', {
+			method: 'POST'
+		});
+	}
+
+	static init() {
+		DOM.get('.class-list-mode-button.structurized').on(DOM.Events.Click, ClassListModule.onModeButtonClick);
+		DOM.get('.class-list-mode-button.unstructurized').on(DOM.Events.Click, ClassListModule.onModeButtonClick);
+		DOM.get('.faq-button').on(DOM.Events.Click, (e) => { Url.goTo('/faq'); });
+
+		const refreshButton = DOM.get('.refresh-button');
+
+		if(refreshButton) {
+			refreshButton.on(DOM.Events.Click, (e) => {
+				if(confirm('Are you sure you want to update sources? It will take approximately 3-5 minutes. The system will not be available until the process finished.')) {
+					ClassListModule.updateSources();
+					Url.goTo('/');
+				}
+			});
+		}
+
+		const modeCookieValue = DOM.getCookieProperty(App.CookieName, ClassListModule.ModeCookieName);
+		
+		ClassListModule._Mode = modeCookieValue || ClassListModule.Mode.Structurized;
+		if(!modeCookieValue)
+			DOM.setCookieProperty(App.CookieName, ClassListModule.ModeCookieName, ClassListModule.Mode.Structurized);
+
+		ClassListModule.load();
+		ClassListModule.classListElement.scrollTo('.class-item.selected');
+	}
 }
 
 window_.on('load', (e) => {
-  ClassListModule.init();
+	ClassListModule.init();
 });

+ 5 - 5
static/modules/search/search.js

@@ -2,20 +2,20 @@ class SearchModule {
   static init() {
     const searchInput = DOM.get('.search-input');
 
-    searchInput.on('keypress', (e) => {
+    searchInput.on(DOM.Events.KeyPress, (e) => {
       const value = CDElement.get(e.target).getValue();
-      if(e.key === 'Enter' && !isEmpty(value))
+      if(e.key === DOM.Keys.Enter && !CDUtils.isEmpty(value))
         ClassListModule.setQuery(value, true);
     });
 
-    searchInput.on('input', (e) => {
+    searchInput.on(DOM.Events.Input, (e) => {
       const value = CDElement.get(e.target).getValue();
-      if(isEmpty(value))
+      if(CDUtils.isEmpty(value))
         ClassListModule.setQuery(value, true);
     });
   }
 }
 
-window_.on('load', (e) => {
+window_.on(DOM.Events.Load, (e) => {
   SearchModule.init();
 });

File diff suppressed because it is too large
+ 963 - 293
static/page/class/script.js


+ 157 - 4
static/page/class/style.css

@@ -7,20 +7,47 @@
 	text-decoration: none !important;
 }
 
-.cm-link:hover {
+.cm-link:hover, .cm-this-prop:hover {
 	text-decoration: underline !important;
 }
 
-.CodeMirror.ctrl-pressed .cm-link:hover {
+.CodeMirror.ctrl-pressed .cm-link:hover,
+.CodeMirror.ctrl-pressed .cm-this-prop:hover {
 	cursor: pointer !important;
 }
 
+.CodeMirror .z8-locale {
+	background-color: #89d4ff21;
+	font-style: italic;
+}
+
 .right.mode-list {
 	height: fit-content;
 }
 
 .right-header {
 	padding: 8px 10px 0 10px;
+	color: #d1d1d1;
+}
+
+.right-header.class-not-found {
+	padding-top: 200px;
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+}
+
+.right-header.class-not-found > .class-not-found-text {
+	font-size: 30px;
+	text-align: center;
+	margin-top: 30px;
+}
+
+.right-header.class-not-found > .class-not-found-image {
+	background: url(/img/404.svg);
+	background-size: cover;
+	width: 256px;
+	height: 256px;
 }
 
 .right-header > .right-header-top {
@@ -32,7 +59,6 @@
 
 .right-header > .right-header-top > .class-name {
 	font-size: 24px;
-	color: #d1d1d1;
 }
 
 .right-header > .right-header-top > .class-name > .class-icon {
@@ -86,7 +112,6 @@
 .right-header > .tabs > .tab {
 	border: 1px solid #d1d1d1;
 	border-bottom: 0;
-	color: #d1d1d1;
 	padding: 5px 10px;
 	cursor: pointer;
 	position: relative;
@@ -139,6 +164,10 @@
 	padding: 10px;
 }
 
+.right.mode-list > .content > .content-tab.empty {
+	display: none !important;
+}
+
 .right.mode-list > .content > .content-tab:not(:last-child) {
 	display: block;
 	border-bottom: 1px solid #d1d1d1;
@@ -150,6 +179,7 @@
 
 .right.mode-list > .content > .content-tab#editor > .CodeMirror {
 	border: 1px solid #d1d1d1;
+	height: 1080px;
 }
 
 .right.mode-tabs > .content > .content-tab > .content-tab-title  {
@@ -163,12 +193,130 @@
 .right:not(.mode-list) > .content > .content-tab:not(#editor) {
 	height: calc(100% - 105px);
 	overflow-y: auto;
+	padding: 10px;
 }
 
 .right.mode-list > .right-header > .tabs {
 	display: none;
 }
 
+.right > .content > .content-tab > .properties-header {
+	display: flex;
+	align-items: center;
+	font-size: 20px;
+	user-select: none;
+	cursor: pointer;
+	margin-bottom: 10px;
+}
+
+.right > .content > .content-tab > .properties-header > .properties-header-collapsed-icon {
+	background: url(/img/chevron.svg);
+	transform: rotate(90deg);
+	width: 20px;
+	height: 20px;
+	background-size: cover;
+	margin-right: 5px;
+	display: inline-block;
+	transition: transform 0.2s ease-in-out;
+}
+
+.right > .content > .content-tab > .properties-header.collapsed > .properties-header-collapsed-icon {
+	transform: rotate(0deg);
+}
+
+.right > .content > .content-tab > .properties-list {
+	padding-left: 20px;
+}
+
+.right > .content > .content-tab > .properties-list > .property-item.highlighted {
+	transition: background-color 1s ease-in-out;
+}
+
+.right > .content > .content-tab > .properties-list > .property-item.highlighted.white {
+	background-color: #ffffff8a;
+}
+
+.right > .content > .content-tab > .properties-list > .property-item {
+	margin-bottom: 15px;
+	padding: 10px;
+	border: 1px solid #d1d1d1;
+	background-color: #00000020;
+	border-radius: 8px;
+	cursor: pointer;
+	user-select: none;
+	position: relative;
+}
+
+.right > .content > .content-tab > .properties-list > .property-item:hover {
+	background-color: #00000040;
+}
+
+.right > .content > .content-tab > .properties-list > .property-item > div:not(:last-child) {
+	margin-bottom: 8px;
+}
+
+.right > .content > .content-tab > .properties-list > .property-item > .property-item-name > .property-item-name-span,
+.right > .content > .content-tab > .properties-list > .property-item > .property-item-type > .property-item-type-span,
+.right > .content > .content-tab > .properties-list > .property-item > .property-item-default-value > .property-item-default-value-span {
+	font-family: monospace;
+	background-color: #00000090;
+	padding: 2px 5px;
+	cursor: default;
+}
+
+.right > .content > .content-tab > .properties-list > .property-item > .property-item-nearest-parent > .property-item-nearest-parent-span {
+	text-decoration: underline;
+	cursor: pointer;
+}
+
+.right > .content > .content-tab > .properties-list > .property-item > .property-item-nearest-parent > .property-item-nearest-parent-span:hover {
+	background-color: #00000090;
+}
+
+.right > .content > .content-tab > .properties-list > .property-item > .property-item-comment > .property-item-comment-input,
+.right > .content > .content-tab > .properties-list > .property-item > .property-item-comment > .property-item-comment-static {
+	width: 100%;
+	padding: 10px;
+	min-height: 100px;
+	border: 1px solid #d1d1d1;
+	background-color: #0000;
+	resize: none;
+	color: #d1d1d1;
+	font-style: italic;
+	border-radius: 4px;
+	outline: none;
+	font-family: 'Play';
+}
+
+.right > .content > .content-tab > .properties-list > .property-item > .property-item-comment > .property-item-comment-static {
+	width: auto;
+}
+
+.right > .content > .content-tab > .properties-list > .property-item > .property-item-comment > .property-item-comment-static.empty {
+	color: #999;
+}
+
+.right > .content > .content-tab > .properties-list > .property-item > .property-item-comment > .property-item-comment-date {
+	margin-top: 15px;
+	height: 25px;
+}
+
+.right > .content > .content-tab > .properties-list > .property-item > .property-item-comment > .property-item-comment-button {
+	width: 50px;
+	text-align: center;
+	border: 1px solid #d1d1d1;
+	position: absolute;
+	bottom: 18px;
+	right: 18px;
+	border-radius: 5px;
+	padding: 3px 0;
+	cursor: pointer;
+}
+
+.right > .content > .content-tab > .properties-list > .property-item > .property-item-comment > .property-item-comment-button:hover {
+	background-color: #ffffff40;
+}
+
 /* Full Source Prompt >>> */
 
 .right > .content > .content-tab#editor > .full-source-prompt {
@@ -186,6 +334,11 @@
 	user-select: none;
 }
 
+.right.mode-list > .content > .content-tab#editor > .full-source-prompt {
+	top: 80px;
+	right: 25px;
+}
+
 .right > .content > .content-tab#editor > .full-source-prompt:hover {
 	opacity: 0.95;
 }

+ 136 - 0
static/page/faq/script.js

@@ -0,0 +1,136 @@
+class FaqPage {
+  static FaqNotes = {
+    'ru': {
+      'Что это?':
+        'Doczilla JS Docs - это инструмент для документирования клиентского кода Doczilla.',
+      'Зачем?':
+        'Главной целью данного инструмента является упрощение процесса ознакомления разработчиков с клиентской частью фреймворка Z8.',
+      'Как работает?':
+        '1. Автоматическое обновление требуемых репозиториев раз в сутки.\n' +
+        '2. Двухэтапный анализ кода.\n' +
+          '<span class="list-spacing"></span>2.1. Статический анализ. Маппинг классов и путей к файлам, в которых описаны классы. Определение динамически заданных свойств (this).\n' +
+          '<span class="list-spacing"></span>2.2. Динамический анализ. Определение типов свойств, сигнатур методов. Формирование дерева предков, "mixins" для каждого класса. Категоризация методов и свойств: собственные, переопредленные, наследованные.\n' +
+        '3. Формирование карты классов.\n' +
+        '4. Веб-сервер.',
+      'Как пользоваться? (читатель)':
+        '<b>1. Список классов.</b>\n' +
+          'Список представлен в левой части страницы. Список имеет два режима отображения: древовидная структура и простой список классов. В древовидной структуре классы расположены в пакетах, которые заданы в названиях классов. В простом списке все классы просто перечислены в алфавитном порядке. '+
+          'Режимы переключаются с помощью соответствюущих кнопок, расположенных над строкой поиска. После переключения режима, ваш выбор будет сохранен с помощью cookies на 12 часов. Таким же образом запоминаются и раскрытые пакеты в древовидной структуре списка.\n\n' +
+        '<b>2. Поиск по классам.</b>\n' +
+          'Чтобы найти интересующие классы, необходимо написать запрос в соответствующее поле, после чего нажать Enter. При нажатии на элемент класса в списке, вы будете перенаправлены на страницу выбранного класса.\n\n'+
+        '<b>3. Страница класса.</b>\n' +
+          'На странице класса доступно два режима отображения: вкладки и список. Режим переключается с помощью соответствующих кнопок в верхней правой части страницы. После переключения режима, ваш выбор будет сохранен с помощью cookies на 12 часов.\n' +
+          'В верхней части страницы расположено наименование класса, а также процент заполненной документации к классу.\n' +
+          'В основной части страницы можно наблюдать 7 разделов: Editor, Methods, Properties, Parents, Mixins, Children, Mixed in.\n\n' +
+          'Подробнее про каждый из разделов можно прочитать в следующем пункте FAQ.',
+      'Разделы страницы класса':
+        '<b>1. Editor</b>\n' +
+          'В данном разделе представлен readonly редактор кода с подсветкой синтаксиса JS. Редактор обладает некоторыми особыми функциями:\n' +
+            '<span class="list-spacing"></span>1. Переход к родительскому классу. Осуществляется при помощи нажатия Ctrl+Click по наименованию родительского класса в свойстве extend.\n' +
+            '<span class="list-spacing"></span>2. Переход к mixins. Осуществляется при помощи нажатия Ctrl+Click по наименованиям классов перечисленных в свойстве mixins.\n' +
+            '<span class="list-spacing"></span>3. Предпросмотр i18n messages. Все места вида "Z8.$(\'...\')" подсвечены в коде, чтобы увидеть, какие строки в действительности кроются за этими выражениями, нужно просто навести курсор мыши на них.\n\n' +
+        '<b>2. Methods</b>\n' +
+        'В данном разделе представлен список методов класса. Методы разделены на следующие категории:\n' +
+          '<span class="list-spacing"></span>1. Static methods: статические методы, описанные в свойстве statics.\n'+
+          '<span class="list-spacing"></span>2. Base methods: собственные методы класса, которых нет ни в одном из родительских классов.\n' +
+          '<span class="list-spacing"></span>3. Overridden methods: переопределенные методы. Если метод отображается в данной категории, значит, он встречается в одном из родительских классов и явным образом переопределен в текущем классе.\n' +
+          '<span class="list-spacing"></span>4. Inherited: наследованные методы. Данные методы пристутствуют в родительских классах, но не были переопределены в текущем классе.\n' +
+        'Комментируемыми являются методы всех категорий, кроме Inherited. В Inherited отображаются readonly-комментарии, которые берутся из ближайших родительских классов (последний ближайший родительский класс, в котором метод был переопределен).\n\n' +
+        '<b>3. Properties</b>\n' +
+          'В данном разделе представлен список свойств класса. Свойства разделены на те же категории, что и методы, + категория Dynamic properties.\n' +
+          'В категории Dynamic properties перечислены свойства, которые задаются "динамически" в коде (например, this.abc = 1).\n' +
+          'В отличие от методов, в свойствах, помимо наименования, в большинстве случаев отображается также тип данных и значение по-умолчанию.\n\n' +
+        '<b>4. Parents</b>\n' +
+          'В данном разделе отображается родительская ветка текущего класса (от самого базового класса к ближайшему родительскому).\n\n' +
+        '<b>5. Mixins</b>\n' +
+          'В данном разделе перечислен список mixins.\n\n' +
+        '<b>6. Children</b>\n' +
+          'В данном разделе перечислен список классов, которым текущий класс приходится родителем (не обязательно ближайшим).\n\n' +
+        '<b>7. Mixed in</b>\n' +
+          'В данном разделе представлен список классов, в которых текущий класс присутстсвует в свойстве mixins.',
+      'Как пользоваться? (редактор)':
+        'Сперва необходимо запросить доступ к ресурсу. Будут выданы логин и пароль, которые следует ввести на <a href="/login">данной странице</a>. Также на эту страницу можно попасть с помощью кнопки <img width="24px" height="24px" src="/img/user.svg" />, находящейся над строкой поиска в левой части страницы. Будучи авторизованными, у вас появится возможность редактировать комментарии.<hr/>' +
+        'Комментируемыми объектами являются свойства(Properties) и методы(Methods) классов, причем комментировать можно только не Inherited.\n' +
+        'Чтобы оставить комментарий нужно нажать на поле комментария, после чего появится поле ввода (textarea). Комментарий может содержать html-теги. Переносы строк автоматически конвертируются в тег &lt;br&gt;!\n' +
+        'После завершения написания комментария, необходимо нажать на кнопку "OK".'
+    },
+
+    'en': {
+      'What is it?':
+        'Doczilla JS Docs - is a tool for documenting client-side code of Doczilla.',
+      'Why?':
+        'The main goal of this tool is to simplify the process of familiarizing developers with the client part of the Z8 framework.',
+      'How does it work?':
+        '1. Automatic updating of required repositories once a day.\n' +
+        '2. Two-stage code analysis.\n' +
+          '<span class="list-spacing"></span>2.1. Static analysis. Mapping of classes and paths to files where classes are described. Determination of dynamically assigned properties (this).\n' +
+          '<span class="list-spacing"></span>2.2. Dynamic analysis. Determination of property types, method signatures. Formation of ancestor tree, "mixins" for each class. Categorization of methods and properties: own, overridden, inherited.\n' +
+        '3. Generating a class map.\n' +
+        '4. Web server.',
+      'How to use? (reader)':
+        '<b>1. Class list.</b>\n' +
+          'The list is presented on the left side of the page. The list has two display modes: tree structure and simple class list. In tree structure, classes are arranged in packages specified in class names. In the simple list, all classes are simply listed in alphabetical order. '+
+          'Modes are switched using the corresponding buttons located above the search bar. After switching the mode, your choice will be saved using cookies for 12 hours. The same applies to expanded packages in the tree structure list.\n\n' +
+        '<b>2. Class search.</b>\n' +
+          'To find classes of interest, you need to enter a query in the corresponding field, then press Enter. When clicking on a class item in the list, you will be redirected to the page of the selected class.\n\n'+
+        '<b>3. Class page.</b>\n' +
+          'On the class page, there are two display modes: tabs and list. The mode is switched using the corresponding buttons in the top right corner of the page. After switching the mode, your choice will be saved using cookies for 12 hours.\n' +
+          'At the top of the page is the class name and the percentage of documentation filled for the class.\n' +
+          'In the main part of the page, you can see 7 sections: Editor, Methods, Properties, Parents, Mixins, Children, Mixed in.\n\n' +
+          'More details about each section can be found in the next FAQ item.',
+      'Class page sections':
+        '<b>1. Editor</b>\n' +
+          'This section presents a readonly code editor with JS syntax highlighting. The editor has some special features:\n' +
+            '<span class="list-spacing"></span>1. Go to parent class. Done by Ctrl+Click on the parent class name in the extend property.\n' +
+            '<span class="list-spacing"></span>2. Go to mixins. Done by Ctrl+Click on the names of classes listed in the mixins property.\n' +
+            '<span class="list-spacing"></span>3. Preview i18n messages. All occurrences of "Z8.$(\'...\')" are highlighted in the code, to see what strings are actually behind these expressions, simply hover over them with the mouse cursor.\n\n' +
+        '<b>2. Methods</b>\n' +
+          'This section presents a list of class methods. Methods are divided into the following categories:\n' +
+            '<span class="list-spacing"></span>1. Static methods: static methods described in the statics property.\n'+
+            '<span class="list-spacing"></span>2. Base methods: own class methods that are not in any of the parent classes.\n' +
+            '<span class="list-spacing"></span>3. Overridden methods: overridden methods. If a method is displayed in this category, it means it is encountered in one of the parent classes and is explicitly overridden in the current class.\n' +
+            '<span class="list-spacing"></span>4. Inherited: inherited methods. These methods exist in parent classes but have not been overridden in the current class.\n' +
+          'Methods of all categories except Inherited are commentable. In Inherited, readonly comments are displayed, taken from the nearest parent classes (the last nearest parent class where the method was overridden).\n\n' +
+        '<b>3. Properties</b>\n' +
+          'This section presents a list of class properties. Properties are divided into the same categories as methods, plus the Dynamic properties category.\n' +
+          'The Dynamic properties category lists properties that are set "dynamically" in the code (for example, this.abc = 1).\n' +
+          'Unlike methods, properties, in addition to the name, in most cases also display the data type and default value.\n\n' +
+        '<b>4. Parents</b>\n' +
+          'This section displays the parent branch of the current class (from the base class to the nearest parent).\n\n' +
+        '<b>5. Mixins</b>\n' +
+          'This section lists mixins.\n\n' +
+        '<b>6. Children</b>\n' +
+          'This section lists classes for which the current class is a parent (not necessarily the nearest).\n\n' +
+        '<b>7. Mixed in</b>\n' +
+          'This section presents a list of classes in which the current class is present in the mixins property.',
+      'How to use? (editor)':
+        'First, you need to request access to the resource. You will be given a login and password, which should be entered on <a href="/login">this page</a>. You can also access this page using the <img width="24px" height="24px" src="/img/user.svg" /> button located above the search bar on the left side of the page. Once authorized, you will be able to edit comments.<hr/>' +
+        'Commentable objects are properties (Properties) and methods (Methods) of classes, and you can only comment on non-Inherited ones.\n' +
+        'To leave a comment, click on the comment field, after which an input field (textarea) will appear. Comments can contain HTML tags. Line breaks are automatically converted to the <br> tag!\n' +
+        'After finishing writing the comment, click the "OK" button.'
+      }
+  };
+  
+  start() {
+    const faqContent = DOM.get('.faq-content');
+    const lang = Url.getHash() || 'en';
+    const faq = FaqPage.FaqNotes[lang] || FaqPage.FaqNotes['en'];
+    let index = 1;
+
+    for(const theme of Object.keys(faq)) {
+      const noteContent = DOM.create({ tag: DOM.Tags.Div, cls: 'faq-note-content hidden', innerHTML: CDUtils.nl2br(faq[theme]) });
+      const noteThemeText = DOM.create({ tag: DOM.Tags.Span, innerHTML: `${index}. ${theme}` });
+      const noteThemeCollapsedIcon = DOM.create({ tag: DOM.Tags.Div, cls: 'collapsed-icon' });
+      const noteTheme = DOM.create({ tag: DOM.Tags.Div, cls: 'faq-note-theme collapsed', cn: [noteThemeText, noteThemeCollapsedIcon] }, faqContent).on('click', (e) => {
+        noteContent.switchClass('hidden');
+        noteTheme.switchClass('collapsed');
+      });
+      faqContent.append(noteContent);
+      index++;
+    }
+  }
+}
+
+window_.on(DOM.Events.Load, (e) => {
+  window.page = new FaqPage().start();
+});

+ 52 - 0
static/page/faq/style.css

@@ -0,0 +1,52 @@
+.main {
+	align-items: center;
+	flex-direction: column;
+}
+
+.main-title {
+	width: 440px;
+}
+
+.faq-content {
+	width: 700px;
+	color: #d1d1d1;
+	margin-top: 25px;
+}
+
+.faq-content > .faq-note-theme {
+	display: flex;
+	align-items: center;
+	font-size: 30px;
+	cursor: pointer;
+	user-select: none;
+	margin-top: 20px;
+}
+
+.faq-content > .faq-note-content {
+	font-size: 20px;
+	border-left: 1px solid #d1d1d1;
+	padding: 0 10px;
+	margin-bottom: 10px;
+	margin-left: 10px;
+	background-color: #0004;
+}
+
+.faq-content > .faq-note-theme > .collapsed-icon {
+	background: url(/img/chevron.svg);
+	transform: rotate(90deg);
+	width: 30px;
+	height: 30px;
+	background-size: cover;
+	margin-right: 5px;
+	display: inline-block;
+	transition: transform 0.2s ease-in-out;
+}
+
+.faq-content > .faq-note-theme.collapsed > .collapsed-icon {
+	transform: rotate(0deg);
+}
+
+span.list-spacing {
+	width: 20px;
+	display: inline-block;
+}

+ 10 - 11
static/page/index/script.js

@@ -9,14 +9,14 @@ class IndexPage {
 	
 	start() {
 		this.codeMirrorEditor = CodeMirror(DOM.get('#editor').get(), {
-			value:	      this.editorHeader,
-			theme:        'darcula',
-			readOnly:     true,
-			lineNumbers:  true,
-			scrollbarStyle: 'null'
+			[App.CodeMirrorProperties.Value]:						this.editorHeader,
+			[App.CodeMirrorProperties.Theme]:						'darcula',
+			[App.CodeMirrorProperties.Readonly]:				true,
+			[App.CodeMirrorProperties.LineNumbers]:			true,
+			[App.CodeMirrorProperties.ScrollbarStyle]:	'null'
 		}).customOnBlur((cm, e) => {}).customHasFocus(() => true).customEnsureCursorVisible((cm) => {});
 
-		this.codeMirrorEditor.focus();
+		this.codeMirrorEditor.cmFocus();
 
 		return this;
 	}
@@ -27,7 +27,7 @@ class IndexPage {
 		const emptyMatrix = matrix.map((line) => CDUtils.repeat(' ', line.length));
 		const tabs = CDUtils.repeat('\t', indentation);
 
-		const lineCount = editor.lineCount();
+		const lineCount = editor.cmLineCount();
 		const firstLine = lineCount;
 
 		let matrixSymbols = [];
@@ -38,8 +38,7 @@ class IndexPage {
 
 		matrixSymbols = CDUtils.arrayShuffle(matrixSymbols);
 
-		editor.setValue(editor.getValue() + '\n' + tabs + emptyMatrix.join('\n'+tabs));
-		//editor.execCommand("goDocEnd");
+		editor.cmSetValue(editor.cmGetValue() + '\n' + tabs + emptyMatrix.join('\n'+tabs));
 
 		let index = 0;
 
@@ -67,8 +66,8 @@ class IndexPage {
 			if (index < text.length) {
 				const char = text.charAt(index);
 				index++;
-				editor.setValue(editor.getValue() + char);
-				editor.setCursor({line: editor.lineCount()-1, ch: editor.getLine(editor.lineCount()-1).length}, { scroll: false });
+				editor.cmSetValue(editor.cmGetValue() + char);
+				editor.setCursor({ line: editor.cmLineCount() - 1, ch: editor.cmGetLine(editor.cmLineCount() - 1).length }, { scroll: false });
 				const nextCharDelay = CDUtils.randInt(lt, ht);
 				setTimeout(typeCharacter, nextCharDelay);
 			} else {

+ 49 - 0
static/page/login/script.js

@@ -0,0 +1,49 @@
+class LoginPage {
+	start() {
+		this.loginInput = DOM.get('.login-form>.login-form-login>.login-form-login-input').focus();
+		this.passwordInput = DOM.get('.login-form>.login-form-password>.login-form-password-input');
+		this.loginButton = DOM.get('.login-form>.login-form-button');
+		this.failureText = DOM.get('.login-form>.login-form-failure');
+
+		this.registerEventHandlers();
+
+		return this;
+	}
+
+	registerEventHandlers() {
+		this.loginButton.on(DOM.Events.Click, this.authorize.bind(this));
+		window_.on(DOM.Events.KeyDown, (e) => {
+			if(e.key === DOM.Keys.Enter)
+				this.authorize();
+		});
+	}
+
+	authorize(e) {
+		const login = this.loginInput.getValue();
+		const password = this.passwordInput.getValue();
+
+		CDUtils.SHA256(password).then((hashedPassword) => {
+			fetch('/authorize', {
+				method: 'POST',
+				headers:{
+					'Content-Type': 'application/x-www-form-urlencoded'
+				},
+				body: new URLSearchParams({
+					'login': login,
+					'password': hashedPassword
+				})
+			}).then((res) => {
+				if(res.status === 200)
+					Url.goTo('/');
+				else if(res.status === 403)
+					this.failureText.setInnerHTML('Wrong login or password');
+        else
+          this.failureText.setInnerHTML('Internal server error');
+			})
+		});
+	}
+}
+
+window_.on(DOM.Events.Load, (e) => {
+	window.page = new LoginPage().start();
+});

+ 52 - 0
static/page/login/style.css

@@ -0,0 +1,52 @@
+.main {
+	justify-content: center;
+	align-items: center;
+}
+
+.login-form {
+	display: flex;
+	flex-direction: column;
+	align-items: flex-end;
+}
+
+.login-form > .login-form-failure {
+	color: #c00;
+	font-size: 20px;
+	height: 25px;
+	text-align: center;
+	margin-bottom: 5px;
+	width: 100%;
+}
+
+.login-form > .login-form-button {
+	width: fit-content;
+	border: 1px solid #d1d1d1;
+	padding: 3px 10px;
+	border-radius: 5px;
+	user-select: none;
+	background-color: #e7e7e7;
+	cursor: pointer;
+	font-size: 20px;
+}
+
+.login-form > .login-form-button:hover {
+	background-color: #bababa;
+}
+
+.login-form > .login-form-login,
+.login-form > .login-form-password {
+	width: 100%;
+}
+
+.login-form > .login-form-login > .login-form-login-input,
+.login-form > .login-form-password > .login-form-password-input {
+	width: 100%;
+	margin-bottom: 10px;
+	border: 1px solid #d1d1d1;
+	background-color: #0000;
+	border-radius: 8px;
+	font-size: 20px;
+	outline: none;
+	padding: 8px 10px;
+	color: #d1d1d1;
+}

+ 137 - 9
static/style/style.css

@@ -16,6 +16,8 @@ body {
 	background-color: #443f3b;
 	font-family: 'Play', monospace, sans-serif;
 	font-weight: 400;
+	-webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
 }
 
 .left {
@@ -34,7 +36,24 @@ body {
 	width: 100%;
 }
 
-.left > .left-header > .title {
+.sources-updating {
+	display: flex;
+	flex-direction: column;
+	height: 100%;
+	justify-content: center;
+	align-items: center;
+}
+
+.sources-updating > .message-container {
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+	width: 400px;
+	border: 2px dashed #d1d1d1;
+	padding: 10px;
+}
+
+.main-title {
 	display: flex;
 	align-items: center;
 	justify-content: space-around;
@@ -45,13 +64,47 @@ body {
 	width: 340px;
 }
 
-.logo {
+.sources-updating > .message-container > .message {
+	color: #d1d1d1;
+	font-size: 24px;
+}
+
+.main-title > .logo {
 	background: url(/favicon.png);
 	background-size: cover;
 	width: 50px;
 	height: 50px;
 }
 
+/* >>> 404 page */
+body.page-not-found {
+	display: flex;
+	justify-content: center;
+}
+
+body.page-not-found > .message-container {
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+	justify-content: center;
+	color: #d1d1d1;
+}
+
+body.page-not-found > .message-container > .page-not-found-text {
+	font-size: 30px;
+	text-align: center;
+	margin-top: 30px;
+}
+
+body.page-not-found > .message-container > .page-not-found-image {
+	margin-top: 20px;
+	background: url(/img/404.svg);
+	background-size: cover;
+	width: 256px;
+	height: 256px;
+}
+/* <<< 404 page */
+
 .main {
 	display: flex;
 	flex-direction: row;
@@ -70,7 +123,7 @@ body {
 	width: 20%;
 }
 
-.left > .left-header > .class-list-mode {
+.left > .left-header > .left-header-toolbar {
 	display: flex;
 	align-items: center;
 	padding-bottom: 10px;
@@ -78,12 +131,14 @@ body {
 	justify-content: center;
 }
 
-.left > .left-header > .class-list-mode > .class-list-mode-text {
+.left > .left-header > .left-header-toolbar > .class-list-mode-text {
 	color: #d1d1d1;
 	margin-right: 15px;
 }
 
-.left > .left-header > .class-list-mode > .class-list-mode-button {
+.left > .left-header > .left-header-toolbar > .class-list-mode-button,
+.left > .left-header > .left-header-toolbar > .faq-button,
+.left > .left-header > .left-header-toolbar > .refresh-button {
 	width: 24px;
 	height: 24px;
 	border: 1px solid #d1d1d1;
@@ -94,20 +149,48 @@ body {
 	cursor: pointer;
 }
 
-.left > .left-header > .class-list-mode > .class-list-mode-button.selected,
-.left > .left-header > .class-list-mode > .class-list-mode-button:hover {
+.left > .left-header > .left-header-toolbar > .class-list-mode-button.selected,
+.left > .left-header > .left-header-toolbar > .class-list-mode-button:hover,
+.left > .left-header > .left-header-toolbar > .faq-button:hover,
+.left > .left-header > .left-header-toolbar > .refresh-button:hover {
 	background-color: #ffffff40;
 }
 
-.left > .left-header > .class-list-mode > .class-list-mode-button.structurized {
+.left > .left-header > .left-header-toolbar > .class-list-mode-button.structurized {
 	background-image: url(/img/file-structure.svg);
 	margin-right: 5px;
 }
 
-.left > .left-header > .class-list-mode > .class-list-mode-button.unstructurized {
+.left > .left-header > .left-header-toolbar > .class-list-mode-button.unstructurized {
 	background-image: url(/img/fa-bars.svg);
 }
 
+.left > .left-header > .left-header-toolbar > .faq-button {
+	background-image: url(/img/faq.svg);
+	margin-left: 10px;
+}
+
+.left > .left-header > .left-header-toolbar > .refresh-button {
+	background-image: url(/img/refresh.svg);
+	margin-left: 10px;
+}
+
+.left > .left-header > .left-header-toolbar > .auth-button {
+	width: 24px;
+	height: 24px;
+	background-size: cover;
+	margin-right: 40px;
+	cursor: pointer;
+}
+
+.left > .left-header > .left-header-toolbar > .auth-button.login-button {
+	background-image: url(/img/user.svg);
+}
+
+.left > .left-header > .left-header-toolbar > .auth-button.logout-button {
+	background-image: url(/img/logout.svg);
+}
+
 /* Class/Dir items >>> */
 
 .class-item, .dir-item>.dir-name {
@@ -177,8 +260,53 @@ body {
 	height: 0 !important;
 }
 
+a, a:visited {
+	color: #d1d1d1;
+}
+
 /* <<< Class/Dir items */
 
+.context-menu {
+	position: absolute;
+	display: flex;
+	flex-direction: column;
+	z-index: 10000;
+	color: #d1d1d1;
+	width: fit-content;
+	background-color: #312d2a;
+	border: 1px solid #d1d1d1;
+	box-shadow: 3px 3px 3px #0004;
+}
+
+.context-menu > .context-menu-item {
+	padding: 5px 10px;
+	user-select: none;
+	cursor: pointer;
+}
+
+.context-menu > .context-menu-item {
+	margin: 3px 0;
+}
+
+.context-menu > .context-menu-item:hover {
+	background-color: #ffffff40;
+}
+
+.context-menu > .context-menu-delimiter {
+	border-bottom: 1px solid #d1d1d1;
+	width: 100%;
+}
+
+.context-menu-overlay {
+	position: absolute;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	margin: auto;
+	z-index: 9999;
+}
+
 .hidden {
 	display: none !important;
 	visibility: hidden !important;

Some files were not shown because too many files changed in this diff