;
}
diff --git a/client/homebrew/pages/editPage/editPage.jsx b/client/homebrew/pages/editPage/editPage.jsx
index 39a6d1931..18cfb2d41 100644
--- a/client/homebrew/pages/editPage/editPage.jsx
+++ b/client/homebrew/pages/editPage/editPage.jsx
@@ -1,8 +1,9 @@
/* eslint-disable max-lines */
require('./editPage.less');
const React = require('react');
-const createClass = require('create-react-class');
const _ = require('lodash');
+const createClass = require('create-react-class');
+
const request = require('../../utils/request-middleware.js');
const { Meta } = require('vitreum/headtags');
@@ -27,6 +28,8 @@ const Markdown = require('naturalcrit/markdown.js');
const { DEFAULT_BREW_LOAD } = require('../../../../server/brewDefaults.js');
const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpers.js');
+import { updateHistory, versionHistoryGarbageCollection } from '../../utils/versionHistory.js';
+
const googleDriveIcon = require('../../googleDrive.svg');
const SAVE_TIMEOUT = 3000;
@@ -41,22 +44,24 @@ const EditPage = createClass({
getInitialState : function() {
return {
- brew : this.props.brew,
- isSaving : false,
- isPending : false,
- alertTrashedGoogleBrew : this.props.brew.trashed,
- alertLoginToTransfer : false,
- saveGoogle : this.props.brew.googleId ? true : false,
- confirmGoogleTransfer : false,
- error : null,
- htmlErrors : Markdown.validate(this.props.brew.text),
- url : '',
- autoSave : true,
- autoSaveWarning : false,
- unsavedTime : new Date(),
- currentEditorPage : 0,
- displayLockMessage : this.props.brew.lock || false,
- themeBundle : {}
+ brew : this.props.brew,
+ isSaving : false,
+ isPending : false,
+ alertTrashedGoogleBrew : this.props.brew.trashed,
+ alertLoginToTransfer : false,
+ saveGoogle : this.props.brew.googleId ? true : false,
+ confirmGoogleTransfer : false,
+ error : null,
+ htmlErrors : Markdown.validate(this.props.brew.text),
+ url : '',
+ autoSave : true,
+ autoSaveWarning : false,
+ unsavedTime : new Date(),
+ currentEditorViewPageNum : 1,
+ currentEditorCursorPageNum : 1,
+ currentBrewRendererPageNum : 1,
+ displayLockMessage : this.props.brew.lock || false,
+ themeBundle : {}
};
},
@@ -113,16 +118,27 @@ const EditPage = createClass({
this.editor.current.update();
},
+ handleEditorViewPageChange : function(pageNumber){
+ this.setState({ currentEditorViewPageNum: pageNumber });
+ },
+
+ handleEditorCursorPageChange : function(pageNumber){
+ this.setState({ currentEditorCursorPageNum: pageNumber });
+ },
+
+ handleBrewRendererPageChange : function(pageNumber){
+ this.setState({ currentBrewRendererPageNum: pageNumber });
+ },
+
handleTextChange : function(text){
//If there are errors, run the validator on every change to give quick feedback
let htmlErrors = this.state.htmlErrors;
if(htmlErrors.length) htmlErrors = Markdown.validate(text);
this.setState((prevState)=>({
- brew : { ...prevState.brew, text: text },
- isPending : true,
- htmlErrors : htmlErrors,
- currentEditorPage : this.editor.current.getCurrentPage() - 1 //Offset index since Marked starts pages at 0
+ brew : { ...prevState.brew, text: text },
+ isPending : true,
+ htmlErrors : htmlErrors,
}), ()=>{if(this.state.autoSave) this.trySave();});
},
@@ -150,6 +166,16 @@ const EditPage = createClass({
return !_.isEqual(this.state.brew, this.savedBrew);
},
+ updateBrew : function(newData){
+ this.setState((prevState)=>({
+ brew : {
+ ...prevState.brew,
+ style : newData.style,
+ text : newData.text
+ }
+ }));
+ },
+
trySave : function(immediate=false){
if(!this.debounceSave) this.debounceSave = _.debounce(this.save, SAVE_TIMEOUT);
if(this.hasChanges()){
@@ -202,6 +228,9 @@ const EditPage = createClass({
htmlErrors : Markdown.validate(prevState.brew.text)
}));
+ updateHistory(this.state.brew);
+ versionHistoryGarbageCollection();
+
const transfer = this.state.saveGoogle == _.isNil(this.state.brew.googleId);
const brew = this.state.brew;
@@ -413,6 +442,12 @@ const EditPage = createClass({
renderer={this.state.brew.renderer}
userThemes={this.props.userThemes}
snippetBundle={this.state.themeBundle.snippets}
+ updateBrew={this.updateBrew}
+ onCursorPageChange={this.handleEditorCursorPageChange}
+ onViewPageChange={this.handleEditorViewPageChange}
+ currentEditorViewPageNum={this.state.currentEditorViewPageNum}
+ currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
+ currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
/>
diff --git a/client/homebrew/pages/homePage/homePage.jsx b/client/homebrew/pages/homePage/homePage.jsx
index d7efcaf14..ac3be81df 100644
--- a/client/homebrew/pages/homePage/homePage.jsx
+++ b/client/homebrew/pages/homePage/homePage.jsx
@@ -1,7 +1,6 @@
require('./homePage.less');
const React = require('react');
const createClass = require('create-react-class');
-const _ = require('lodash');
const cx = require('classnames');
const request = require('../../utils/request-middleware.js');
const { Meta } = require('vitreum/headtags');
@@ -32,11 +31,13 @@ const HomePage = createClass({
},
getInitialState : function() {
return {
- brew : this.props.brew,
- welcomeText : this.props.brew.text,
- error : undefined,
- currentEditorPage : 0,
- themeBundle : {}
+ brew : this.props.brew,
+ welcomeText : this.props.brew.text,
+ error : undefined,
+ currentEditorViewPageNum : 1,
+ currentEditorCursorPageNum : 1,
+ currentBrewRendererPageNum : 1,
+ themeBundle : {}
};
},
@@ -61,10 +62,22 @@ const HomePage = createClass({
handleSplitMove : function(){
this.editor.current.update();
},
+
+ handleEditorViewPageChange : function(pageNumber){
+ this.setState({ currentEditorViewPageNum: pageNumber });
+ },
+
+ handleEditorCursorPageChange : function(pageNumber){
+ this.setState({ currentEditorCursorPageNum: pageNumber });
+ },
+
+ handleBrewRendererPageChange : function(pageNumber){
+ this.setState({ currentBrewRendererPageNum: pageNumber });
+ },
+
handleTextChange : function(text){
this.setState((prevState)=>({
- brew : { ...prevState.brew, text: text },
- currentEditorPage : this.editor.current.getCurrentPage() - 1 //Offset index since Marked starts pages at 0
+ brew : { ...prevState.brew, text: text },
}));
},
renderNavbar : function(){
@@ -97,12 +110,20 @@ const HomePage = createClass({
renderer={this.state.brew.renderer}
showEditButtons={false}
snippetBundle={this.state.themeBundle.snippets}
+ onCursorPageChange={this.handleEditorCursorPageChange}
+ onViewPageChange={this.handleEditorViewPageChange}
+ currentEditorViewPageNum={this.state.currentEditorViewPageNum}
+ currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
+ currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
/>
diff --git a/client/homebrew/pages/newPage/newPage.jsx b/client/homebrew/pages/newPage/newPage.jsx
index 5b0f59c00..c147cd474 100644
--- a/client/homebrew/pages/newPage/newPage.jsx
+++ b/client/homebrew/pages/newPage/newPage.jsx
@@ -39,13 +39,15 @@ const NewPage = createClass({
const brew = this.props.brew;
return {
- brew : brew,
- isSaving : false,
- saveGoogle : (global.account && global.account.googleId ? true : false),
- error : null,
- htmlErrors : Markdown.validate(brew.text),
- currentEditorPage : 0,
- themeBundle : {}
+ brew : brew,
+ isSaving : false,
+ saveGoogle : (global.account && global.account.googleId ? true : false),
+ error : null,
+ htmlErrors : Markdown.validate(brew.text),
+ currentEditorViewPageNum : 1,
+ currentEditorCursorPageNum : 1,
+ currentBrewRendererPageNum : 1,
+ themeBundle : {}
};
},
@@ -108,15 +110,26 @@ const NewPage = createClass({
this.editor.current.update();
},
+ handleEditorViewPageChange : function(pageNumber){
+ this.setState({ currentEditorViewPageNum: pageNumber });
+ },
+
+ handleEditorCursorPageChange : function(pageNumber){
+ this.setState({ currentEditorCursorPageNum: pageNumber });
+ },
+
+ handleBrewRendererPageChange : function(pageNumber){
+ this.setState({ currentBrewRendererPageNum: pageNumber });
+ },
+
handleTextChange : function(text){
//If there are errors, run the validator on every change to give quick feedback
let htmlErrors = this.state.htmlErrors;
if(htmlErrors.length) htmlErrors = Markdown.validate(text);
this.setState((prevState)=>({
- brew : { ...prevState.brew, text: text },
- htmlErrors : htmlErrors,
- currentEditorPage : this.editor.current.getCurrentPage() - 1 //Offset index since Marked starts pages at 0
+ brew : { ...prevState.brew, text: text },
+ htmlErrors : htmlErrors,
}));
localStorage.setItem(BREWKEY, text);
},
@@ -221,6 +234,11 @@ const NewPage = createClass({
renderer={this.state.brew.renderer}
userThemes={this.props.userThemes}
snippetBundle={this.state.themeBundle.snippets}
+ onCursorPageChange={this.handleEditorCursorPageChange}
+ onViewPageChange={this.handleEditorViewPageChange}
+ currentEditorViewPageNum={this.state.currentEditorViewPageNum}
+ currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
+ currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
/>
diff --git a/client/homebrew/pages/vaultPage/vaultPage.jsx b/client/homebrew/pages/vaultPage/vaultPage.jsx
index a550ec578..bad1fbd57 100644
--- a/client/homebrew/pages/vaultPage/vaultPage.jsx
+++ b/client/homebrew/pages/vaultPage/vaultPage.jsx
@@ -330,7 +330,7 @@ const VaultPage = (props)=>{
if(error) {
const errorText = ErrorIndex()[error.HBErrorCode.toString()] || '';
-
+
return (
Error: {errorText}
diff --git a/client/homebrew/utils/versionHistory.js b/client/homebrew/utils/versionHistory.js
new file mode 100644
index 000000000..ad7c6102e
--- /dev/null
+++ b/client/homebrew/utils/versionHistory.js
@@ -0,0 +1,116 @@
+export const HISTORY_PREFIX = 'HOMEBREWERY-HISTORY';
+export const HISTORY_SLOTS = 5;
+
+// History values in minutes
+const DEFAULT_HISTORY_SAVE_DELAYS = {
+ '0' : 0,
+ '1' : 2,
+ '2' : 10,
+ '3' : 60,
+ '4' : 12 * 60,
+ '5' : 2 * 24 * 60
+};
+
+const DEFAULT_GARBAGE_COLLECT_DELAY = 28 * 24 * 60;
+
+const HISTORY_SAVE_DELAYS = global.config?.historyData?.HISTORY_SAVE_DELAYS ?? DEFAULT_HISTORY_SAVE_DELAYS;
+const GARBAGE_COLLECT_DELAY = global.config?.historyData?.GARBAGE_COLLECT_DELAY ?? DEFAULT_GARBAGE_COLLECT_DELAY;
+
+
+
+function getKeyBySlot(brew, slot){
+ return `${HISTORY_PREFIX}-${brew.shareId}-${slot}`;
+};
+
+function getVersionBySlot(brew, slot){
+ // Read stored brew data
+ // - If it exists, parse data to object
+ // - If it doesn't exist, pass default object
+ const key = getKeyBySlot(brew, slot);
+ const storedVersion = localStorage.getItem(key);
+ const output = storedVersion ? JSON.parse(storedVersion) : { expireAt: '2000-01-01T00:00:00.000Z', shareId: brew.shareId, noData: true };
+ return output;
+};
+
+function updateStoredBrew(brew, slot = 0) {
+ const archiveBrew = {
+ title : brew.title,
+ text : brew.text,
+ style : brew.style,
+ version : brew.version,
+ shareId : brew.shareId,
+ savedAt : brew?.savedAt || new Date(),
+ expireAt : new Date()
+ };
+
+ archiveBrew.expireAt.setMinutes(archiveBrew.expireAt.getMinutes() + HISTORY_SAVE_DELAYS[slot]);
+
+ const key = getKeyBySlot(brew, slot);
+ localStorage.setItem(key, JSON.stringify(archiveBrew));
+}
+
+
+export function historyExists(brew){
+ return Object.keys(localStorage)
+ .some((key)=>{
+ return key.startsWith(`${HISTORY_PREFIX}-${brew.shareId}`);
+ });
+}
+
+export function loadHistory(brew){
+ const history = {};
+
+ // Load data from local storage to History object
+ for (let i = 1; i <= HISTORY_SLOTS; i++){
+ history[i] = getVersionBySlot(brew, i);
+ };
+
+ return history;
+}
+
+export function updateHistory(brew) {
+ const history = loadHistory(brew);
+
+ // Walk each version position
+ for (let slot = HISTORY_SLOTS; slot > 0; slot--){
+ const storedVersion = history[slot];
+
+ // If slot has expired, update all lower slots and break
+ if(new Date() >= new Date(storedVersion.expireAt)){
+ for (let updateSlot = slot - 1; updateSlot>0; updateSlot--){
+ // Move data from updateSlot to updateSlot + 1
+ !history[updateSlot]?.noData && updateStoredBrew(history[updateSlot], updateSlot + 1);
+ };
+
+ // Update the most recent brew
+ updateStoredBrew(brew, 1);
+
+ // Break out of data checks because we found an expired value
+ break;
+ }
+ };
+};
+
+export function getHistoryItems(brew){
+ const historyArray = [];
+
+ for (let i = 1; i <= HISTORY_SLOTS; i++){
+ historyArray.push(getVersionBySlot(brew, i));
+ }
+
+ return historyArray;
+};
+
+export function versionHistoryGarbageCollection(){
+ Object.keys(localStorage)
+ .filter((key)=>{
+ return key.startsWith(HISTORY_PREFIX);
+ })
+ .forEach((key)=>{
+ const collectAt = new Date(JSON.parse(localStorage.getItem(key)).savedAt);
+ collectAt.setMinutes(collectAt.getMinutes() + GARBAGE_COLLECT_DELAY);
+ if(new Date() > collectAt){
+ localStorage.removeItem(key);
+ }
+ });
+};
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 473ea3834..16d308bf5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -23,9 +23,9 @@
"dedent-tabs": "^0.10.3",
"dompurify": "^3.1.6",
"expr-eval": "^2.0.2",
- "express": "^4.19.2",
+ "express": "^4.21.0",
"express-async-handler": "^1.2.0",
- "express-static-gzip": "2.1.7",
+ "express-static-gzip": "2.1.8",
"fs-extra": "11.2.0",
"js-yaml": "^4.1.0",
"jwt-simple": "^0.5.6",
@@ -38,13 +38,13 @@
"marked-smartypants-lite": "^1.0.2",
"markedLegacy": "npm:marked@^0.3.19",
"moment": "^2.30.1",
- "mongoose": "^8.6.1",
+ "mongoose": "^8.6.2",
"nanoid": "3.3.4",
"nconf": "^0.12.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-frame-component": "^4.1.3",
- "react-router-dom": "6.26.1",
+ "react-router-dom": "6.26.2",
"sanitize-filename": "1.6.3",
"superagent": "^10.1.0",
"vitreum": "git+https://git@github.com/calculuschild/vitreum.git"
@@ -53,7 +53,7 @@
"@stylistic/stylelint-plugin": "^3.0.1",
"eslint": "^9.10.0",
"eslint-plugin-jest": "^28.8.3",
- "eslint-plugin-react": "^7.35.2",
+ "eslint-plugin-react": "^7.36.1",
"globals": "^15.9.0",
"jest": "^29.7.0",
"jest-expect-message": "^1.1.3",
@@ -3025,9 +3025,9 @@
}
},
"node_modules/@remix-run/router": {
- "version": "1.19.1",
- "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.19.1.tgz",
- "integrity": "sha512-S45oynt/WH19bHbIXjtli6QmwNYvaz+vtnubvNpNDvUOoA/OWh6j1OikIP3G+v5GHdxyC6EXoChG3HgYGEUfcg==",
+ "version": "1.19.2",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.19.2.tgz",
+ "integrity": "sha512-baiMx18+IMuD1yyvOGaHM9QrVUPGGG0jC+z+IPHnRJWUAUvaKuWKyE8gjDj2rzv3sz9zOGoRSPgeBVHRhZnBlA==",
"engines": {
"node": ">=14.0.0"
}
@@ -4140,10 +4140,9 @@
"license": "MIT"
},
"node_modules/body-parser": {
- "version": "1.20.2",
- "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz",
- "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==",
- "license": "MIT",
+ "version": "1.20.3",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
+ "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
"dependencies": {
"bytes": "3.1.2",
"content-type": "~1.0.5",
@@ -4153,7 +4152,7 @@
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"on-finished": "2.4.1",
- "qs": "6.11.0",
+ "qs": "6.13.0",
"raw-body": "2.5.2",
"type-is": "~1.6.18",
"unpipe": "1.0.0"
@@ -5601,10 +5600,9 @@
"license": "MIT"
},
"node_modules/encodeurl": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
- "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
- "license": "MIT",
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"engines": {
"node": ">= 0.8"
}
@@ -5915,9 +5913,9 @@
}
},
"node_modules/eslint-plugin-react": {
- "version": "7.35.2",
- "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.35.2.tgz",
- "integrity": "sha512-Rbj2R9zwP2GYNcIak4xoAMV57hrBh3hTaR0k7hVjwCQgryE/pw5px4b13EYjduOI0hfXyZhwBxaGpOTbWSGzKQ==",
+ "version": "7.36.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.36.1.tgz",
+ "integrity": "sha512-/qwbqNXZoq+VP30s1d4Nc1C5GTxjJQjk4Jzs4Wq2qzxFM7dSmuG2UkIjg2USMLh3A/aVcUNrK7v0J5U1XEGGwA==",
"dev": true,
"dependencies": {
"array-includes": "^3.1.8",
@@ -6317,37 +6315,36 @@
"license": "MIT"
},
"node_modules/express": {
- "version": "4.19.2",
- "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz",
- "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==",
- "license": "MIT",
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz",
+ "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
- "body-parser": "1.20.2",
+ "body-parser": "1.20.3",
"content-disposition": "0.5.4",
"content-type": "~1.0.4",
"cookie": "0.6.0",
"cookie-signature": "1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
- "encodeurl": "~1.0.2",
+ "encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
- "finalhandler": "1.2.0",
+ "finalhandler": "1.3.1",
"fresh": "0.5.2",
"http-errors": "2.0.0",
- "merge-descriptors": "1.0.1",
+ "merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
- "path-to-regexp": "0.1.7",
+ "path-to-regexp": "0.1.10",
"proxy-addr": "~2.0.7",
- "qs": "6.11.0",
+ "qs": "6.13.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
- "send": "0.18.0",
- "serve-static": "1.15.0",
+ "send": "0.19.0",
+ "serve-static": "1.16.2",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"type-is": "~1.6.18",
@@ -6365,12 +6362,11 @@
"license": "MIT"
},
"node_modules/express-static-gzip": {
- "version": "2.1.7",
- "resolved": "https://registry.npmjs.org/express-static-gzip/-/express-static-gzip-2.1.7.tgz",
- "integrity": "sha512-QOCZUC+lhPPCjIJKpQGu1Oa61Axg9Mq09Qvit8Of7kzpMuwDeMSqjjQteQS3OVw/GkENBoSBheuQDWPlngImvw==",
- "license": "MIT",
+ "version": "2.1.8",
+ "resolved": "https://registry.npmjs.org/express-static-gzip/-/express-static-gzip-2.1.8.tgz",
+ "integrity": "sha512-g8tiJuI9Y9Ffy59ehVXvqb0hhP83JwZiLxzanobPaMbkB5qBWA8nuVgd+rcd5qzH3GkgogTALlc0BaADYwnMbQ==",
"dependencies": {
- "serve-static": "^1.14.1"
+ "serve-static": "^1.16.2"
}
},
"node_modules/express/node_modules/cookie": {
@@ -6586,13 +6582,12 @@
}
},
"node_modules/finalhandler": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz",
- "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==",
- "license": "MIT",
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
+ "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
"dependencies": {
"debug": "2.6.9",
- "encodeurl": "~1.0.2",
+ "encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
@@ -6607,7 +6602,6 @@
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
- "license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
@@ -6615,8 +6609,7 @@
"node_modules/finalhandler/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
- "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
- "license": "MIT"
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"node_modules/find-up": {
"version": "5.0.0",
@@ -10535,10 +10528,12 @@
}
},
"node_modules/merge-descriptors": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
- "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==",
- "license": "MIT"
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
},
"node_modules/merge-stream": {
"version": "2.0.0",
@@ -10806,9 +10801,9 @@
}
},
"node_modules/mongoose": {
- "version": "8.6.1",
- "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.6.1.tgz",
- "integrity": "sha512-dppGcYqvsdg+VcnqXR5b467V4a+iNhmvkfYNpEPi6AjaUxnz6ioEDmrMLOi+sOWjvoHapuwPOigV4f2l7HC6ag==",
+ "version": "8.6.2",
+ "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.6.2.tgz",
+ "integrity": "sha512-ErbDVvuUzUfyQpXvJ6sXznmZDICD8r6wIsa0VKjJtB6/LZncqwUn5Um040G1BaNo6L3Jz+xItLSwT0wZmSmUaQ==",
"dependencies": {
"bson": "^6.7.0",
"kareem": "2.6.3",
@@ -11649,10 +11644,9 @@
}
},
"node_modules/path-to-regexp": {
- "version": "0.1.7",
- "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
- "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==",
- "license": "MIT"
+ "version": "0.1.10",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz",
+ "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w=="
},
"node_modules/path-type": {
"version": "4.0.0",
@@ -12086,12 +12080,11 @@
"license": "MIT"
},
"node_modules/qs": {
- "version": "6.11.0",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz",
- "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==",
- "license": "BSD-3-Clause",
+ "version": "6.13.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
+ "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
"dependencies": {
- "side-channel": "^1.0.4"
+ "side-channel": "^1.0.6"
},
"engines": {
"node": ">=0.6"
@@ -12216,11 +12209,11 @@
"license": "MIT"
},
"node_modules/react-router": {
- "version": "6.26.1",
- "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.26.1.tgz",
- "integrity": "sha512-kIwJveZNwp7teQRI5QmwWo39A5bXRyqpH0COKKmPnyD2vBvDwgFXSqDUYtt1h+FEyfnE8eXr7oe0MxRzVwCcvQ==",
+ "version": "6.26.2",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.26.2.tgz",
+ "integrity": "sha512-tvN1iuT03kHgOFnLPfLJ8V95eijteveqdOSk+srqfePtQvqCExB8eHOYnlilbOcyJyKnYkr1vJvf7YqotAJu1A==",
"dependencies": {
- "@remix-run/router": "1.19.1"
+ "@remix-run/router": "1.19.2"
},
"engines": {
"node": ">=14.0.0"
@@ -12230,12 +12223,12 @@
}
},
"node_modules/react-router-dom": {
- "version": "6.26.1",
- "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.26.1.tgz",
- "integrity": "sha512-veut7m41S1fLql4pLhxeSW3jlqs+4MtjRLj0xvuCEXsxusJCbs6I8yn9BxzzDX2XDgafrccY6hwjmd/bL54tFw==",
+ "version": "6.26.2",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.26.2.tgz",
+ "integrity": "sha512-z7YkaEW0Dy35T3/QKPYB1LjMK2R1fxnHO8kWpUMTBdfVzZrWOiY9a7CtN8HqdWtDUWd5FY6Dl8HFsqVwH4uOtQ==",
"dependencies": {
- "@remix-run/router": "1.19.1",
- "react-router": "6.26.1"
+ "@remix-run/router": "1.19.2",
+ "react-router": "6.26.2"
},
"engines": {
"node": ">=14.0.0"
@@ -12733,10 +12726,9 @@
}
},
"node_modules/send": {
- "version": "0.18.0",
- "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz",
- "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==",
- "license": "MIT",
+ "version": "0.19.0",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
+ "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
@@ -12760,7 +12752,6 @@
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
- "license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
@@ -12768,25 +12759,30 @@
"node_modules/send/node_modules/debug/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
- "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
- "license": "MIT"
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
+ },
+ "node_modules/send/node_modules/encodeurl": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
+ "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
+ "engines": {
+ "node": ">= 0.8"
+ }
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "license": "MIT"
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/serve-static": {
- "version": "1.15.0",
- "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz",
- "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==",
- "license": "MIT",
+ "version": "1.16.2",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
+ "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
"dependencies": {
- "encodeurl": "~1.0.2",
+ "encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
- "send": "0.18.0"
+ "send": "0.19.0"
},
"engines": {
"node": ">= 0.8.0"
@@ -14628,21 +14624,6 @@
"integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
"license": "MIT"
},
- "node_modules/url/node_modules/qs": {
- "version": "6.13.0",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
- "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "side-channel": "^1.0.6"
- },
- "engines": {
- "node": ">=0.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
"node_modules/use": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
diff --git a/package.json b/package.json
index 67a9e542f..ef39fbf6f 100644
--- a/package.json
+++ b/package.json
@@ -98,9 +98,9 @@
"dedent-tabs": "^0.10.3",
"dompurify": "^3.1.6",
"expr-eval": "^2.0.2",
- "express": "^4.19.2",
+ "express": "^4.21.0",
"express-async-handler": "^1.2.0",
- "express-static-gzip": "2.1.7",
+ "express-static-gzip": "2.1.8",
"fs-extra": "11.2.0",
"js-yaml": "^4.1.0",
"jwt-simple": "^0.5.6",
@@ -113,13 +113,13 @@
"marked-smartypants-lite": "^1.0.2",
"markedLegacy": "npm:marked@^0.3.19",
"moment": "^2.30.1",
- "mongoose": "^8.6.1",
+ "mongoose": "^8.6.2",
"nanoid": "3.3.4",
"nconf": "^0.12.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-frame-component": "^4.1.3",
- "react-router-dom": "6.26.1",
+ "react-router-dom": "6.26.2",
"sanitize-filename": "1.6.3",
"superagent": "^10.1.0",
"vitreum": "git+https://git@github.com/calculuschild/vitreum.git"
@@ -128,7 +128,7 @@
"@stylistic/stylelint-plugin": "^3.0.1",
"eslint": "^9.10.0",
"eslint-plugin-jest": "^28.8.3",
- "eslint-plugin-react": "^7.35.2",
+ "eslint-plugin-react": "^7.36.1",
"globals": "^15.9.0",
"jest": "^29.7.0",
"jest-expect-message": "^1.1.3",
diff --git a/server/app.js b/server/app.js
index beb0b249c..f5864caae 100644
--- a/server/app.js
+++ b/server/app.js
@@ -1,543 +1,561 @@
-/*eslint max-lines: ["warn", {"max": 500, "skipBlankLines": true, "skipComments": true}]*/
-// Set working directory to project root
-process.chdir(`${__dirname}/..`);
-
-const _ = require('lodash');
-const jwt = require('jwt-simple');
-const express = require('express');
-const yaml = require('js-yaml');
-const app = express();
-const config = require('./config.js');
-
-const { homebrewApi, getBrew, getUsersBrewThemes, getCSS } = require('./homebrew.api.js');
-const GoogleActions = require('./googleActions.js');
-const serveCompressedStaticAssets = require('./static-assets.mv.js');
-const sanitizeFilename = require('sanitize-filename');
-const asyncHandler = require('express-async-handler');
-const templateFn = require('./../client/template.js');
-
-const { DEFAULT_BREW } = require('./brewDefaults.js');
-
-const { splitTextStyleAndMetadata } = require('../shared/helpers.js');
-
-
-const sanitizeBrew = (brew, accessType)=>{
- brew._id = undefined;
- brew.__v = undefined;
- if(accessType !== 'edit' && accessType !== 'shareAuthor') {
- brew.editId = undefined;
- }
- return brew;
-};
-
-app.use('/', serveCompressedStaticAssets(`build`));
-app.use(require('./middleware/content-negotiation.js'));
-app.use(require('body-parser').json({ limit: '25mb' }));
-app.use(require('cookie-parser')());
-app.use(require('./forcessl.mw.js'));
-
-//Account Middleware
-app.use((req, res, next)=>{
- if(req.cookies && req.cookies.nc_session){
- try {
- req.account = jwt.decode(req.cookies.nc_session, config.get('secret'));
- //console.log("Just loaded up JWT from cookie:");
- //console.log(req.account);
- } catch (e){}
- }
-
- req.config = {
- google_client_id : config.get('google_client_id'),
- google_client_secret : config.get('google_client_secret')
- };
- return next();
-});
-
-app.use(homebrewApi);
-app.use(require('./admin.api.js'));
-app.use(require('./vault.api.js'));
-
-const HomebrewModel = require('./homebrew.model.js').model;
-const welcomeText = require('fs').readFileSync('client/homebrew/pages/homePage/welcome_msg.md', 'utf8');
-const welcomeTextLegacy = require('fs').readFileSync('client/homebrew/pages/homePage/welcome_msg_legacy.md', 'utf8');
-const migrateText = require('fs').readFileSync('client/homebrew/pages/homePage/migrate.md', 'utf8');
-const changelogText = require('fs').readFileSync('changelog.md', 'utf8');
-const faqText = require('fs').readFileSync('faq.md', 'utf8');
-
-String.prototype.replaceAll = function(s, r){return this.split(s).join(r);};
-
-const defaultMetaTags = {
- site_name : 'The Homebrewery - Make your Homebrew content look legit!',
- title : 'The Homebrewery',
- description : 'A NaturalCrit Tool for creating authentic Homebrews using Markdown.',
- image : `${config.get('publicUrl')}/thumbnail.png`,
- type : 'website'
-};
-
-//Robots.txt
-app.get('/robots.txt', (req, res)=>{
- return res.sendFile(`robots.txt`, { root: process.cwd() });
-});
-
-//Home page
-app.get('/', (req, res, next)=>{
- req.brew = {
- text : welcomeText,
- renderer : 'V3',
- theme : '5ePHB'
- },
-
- req.ogMeta = { ...defaultMetaTags,
- title : 'Homepage',
- description : 'Homepage'
- };
-
- splitTextStyleAndMetadata(req.brew);
- return next();
-});
-
-//Home page Legacy
-app.get('/legacy', (req, res, next)=>{
- req.brew = {
- text : welcomeTextLegacy,
- renderer : 'legacy',
- theme : '5ePHB'
- },
-
- req.ogMeta = { ...defaultMetaTags,
- title : 'Homepage (Legacy)',
- description : 'Homepage'
- };
-
- splitTextStyleAndMetadata(req.brew);
- return next();
-});
-
-//Legacy/Other Document -> v3 Migration Guide
-app.get('/migrate', (req, res, next)=>{
- req.brew = {
- text : migrateText,
- renderer : 'V3',
- theme : '5ePHB'
- },
-
- req.ogMeta = { ...defaultMetaTags,
- title : 'v3 Migration Guide',
- description : 'A brief guide to converting Legacy documents to the v3 renderer.'
- };
-
- splitTextStyleAndMetadata(req.brew);
- return next();
-});
-
-//Changelog page
-app.get('/changelog', async (req, res, next)=>{
- req.brew = {
- title : 'Changelog',
- text : changelogText,
- renderer : 'V3',
- theme : '5ePHB'
- },
-
- req.ogMeta = { ...defaultMetaTags,
- title : 'Changelog',
- description : 'Development changelog.'
- };
-
- splitTextStyleAndMetadata(req.brew);
- return next();
-});
-
-//FAQ page
-app.get('/faq', async (req, res, next)=>{
- req.brew = {
- title : 'FAQ',
- text : faqText,
- renderer : 'V3',
- theme : '5ePHB'
- },
-
- req.ogMeta = { ...defaultMetaTags,
- title : 'FAQ',
- description : 'Frequently Asked Questions'
- };
-
- splitTextStyleAndMetadata(req.brew);
- return next();
-});
-
-//Source page
-app.get('/source/:id', asyncHandler(getBrew('share')), (req, res)=>{
- const { brew } = req;
-
- const replaceStrings = { '&': '&', '<': '<', '>': '>' };
- let text = brew.text;
- for (const replaceStr in replaceStrings) {
- text = text.replaceAll(replaceStr, replaceStrings[replaceStr]);
- }
- text = `
${text}`;
- res.status(200).send(text);
-});
-
-//Download brew source page
-app.get('/download/:id', asyncHandler(getBrew('share')), (req, res)=>{
- const { brew } = req;
- sanitizeBrew(brew, 'share');
- const prefix = 'HB - ';
-
- const encodeRFC3986ValueChars = (str)=>{
- return (
- encodeURIComponent(str)
- .replace(/[!'()*]/g, (char)=>{`%${char.charCodeAt(0).toString(16).toUpperCase()}`;})
- );
- };
-
- let fileName = sanitizeFilename(`${prefix}${brew.title}`).replaceAll(' ', '');
- if(!fileName || !fileName.length) { fileName = `${prefix}-Untitled-Brew`; };
- res.set({
- 'Cache-Control' : 'no-cache',
- 'Content-Type' : 'text/plain',
- 'Content-Disposition' : `attachment; filename*=UTF-8''${encodeRFC3986ValueChars(fileName)}.txt`
- });
- res.status(200).send(brew.text);
-});
-
-//Serve brew styling
-app.get('/css/:id', asyncHandler(getBrew('share')), (req, res)=>{getCSS(req, res);});
-
-//User Page
-app.get('/user/:username', async (req, res, next)=>{
- const ownAccount = req.account && (req.account.username == req.params.username);
-
- req.ogMeta = { ...defaultMetaTags,
- title : `${req.params.username}'s Collection`,
- description : 'View my collection of homebrew on the Homebrewery.'
- // type : could be 'profile'?
- };
-
- const fields = [
- 'googleId',
- 'title',
- 'pageCount',
- 'description',
- 'authors',
- 'lang',
- 'published',
- 'views',
- 'shareId',
- 'editId',
- 'createdAt',
- 'updatedAt',
- 'lastViewed',
- 'thumbnail',
- 'tags'
- ];
-
- let brews = await HomebrewModel.getByUser(req.params.username, ownAccount, fields)
- .catch((err)=>{
- console.log(err);
- });
-
- if(ownAccount && req?.account?.googleId){
- const auth = await GoogleActions.authCheck(req.account, res);
- let googleBrews = await GoogleActions.listGoogleBrews(auth)
- .catch((err)=>{
- console.error(err);
- });
-
- if(googleBrews && googleBrews.length > 0) {
- for (const brew of brews.filter((brew)=>brew.googleId)) {
- const match = googleBrews.findIndex((b)=>b.editId === brew.editId);
- if(match !== -1) {
- brew.googleId = googleBrews[match].googleId;
- brew.stubbed = true;
- brew.pageCount = googleBrews[match].pageCount;
- brew.renderer = googleBrews[match].renderer;
- brew.version = googleBrews[match].version;
- brew.webViewLink = googleBrews[match].webViewLink;
- googleBrews.splice(match, 1);
- }
- }
-
- googleBrews = googleBrews.map((brew)=>({ ...brew, authors: [req.account.username] }));
- brews = _.concat(brews, googleBrews);
- }
- }
-
- req.brews = _.map(brews, (brew)=>{
- // Clean up brew data
- brew.title = brew.title?.trim();
- brew.description = brew.description?.trim();
- return sanitizeBrew(brew, ownAccount ? 'edit' : 'share');
- });
-
- return next();
-});
-
-//Edit Page
-app.get('/edit/:id', asyncHandler(getBrew('edit')), asyncHandler(async(req, res, next)=>{
- req.brew = req.brew.toObject ? req.brew.toObject() : req.brew;
-
- req.userThemes = await(getUsersBrewThemes(req.account?.username));
-
- req.ogMeta = { ...defaultMetaTags,
- title : req.brew.title || 'Untitled Brew',
- description : req.brew.description || 'No description.',
- image : req.brew.thumbnail || defaultMetaTags.image,
- type : 'article'
- };
-
- sanitizeBrew(req.brew, 'edit');
- splitTextStyleAndMetadata(req.brew);
- res.header('Cache-Control', 'no-cache, no-store'); //reload the latest saved brew when pressing back button, not the cached version before save.
- return next();
-}));
-
-//New Page from ID
-app.get('/new/:id', asyncHandler(getBrew('share')), asyncHandler(async(req, res, next)=>{
- sanitizeBrew(req.brew, 'share');
- splitTextStyleAndMetadata(req.brew);
- const brew = {
- shareId : req.brew.shareId,
- title : `CLONE - ${req.brew.title}`,
- text : req.brew.text,
- style : req.brew.style,
- renderer : req.brew.renderer,
- theme : req.brew.theme,
- tags : req.brew.tags,
- };
- req.brew = _.defaults(brew, DEFAULT_BREW);
-
- req.userThemes = await(getUsersBrewThemes(req.account?.username));
-
- req.ogMeta = { ...defaultMetaTags,
- title : 'New',
- description : 'Start crafting your homebrew on the Homebrewery!'
- };
-
- return next();
-}));
-
-//New Page
-app.get('/new', asyncHandler(async(req, res, next)=>{
- req.userThemes = await(getUsersBrewThemes(req.account?.username));
-
- req.ogMeta = { ...defaultMetaTags,
- title : 'New',
- description : 'Start crafting your homebrew on the Homebrewery!'
- };
-
- return next();
-}));
-
-//Share Page
-app.get('/share/:id', asyncHandler(getBrew('share')), asyncHandler(async (req, res, next)=>{
- const { brew } = req;
- req.ogMeta = { ...defaultMetaTags,
- title : req.brew.title || 'Untitled Brew',
- description : req.brew.description || 'No description.',
- image : req.brew.thumbnail || defaultMetaTags.image,
- type : 'article'
- };
-
- // increase visitor view count, do not include visits by author(s)
- if(!brew.authors.includes(req.account?.username)){
- if(req.params.id.length > 12 && !brew._id) {
- const googleId = brew.googleId;
- const shareId = brew.shareId;
- await GoogleActions.increaseView(googleId, shareId, 'share', brew)
- .catch((err)=>{next(err);});
- } else {
- await HomebrewModel.increaseView({ shareId: brew.shareId });
- }
- };
-
- brew.authors.includes(req.account?.username) ? sanitizeBrew(req.brew, 'shareAuthor') : sanitizeBrew(req.brew, 'share');
- splitTextStyleAndMetadata(req.brew);
- return next();
-}));
-
-//Account Page
-app.get('/account', asyncHandler(async (req, res, next)=>{
- const data = {};
- data.title = 'Account Information Page';
-
- if(!req.account) {
- res.set('WWW-Authenticate', 'Bearer realm="Authorization Required"');
- const error = new Error('No valid account');
- error.status = 401;
- error.HBErrorCode = '50';
- error.page = data.title;
- return next(error);
- };
-
- let auth;
- let googleCount = [];
- if(req.account) {
- if(req.account.googleId) {
- try {
- auth = await GoogleActions.authCheck(req.account, res, false);
- } catch (e) {
- auth = undefined;
- console.log('Google auth check failed!');
- console.log(e);
- }
- if(auth.credentials.access_token) {
- try {
- googleCount = await GoogleActions.listGoogleBrews(auth);
- } catch (e) {
- googleCount = undefined;
- console.log('List Google files failed!');
- console.log(e);
- }
- }
- }
-
- const query = { authors: req.account.username, googleId: { $exists: false } };
- const mongoCount = await HomebrewModel.countDocuments(query)
- .catch((err)=>{
- mongoCount = 0;
- console.log(err);
- });
-
- data.accountDetails = {
- username : req.account.username,
- issued : req.account.issued,
- googleId : Boolean(req.account.googleId),
- authCheck : Boolean(req.account.googleId && auth.credentials.access_token),
- mongoCount : mongoCount,
- googleCount : googleCount?.length
- };
- }
-
- req.brew = data;
-
- req.ogMeta = { ...defaultMetaTags,
- title : `Account Page`,
- description : null
- };
-
- return next();
-}));
-
-const nodeEnv = config.get('node_env');
-const isLocalEnvironment = config.get('local_environments').includes(nodeEnv);
-// Local only
-if(isLocalEnvironment){
- // Login
- app.post('/local/login', (req, res)=>{
- const username = req.body.username;
- if(!username) return;
-
- const payload = jwt.encode({ username: username, issued: new Date }, config.get('secret'));
- return res.json(payload);
- });
-}
-
-//Vault Page
-app.get('/vault', asyncHandler(async(req, res, next)=>{
- req.ogMeta = { ...defaultMetaTags,
- title : 'The Vault',
- description : 'Search for Brews'
- };
- return next();
-}));
-
-//Send rendered page
-app.use(asyncHandler(async (req, res, next)=>{
- if (!req.route) return res.redirect('/'); // Catch-all for invalid routes
-
- const page = await renderPage(req, res);
- if(!page) return;
- res.send(page);
-}));
-
-//Render the page
-const renderPage = async (req, res)=>{
- // Create configuration object
- const configuration = {
- local : isLocalEnvironment,
- publicUrl : config.get('publicUrl') ?? '',
- environment : nodeEnv
- };
- const props = {
- version : require('./../package.json').version,
- url : req.customUrl || req.originalUrl,
- brew : req.brew,
- brews : req.brews,
- googleBrews : req.googleBrews,
- account : req.account,
- enable_v3 : config.get('enable_v3'),
- enable_themes : config.get('enable_themes'),
- config : configuration,
- ogMeta : req.ogMeta,
- userThemes : req.userThemes
- };
- const title = req.brew ? req.brew.title : '';
- const page = await templateFn('homebrew', title, props)
- .catch((err)=>{
- console.log(err);
- });
- return page;
-};
-
-//v=====----- Error-Handling Middleware -----=====v//
-//Format Errors as plain objects so all fields will appear in the string sent
-const formatErrors = (key, value)=>{
- if(value instanceof Error) {
- const error = {};
- Object.getOwnPropertyNames(value).forEach(function (key) {
- error[key] = value[key];
- });
- return error;
- }
- return value;
-};
-
-const getPureError = (error)=>{
- return JSON.parse(JSON.stringify(error, formatErrors));
-};
-
-app.use(async (err, req, res, next)=>{
- err.originalUrl = req.originalUrl;
- console.error(err);
-
- if(err.originalUrl?.startsWith('/api/')) {
- // console.log('API error');
- res.status(err.status || err.response?.status || 500).send(err);
- return;
- }
-
- // console.log('non-API error');
- const status = err.status || err.code || 500;
-
- req.ogMeta = { ...defaultMetaTags,
- title : 'Error Page',
- description : 'Something went wrong!'
- };
- req.brew = {
- ...err,
- title : 'Error - Something went wrong!',
- text : err.errors?.map((error)=>{return error.message;}).join('\n\n') || err.message || 'Unknown error!',
- status : status,
- HBErrorCode : err.HBErrorCode ?? '00',
- pureError : getPureError(err)
- };
- req.customUrl= '/error';
-
- const page = await renderPage(req, res);
- if(!page) return;
- res.send(page);
-});
-
-app.use((req, res)=>{
- if(!res.headersSent) {
- console.error('Headers have not been sent, responding with a server error.', req.url);
- res.status(500).send('An error occurred and the server did not send a response. The error has been logged, please note the time this occurred and report this issue.');
- }
-});
-//^=====--------------------------------------=====^//
-
-module.exports = {
- app : app
-};
+/*eslint max-lines: ["warn", {"max": 500, "skipBlankLines": true, "skipComments": true}]*/
+// Set working directory to project root
+process.chdir(`${__dirname}/..`);
+
+const _ = require('lodash');
+const jwt = require('jwt-simple');
+const express = require('express');
+const yaml = require('js-yaml');
+const app = express();
+const config = require('./config.js');
+
+const { homebrewApi, getBrew, getUsersBrewThemes, getCSS } = require('./homebrew.api.js');
+const GoogleActions = require('./googleActions.js');
+const serveCompressedStaticAssets = require('./static-assets.mv.js');
+const sanitizeFilename = require('sanitize-filename');
+const asyncHandler = require('express-async-handler');
+const templateFn = require('./../client/template.js');
+
+const { DEFAULT_BREW } = require('./brewDefaults.js');
+
+const { splitTextStyleAndMetadata } = require('../shared/helpers.js');
+
+
+const sanitizeBrew = (brew, accessType)=>{
+ brew._id = undefined;
+ brew.__v = undefined;
+ if(accessType !== 'edit' && accessType !== 'shareAuthor') {
+ brew.editId = undefined;
+ }
+ return brew;
+};
+
+app.use('/', serveCompressedStaticAssets(`build`));
+app.use(require('./middleware/content-negotiation.js'));
+app.use(require('body-parser').json({ limit: '25mb' }));
+app.use(require('cookie-parser')());
+app.use(require('./forcessl.mw.js'));
+
+//Account Middleware
+app.use((req, res, next)=>{
+ if(req.cookies && req.cookies.nc_session){
+ try {
+ req.account = jwt.decode(req.cookies.nc_session, config.get('secret'));
+ //console.log("Just loaded up JWT from cookie:");
+ //console.log(req.account);
+ } catch (e){}
+ }
+
+ req.config = {
+ google_client_id : config.get('google_client_id'),
+ google_client_secret : config.get('google_client_secret')
+ };
+ return next();
+});
+
+app.use(homebrewApi);
+app.use(require('./admin.api.js'));
+app.use(require('./vault.api.js'));
+
+const HomebrewModel = require('./homebrew.model.js').model;
+const welcomeText = require('fs').readFileSync('client/homebrew/pages/homePage/welcome_msg.md', 'utf8');
+const welcomeTextLegacy = require('fs').readFileSync('client/homebrew/pages/homePage/welcome_msg_legacy.md', 'utf8');
+const migrateText = require('fs').readFileSync('client/homebrew/pages/homePage/migrate.md', 'utf8');
+const changelogText = require('fs').readFileSync('changelog.md', 'utf8');
+const faqText = require('fs').readFileSync('faq.md', 'utf8');
+
+String.prototype.replaceAll = function(s, r){return this.split(s).join(r);};
+
+const defaultMetaTags = {
+ site_name : 'The Homebrewery - Make your Homebrew content look legit!',
+ title : 'The Homebrewery',
+ description : 'A NaturalCrit Tool for creating authentic Homebrews using Markdown.',
+ image : `${config.get('publicUrl')}/thumbnail.png`,
+ type : 'website'
+};
+
+//Robots.txt
+app.get('/robots.txt', (req, res)=>{
+ return res.sendFile(`robots.txt`, { root: process.cwd() });
+});
+
+//Home page
+app.get('/', (req, res, next)=>{
+ req.brew = {
+ text : welcomeText,
+ renderer : 'V3',
+ theme : '5ePHB'
+ },
+
+ req.ogMeta = { ...defaultMetaTags,
+ title : 'Homepage',
+ description : 'Homepage'
+ };
+
+ splitTextStyleAndMetadata(req.brew);
+ return next();
+});
+
+//Home page Legacy
+app.get('/legacy', (req, res, next)=>{
+ req.brew = {
+ text : welcomeTextLegacy,
+ renderer : 'legacy',
+ theme : '5ePHB'
+ },
+
+ req.ogMeta = { ...defaultMetaTags,
+ title : 'Homepage (Legacy)',
+ description : 'Homepage'
+ };
+
+ splitTextStyleAndMetadata(req.brew);
+ return next();
+});
+
+//Legacy/Other Document -> v3 Migration Guide
+app.get('/migrate', (req, res, next)=>{
+ req.brew = {
+ text : migrateText,
+ renderer : 'V3',
+ theme : '5ePHB'
+ },
+
+ req.ogMeta = { ...defaultMetaTags,
+ title : 'v3 Migration Guide',
+ description : 'A brief guide to converting Legacy documents to the v3 renderer.'
+ };
+
+ splitTextStyleAndMetadata(req.brew);
+ return next();
+});
+
+//Changelog page
+app.get('/changelog', async (req, res, next)=>{
+ req.brew = {
+ title : 'Changelog',
+ text : changelogText,
+ renderer : 'V3',
+ theme : '5ePHB'
+ },
+
+ req.ogMeta = { ...defaultMetaTags,
+ title : 'Changelog',
+ description : 'Development changelog.'
+ };
+
+ splitTextStyleAndMetadata(req.brew);
+ return next();
+});
+
+//FAQ page
+app.get('/faq', async (req, res, next)=>{
+ req.brew = {
+ title : 'FAQ',
+ text : faqText,
+ renderer : 'V3',
+ theme : '5ePHB'
+ },
+
+ req.ogMeta = { ...defaultMetaTags,
+ title : 'FAQ',
+ description : 'Frequently Asked Questions'
+ };
+
+ splitTextStyleAndMetadata(req.brew);
+ return next();
+});
+
+//Source page
+app.get('/source/:id', asyncHandler(getBrew('share')), (req, res)=>{
+ const { brew } = req;
+
+ const replaceStrings = { '&': '&', '<': '<', '>': '>' };
+ let text = brew.text;
+ for (const replaceStr in replaceStrings) {
+ text = text.replaceAll(replaceStr, replaceStrings[replaceStr]);
+ }
+ text = `
${text}`;
+ res.status(200).send(text);
+});
+
+//Download brew source page
+app.get('/download/:id', asyncHandler(getBrew('share')), (req, res)=>{
+ const { brew } = req;
+ sanitizeBrew(brew, 'share');
+ const prefix = 'HB - ';
+
+ const encodeRFC3986ValueChars = (str)=>{
+ return (
+ encodeURIComponent(str)
+ .replace(/[!'()*]/g, (char)=>{`%${char.charCodeAt(0).toString(16).toUpperCase()}`;})
+ );
+ };
+
+ let fileName = sanitizeFilename(`${prefix}${brew.title}`).replaceAll(' ', '');
+ if(!fileName || !fileName.length) { fileName = `${prefix}-Untitled-Brew`; };
+ res.set({
+ 'Cache-Control' : 'no-cache',
+ 'Content-Type' : 'text/plain',
+ 'Content-Disposition' : `attachment; filename*=UTF-8''${encodeRFC3986ValueChars(fileName)}.txt`
+ });
+ res.status(200).send(brew.text);
+});
+
+//Serve brew metadata
+app.get('/metadata/:id', asyncHandler(getBrew('share')), (req, res)=>{
+ const { brew } = req;
+ sanitizeBrew(brew, 'share');
+
+ const fields = ['title', 'pageCount', 'description', 'authors', 'lang',
+ 'published', 'views', 'shareId', 'createdAt', 'updatedAt',
+ 'lastViewed', 'thumbnail', 'tags'
+ ];
+
+ const metadata = fields.reduce((acc, field)=>{
+ if(brew[field] !== undefined) acc[field] = brew[field];
+ return acc;
+ }, {});
+ res.status(200).json(metadata);
+});
+
+//Serve brew styling
+app.get('/css/:id', asyncHandler(getBrew('share')), (req, res)=>{getCSS(req, res);});
+
+//User Page
+app.get('/user/:username', async (req, res, next)=>{
+ const ownAccount = req.account && (req.account.username == req.params.username);
+
+ req.ogMeta = { ...defaultMetaTags,
+ title : `${req.params.username}'s Collection`,
+ description : 'View my collection of homebrew on the Homebrewery.'
+ // type : could be 'profile'?
+ };
+
+ const fields = [
+ 'googleId',
+ 'title',
+ 'pageCount',
+ 'description',
+ 'authors',
+ 'lang',
+ 'published',
+ 'views',
+ 'shareId',
+ 'editId',
+ 'createdAt',
+ 'updatedAt',
+ 'lastViewed',
+ 'thumbnail',
+ 'tags'
+ ];
+
+ let brews = await HomebrewModel.getByUser(req.params.username, ownAccount, fields)
+ .catch((err)=>{
+ console.log(err);
+ });
+
+ if(ownAccount && req?.account?.googleId){
+ const auth = await GoogleActions.authCheck(req.account, res);
+ let googleBrews = await GoogleActions.listGoogleBrews(auth)
+ .catch((err)=>{
+ console.error(err);
+ });
+
+ if(googleBrews && googleBrews.length > 0) {
+ for (const brew of brews.filter((brew)=>brew.googleId)) {
+ const match = googleBrews.findIndex((b)=>b.editId === brew.editId);
+ if(match !== -1) {
+ brew.googleId = googleBrews[match].googleId;
+ brew.stubbed = true;
+ brew.pageCount = googleBrews[match].pageCount;
+ brew.renderer = googleBrews[match].renderer;
+ brew.version = googleBrews[match].version;
+ brew.webViewLink = googleBrews[match].webViewLink;
+ googleBrews.splice(match, 1);
+ }
+ }
+
+ googleBrews = googleBrews.map((brew)=>({ ...brew, authors: [req.account.username] }));
+ brews = _.concat(brews, googleBrews);
+ }
+ }
+
+ req.brews = _.map(brews, (brew)=>{
+ // Clean up brew data
+ brew.title = brew.title?.trim();
+ brew.description = brew.description?.trim();
+ return sanitizeBrew(brew, ownAccount ? 'edit' : 'share');
+ });
+
+ return next();
+});
+
+//Edit Page
+app.get('/edit/:id', asyncHandler(getBrew('edit')), asyncHandler(async(req, res, next)=>{
+ req.brew = req.brew.toObject ? req.brew.toObject() : req.brew;
+
+ req.userThemes = await(getUsersBrewThemes(req.account?.username));
+
+ req.ogMeta = { ...defaultMetaTags,
+ title : req.brew.title || 'Untitled Brew',
+ description : req.brew.description || 'No description.',
+ image : req.brew.thumbnail || defaultMetaTags.image,
+ type : 'article'
+ };
+
+ sanitizeBrew(req.brew, 'edit');
+ splitTextStyleAndMetadata(req.brew);
+ res.header('Cache-Control', 'no-cache, no-store'); //reload the latest saved brew when pressing back button, not the cached version before save.
+ return next();
+}));
+
+//New Page from ID
+app.get('/new/:id', asyncHandler(getBrew('share')), asyncHandler(async(req, res, next)=>{
+ sanitizeBrew(req.brew, 'share');
+ splitTextStyleAndMetadata(req.brew);
+ const brew = {
+ shareId : req.brew.shareId,
+ title : `CLONE - ${req.brew.title}`,
+ text : req.brew.text,
+ style : req.brew.style,
+ renderer : req.brew.renderer,
+ theme : req.brew.theme,
+ tags : req.brew.tags,
+ };
+ req.brew = _.defaults(brew, DEFAULT_BREW);
+
+ req.userThemes = await(getUsersBrewThemes(req.account?.username));
+
+ req.ogMeta = { ...defaultMetaTags,
+ title : 'New',
+ description : 'Start crafting your homebrew on the Homebrewery!'
+ };
+
+ return next();
+}));
+
+//New Page
+app.get('/new', asyncHandler(async(req, res, next)=>{
+ req.userThemes = await(getUsersBrewThemes(req.account?.username));
+
+ req.ogMeta = { ...defaultMetaTags,
+ title : 'New',
+ description : 'Start crafting your homebrew on the Homebrewery!'
+ };
+
+ return next();
+}));
+
+//Share Page
+app.get('/share/:id', asyncHandler(getBrew('share')), asyncHandler(async (req, res, next)=>{
+ const { brew } = req;
+ req.ogMeta = { ...defaultMetaTags,
+ title : req.brew.title || 'Untitled Brew',
+ description : req.brew.description || 'No description.',
+ image : req.brew.thumbnail || defaultMetaTags.image,
+ type : 'article'
+ };
+
+ // increase visitor view count, do not include visits by author(s)
+ if(!brew.authors.includes(req.account?.username)){
+ if(req.params.id.length > 12 && !brew._id) {
+ const googleId = brew.googleId;
+ const shareId = brew.shareId;
+ await GoogleActions.increaseView(googleId, shareId, 'share', brew)
+ .catch((err)=>{next(err);});
+ } else {
+ await HomebrewModel.increaseView({ shareId: brew.shareId });
+ }
+ };
+
+ brew.authors.includes(req.account?.username) ? sanitizeBrew(req.brew, 'shareAuthor') : sanitizeBrew(req.brew, 'share');
+ splitTextStyleAndMetadata(req.brew);
+ return next();
+}));
+
+//Account Page
+app.get('/account', asyncHandler(async (req, res, next)=>{
+ const data = {};
+ data.title = 'Account Information Page';
+
+ if(!req.account) {
+ res.set('WWW-Authenticate', 'Bearer realm="Authorization Required"');
+ const error = new Error('No valid account');
+ error.status = 401;
+ error.HBErrorCode = '50';
+ error.page = data.title;
+ return next(error);
+ };
+
+ let auth;
+ let googleCount = [];
+ if(req.account) {
+ if(req.account.googleId) {
+ try {
+ auth = await GoogleActions.authCheck(req.account, res, false);
+ } catch (e) {
+ auth = undefined;
+ console.log('Google auth check failed!');
+ console.log(e);
+ }
+ if(auth.credentials.access_token) {
+ try {
+ googleCount = await GoogleActions.listGoogleBrews(auth);
+ } catch (e) {
+ googleCount = undefined;
+ console.log('List Google files failed!');
+ console.log(e);
+ }
+ }
+ }
+
+ const query = { authors: req.account.username, googleId: { $exists: false } };
+ const mongoCount = await HomebrewModel.countDocuments(query)
+ .catch((err)=>{
+ mongoCount = 0;
+ console.log(err);
+ });
+
+ data.accountDetails = {
+ username : req.account.username,
+ issued : req.account.issued,
+ googleId : Boolean(req.account.googleId),
+ authCheck : Boolean(req.account.googleId && auth.credentials.access_token),
+ mongoCount : mongoCount,
+ googleCount : googleCount?.length
+ };
+ }
+
+ req.brew = data;
+
+ req.ogMeta = { ...defaultMetaTags,
+ title : `Account Page`,
+ description : null
+ };
+
+ return next();
+}));
+
+const nodeEnv = config.get('node_env');
+const isLocalEnvironment = config.get('local_environments').includes(nodeEnv);
+// Local only
+if(isLocalEnvironment){
+ // Login
+ app.post('/local/login', (req, res)=>{
+ const username = req.body.username;
+ if(!username) return;
+
+ const payload = jwt.encode({ username: username, issued: new Date }, config.get('secret'));
+ return res.json(payload);
+ });
+}
+
+//Vault Page
+app.get('/vault', asyncHandler(async(req, res, next)=>{
+ req.ogMeta = { ...defaultMetaTags,
+ title : 'The Vault',
+ description : 'Search for Brews'
+ };
+ return next();
+}));
+
+//Send rendered page
+app.use(asyncHandler(async (req, res, next)=>{
+ if(!req.route) return res.redirect('/'); // Catch-all for invalid routes
+
+ const page = await renderPage(req, res);
+ if(!page) return;
+ res.send(page);
+}));
+
+//Render the page
+const renderPage = async (req, res)=>{
+ // Create configuration object
+ const configuration = {
+ local : isLocalEnvironment,
+ publicUrl : config.get('publicUrl') ?? '',
+ environment : nodeEnv,
+ history : config.get('historyConfig') ?? {}
+ };
+ const props = {
+ version : require('./../package.json').version,
+ url : req.customUrl || req.originalUrl,
+ brew : req.brew,
+ brews : req.brews,
+ googleBrews : req.googleBrews,
+ account : req.account,
+ enable_v3 : config.get('enable_v3'),
+ enable_themes : config.get('enable_themes'),
+ config : configuration,
+ ogMeta : req.ogMeta,
+ userThemes : req.userThemes
+ };
+ const title = req.brew ? req.brew.title : '';
+ const page = await templateFn('homebrew', title, props)
+ .catch((err)=>{
+ console.log(err);
+ });
+ return page;
+};
+
+//v=====----- Error-Handling Middleware -----=====v//
+//Format Errors as plain objects so all fields will appear in the string sent
+const formatErrors = (key, value)=>{
+ if(value instanceof Error) {
+ const error = {};
+ Object.getOwnPropertyNames(value).forEach(function (key) {
+ error[key] = value[key];
+ });
+ return error;
+ }
+ return value;
+};
+
+const getPureError = (error)=>{
+ return JSON.parse(JSON.stringify(error, formatErrors));
+};
+
+app.use(async (err, req, res, next)=>{
+ err.originalUrl = req.originalUrl;
+ console.error(err);
+
+ if(err.originalUrl?.startsWith('/api/')) {
+ // console.log('API error');
+ res.status(err.status || err.response?.status || 500).send(err);
+ return;
+ }
+
+ // console.log('non-API error');
+ const status = err.status || err.code || 500;
+
+ req.ogMeta = { ...defaultMetaTags,
+ title : 'Error Page',
+ description : 'Something went wrong!'
+ };
+ req.brew = {
+ ...err,
+ title : 'Error - Something went wrong!',
+ text : err.errors?.map((error)=>{return error.message;}).join('\n\n') || err.message || 'Unknown error!',
+ status : status,
+ HBErrorCode : err.HBErrorCode ?? '00',
+ pureError : getPureError(err)
+ };
+ req.customUrl= '/error';
+
+ const page = await renderPage(req, res);
+ if(!page) return;
+ res.send(page);
+});
+
+app.use((req, res)=>{
+ if(!res.headersSent) {
+ console.error('Headers have not been sent, responding with a server error.', req.url);
+ res.status(500).send('An error occurred and the server did not send a response. The error has been logged, please note the time this occurred and report this issue.');
+ }
+});
+//^=====--------------------------------------=====^//
+
+module.exports = {
+ app : app
+};
diff --git a/server/homebrew.api.spec.js b/server/homebrew.api.spec.js
index 6e7c36641..dd4641c09 100644
--- a/server/homebrew.api.spec.js
+++ b/server/homebrew.api.spec.js
@@ -934,7 +934,7 @@ brew`);
expect(req.brew).toEqual(testBrew);
expect(req.brew).toHaveProperty('style', '\nI Have a style!\n');
expect(res.status).toHaveBeenCalledWith(200);
- expect(res.send).toHaveBeenCalledWith("\nI Have a style!\n");
+ expect(res.send).toHaveBeenCalledWith('\nI Have a style!\n');
expect(res.set).toHaveBeenCalledWith({
'Cache-Control' : 'no-cache',
'Content-Type' : 'text/css'
diff --git a/shared/naturalcrit/codeEditor/codeEditor.jsx b/shared/naturalcrit/codeEditor/codeEditor.jsx
index 3186e39f1..fb69b6dcf 100644
--- a/shared/naturalcrit/codeEditor/codeEditor.jsx
+++ b/shared/naturalcrit/codeEditor/codeEditor.jsx
@@ -397,6 +397,11 @@ const CodeEditor = createClass({
getCursorPosition : function(){
return this.codeMirror.getCursor();
},
+ getTopVisibleLine : function(){
+ const rect = this.codeMirror.getWrapperElement().getBoundingClientRect();
+ const topVisibleLine = this.codeMirror.lineAtHeight(rect.top, 'window');
+ return topVisibleLine;
+ },
updateSize : function(){
this.codeMirror.refresh();
},
diff --git a/shared/naturalcrit/markdown.js b/shared/naturalcrit/markdown.js
index 205063641..ef789bdd6 100644
--- a/shared/naturalcrit/markdown.js
+++ b/shared/naturalcrit/markdown.js
@@ -105,16 +105,16 @@ renderer.link = function (href, title, text) {
// Expose `src` attribute as `--HB_src` to make the URL accessible via CSS
renderer.image = function (href, title, text) {
href = cleanUrl(href);
- if (href === null)
+ if(href === null)
return text;
let out = `

';
return out;
-}
+};
// Disable default reflink behavior, as it steps on our variables extension
tokenizer.def = function () {
@@ -745,7 +745,7 @@ const tableTerminators = [
`:+\\n`, // hardBreak
` *{[^\n]+}`, // blockInjector
` *{{[^{\n]*\n.*?\n}}` // mustacheDiv
-]
+];
Marked.use(MarkedVariables());
Marked.use({ extensions : [definitionListsMultiLine, definitionListsSingleLine, forcedParagraphBreaks, superSubScripts,
@@ -755,12 +755,12 @@ Marked.use({ renderer: renderer, tokenizer: tokenizer, mangle: false });
Marked.use(MarkedExtendedTables(tableTerminators), MarkedGFMHeadingId({ globalSlugs: true }), MarkedSmartypantsLite(), MarkedEmojis(MarkedEmojiOptions));
function cleanUrl(href) {
- try {
- href = encodeURI(href).replace(/%25/g, '%');
- } catch {
- return null;
- }
- return href;
+ try {
+ href = encodeURI(href).replace(/%25/g, '%');
+ } catch {
+ return null;
+ }
+ return href;
}
const escapeTest = /[&<>"']/;
diff --git a/shared/naturalcrit/splitPane/splitPane.jsx b/shared/naturalcrit/splitPane/splitPane.jsx
index 606b22d40..23ae5d321 100644
--- a/shared/naturalcrit/splitPane/splitPane.jsx
+++ b/shared/naturalcrit/splitPane/splitPane.jsx
@@ -42,6 +42,10 @@ const SplitPane = createClass({
});
}
window.addEventListener('resize', this.handleWindowResize);
+
+ // This lives here instead of in the initial render because you cannot touch localStorage until the componant mounts.
+ const loadLiveScroll = window.localStorage.getItem('liveScroll') === 'true';
+ this.setState({ liveScroll: loadLiveScroll });
},
componentWillUnmount : function() {
@@ -89,6 +93,11 @@ const SplitPane = createClass({
userSetDividerPos : newSize
});
},
+
+ liveScrollToggle : function() {
+ window.localStorage.setItem('liveScroll', String(!this.state.liveScroll));
+ this.setState({ liveScroll: !this.state.liveScroll });
+ },
/*
unFocus : function() {
if(document.selection){
@@ -120,6 +129,11 @@ const SplitPane = createClass({
onClick={()=>this.setState({ moveBrew: !this.state.moveBrew })} >
>;
}
},
@@ -144,9 +158,10 @@ const SplitPane = createClass({
>
{React.cloneElement(this.props.children[0], {
...(this.props.showDividerButtons && {
- moveBrew: this.state.moveBrew,
- moveSource: this.state.moveSource,
- setMoveArrows: this.setMoveArrows,
+ moveBrew : this.state.moveBrew,
+ moveSource : this.state.moveSource,
+ liveScroll : this.state.liveScroll,
+ setMoveArrows : this.setMoveArrows,
}),
})}
diff --git a/shared/naturalcrit/splitPane/splitPane.less b/shared/naturalcrit/splitPane/splitPane.less
index 831b5ce47..e5b3dd7f8 100644
--- a/shared/naturalcrit/splitPane/splitPane.less
+++ b/shared/naturalcrit/splitPane/splitPane.less
@@ -53,6 +53,15 @@
.tooltipRight('Jump to location in Preview');
top : 60px;
}
+ &.lock{
+ .tooltipRight('De-sync Editor and Preview locations.');
+ top : 90px;
+ background: #666;
+ }
+ &.unlock{
+ .tooltipRight('Sync Editor and Preview locations');
+ top : 90px;
+ }
&:hover{
background-color: #666;
}
diff --git a/themes/V3/5ePHB/snippets.js b/themes/V3/5ePHB/snippets.js
index 4daa05c51..c3094abc4 100644
--- a/themes/V3/5ePHB/snippets.js
+++ b/themes/V3/5ePHB/snippets.js
@@ -27,35 +27,154 @@ module.exports = [
experimental : true,
subsnippets : [
{
- name : 'Table of Contents',
+ name : 'Generate Table of Contents',
icon : 'fas fa-book',
gen : TableOfContentsGen,
experimental : true
},
{
- name : 'Include in ToC up to H3',
- icon : 'fas fa-dice-three',
+ name : 'Table of Contents Individual Inclusion',
+ icon : 'fas fa-book',
+ gen : dedent `\n{{tocInclude# CHANGE # to your header level
+ }}\n`,
+ subsnippets : [
+ {
+ name : 'Individual Inclusion H1',
+ icon : 'fas fa-book',
+ gen : dedent `\n{{tocIncludeH1 \n
+ }}\n`,
+ },
+ {
+ name : 'Individual Inclusion H2',
+ icon : 'fas fa-book',
+ gen : dedent `\n{{tocIncludeH2 \n
+ }}\n`,
+ },
+ {
+ name : 'Individual Inclusion H3',
+ icon : 'fas fa-book',
+ gen : dedent `\n{{tocIncludeH3 \n
+ }}\n`,
+ },
+ {
+ name : 'Individual Inclusion H4',
+ icon : 'fas fa-book',
+ gen : dedent `\n{{tocIncludeH4 \n
+ }}\n`,
+ },
+ {
+ name : 'Individual Inclusion H5',
+ icon : 'fas fa-book',
+ gen : dedent `\n{{tocIncludeH5 \n
+ }}\n`,
+ },
+ {
+ name : 'Individual Inclusion H6',
+ icon : 'fas fa-book',
+ gen : dedent `\n{{tocIncludeH6 \n
+ }}\n`,
+ }
+ ]
+ },
+ {
+ name : 'Table of Contents Range Inclusion',
+ icon : 'fas fa-book',
gen : dedent `\n{{tocDepthH3
}}\n`,
+ subsnippets : [
+ {
+ name : 'Include in ToC up to H3',
+ icon : 'fas fa-dice-three',
+ gen : dedent `\n{{tocDepthH3
+ }}\n`,
+ },
+ {
+ name : 'Include in ToC up to H4',
+ icon : 'fas fa-dice-four',
+ gen : dedent `\n{{tocDepthH4
+ }}\n`,
+ },
+ {
+ name : 'Include in ToC up to H5',
+ icon : 'fas fa-dice-five',
+ gen : dedent `\n{{tocDepthH5
+ }}\n`,
+ },
+ {
+ name : 'Include in ToC up to H6',
+ icon : 'fas fa-dice-six',
+ gen : dedent `\n{{tocDepthH6
+ }}\n`,
+ },
+ ]
},
{
- name : 'Include in ToC up to H4',
- icon : 'fas fa-dice-four',
- gen : dedent `\n{{tocDepthH4
+ name : 'Table of Contents Individual Exclusion',
+ icon : 'fas fa-book',
+ gen : dedent `\n{{tocExcludeH1 \n
}}\n`,
+ subsnippets : [
+ {
+ name : 'Individual Exclusion H1',
+ icon : 'fas fa-book',
+ gen : dedent `\n{{tocExcludeH1 \n
+ }}\n`,
+ },
+ {
+ name : 'Individual Exclusion H2',
+ icon : 'fas fa-book',
+ gen : dedent `\n{{tocExcludeH2 \n
+ }}\n`,
+ },
+ {
+ name : 'Individual Exclusion H3',
+ icon : 'fas fa-book',
+ gen : dedent `\n{{tocExcludeH3 \n
+ }}\n`,
+ },
+ {
+ name : 'Individual Exclusion H4',
+ icon : 'fas fa-book',
+ gen : dedent `\n{{tocExcludeH4 \n
+ }}\n`,
+ },
+ {
+ name : 'Individual Exclusion H5',
+ icon : 'fas fa-book',
+ gen : dedent `\n{{tocExcludeH5 \n
+ }}\n`,
+ },
+ {
+ name : 'Individual Exclusion H6',
+ icon : 'fas fa-book',
+ gen : dedent `\n{{tocExcludeH6 \n
+ }}\n`,
+ },
+ ]
},
+
{
- name : 'Include in ToC up to H5',
- icon : 'fas fa-dice-five',
- gen : dedent `\n{{tocDepthH5
- }}\n`,
- },
- {
- name : 'Include in ToC up to H6',
- icon : 'fas fa-dice-six',
- gen : dedent `\n{{tocDepthH6
- }}\n`,
+ name : 'Table of Contents Toggles',
+ icon : 'fas fa-book',
+ gen : `{{tocGlobalH4}}\n\n`,
+ subsnippets : [
+ {
+ name : 'Enable H1-H4 all pages',
+ icon : 'fas fa-dice-four',
+ gen : `{{tocGlobalH4}}\n\n`,
+ },
+ {
+ name : 'Enable H1-H5 all pages',
+ icon : 'fas fa-dice-five',
+ gen : `{{tocGlobalH5}}\n\n`,
+ },
+ {
+ name : 'Enable H1-H6 all pages',
+ icon : 'fas fa-dice-six',
+ gen : `{{tocGlobalH6}}\n\n`,
+ },
+ ]
}
]
},
@@ -94,7 +213,7 @@ module.exports = [
background-image: linear-gradient(-45deg, #322814, #998250, #322814);
line-height: 1em;
}\n\n`
- }
+ },
]
},
diff --git a/themes/V3/5ePHB/snippets/tableOfContents.gen.js b/themes/V3/5ePHB/snippets/tableOfContents.gen.js
index 49a8c168a..44c400762 100644
--- a/themes/V3/5ePHB/snippets/tableOfContents.gen.js
+++ b/themes/V3/5ePHB/snippets/tableOfContents.gen.js
@@ -4,9 +4,9 @@ const dedent = require('dedent-tabs').default;
const mapPages = (pages)=>{
let actualPage = 0;
let mappedPage = 0; // Number displayed in footer
- let pageMap = [];
+ const pageMap = [];
- pages.forEach(page => {
+ pages.forEach((page)=>{
actualPage++;
const doSkip = page.querySelector('.skipCounting');
const doReset = page.querySelector('.resetCounting');
@@ -24,13 +24,13 @@ const mapPages = (pages)=>{
return pageMap;
};
-const getMarkdown = (headings, pageMap) => {
+const getMarkdown = (headings, pageMap)=>{
const levelPad = ['- ###', ' - ####', ' -', ' -', ' -', ' -'];
-
- let allMarkdown = [];
- let depthChain = [0];
- headings.forEach(heading => {
+ const allMarkdown = [];
+ const depthChain = [0];
+
+ headings.forEach((heading)=>{
const page = parseInt(heading.closest('.page').id?.replace(/^p/, ''));
const mappedPage = pageMap[page].mappedPage;
const showPage = pageMap[page].showPage;
@@ -42,14 +42,14 @@ const getMarkdown = (headings, pageMap) => {
return;
//If different header depth than last, remove indents until nearest higher-level header, then indent once
- if (depth !== depthChain[depthChain.length -1]) {
+ if(depth !== depthChain[depthChain.length -1]) {
while (depth <= depthChain[depthChain.length - 1]) {
depthChain.pop();
}
depthChain.push(depth);
}
- let markdown = `${levelPad[depthChain.length - 2]} [{{ ${title}}}{{ ${mappedPage}}}](#p${page})`;
+ const markdown = `${levelPad[depthChain.length - 2]} [{{ ${title}}}{{ ${mappedPage}}}](#p${page})`;
allMarkdown.push(markdown);
});
return allMarkdown.join('\n');
diff --git a/themes/V3/5ePHB/style.less b/themes/V3/5ePHB/style.less
index ddffbec2f..5a2b5cf3f 100644
--- a/themes/V3/5ePHB/style.less
+++ b/themes/V3/5ePHB/style.less
@@ -11,6 +11,7 @@
--HB_Color_CaptionText : #766649; // Brown
--HB_Color_WatercolorStain : #BBAD82; // Light brown
--HB_Color_Footnotes : #C9AD6A; // Gold
+ --TOC : 'include';
}
.useSansSerif() {
@@ -797,7 +798,7 @@
// *****************************/
// Default Exclusions
-// Anything not exlcuded is included, default Headers are H1, H2, and H3.
+// Anything not excluded is included, default Headers are H1, H2, and H3.
h4,
h5,
h6,
@@ -808,12 +809,23 @@ h6,
.noToC,
.toc { --TOC: exclude; }
-.tocDepthH2 :is(h1, h2) {--TOC: include; }
-.tocDepthH3 :is(h1, h2, h3) {--TOC: include; }
-.tocDepthH4 :is(h1, h2, h3, h4) {--TOC: include; }
-.tocDepthH5 :is(h1, h2, h3, h4, h5) {--TOC: include; }
-.tocDepthH6 :is(h1, h2, h3, h4, h5, h6) {--TOC: include; }
+// Brew level default inclusion changes.
+// These add Headers 'back' to inclusion.
+.pages:has(.tocGlobalH4) {
+ h4 {--TOC: include; }
+}
+
+.pages:has(.tocGlobalH5) {
+ h4, h5 {--TOC: include; }
+}
+
+.pages:has(.tocGlobalH6) {
+ h4, h5, h6 {--TOC: include; }
+}
+
+// Block level inclusion changes
+// These include either a single (include) or a range (depth)
.tocIncludeH1 h1 {--TOC: include; }
.tocIncludeH2 h2 {--TOC: include; }
.tocIncludeH3 h3 {--TOC: include; }
@@ -821,6 +833,21 @@ h6,
.tocIncludeH5 h5 {--TOC: include; }
.tocIncludeH6 h6 {--TOC: include; }
+.tocDepthH2 :is(h1, h2) {--TOC: include; }
+.tocDepthH3 :is(h1, h2, h3) {--TOC: include; }
+.tocDepthH4 :is(h1, h2, h3, h4) {--TOC: include; }
+.tocDepthH5 :is(h1, h2, h3, h4, h5) {--TOC: include; }
+.tocDepthH6 :is(h1, h2, h3, h4, h5, h6) {--TOC: include; }
+
+// Block level exclusion changes
+// These exclude a single block level
+.tocExcludeH1 h1 {--TOC: exclude; }
+.tocExcludeH2 h2 {--TOC: exclude; }
+.tocExcludeH3 h3 {--TOC: exclude; }
+.tocExcludeH4 h4 {--TOC: exclude; }
+.tocExcludeH5 h5 {--TOC: exclude; }
+.tocExcludeH6 h6 {--TOC: exclude; }
+
.page:has(.partCover) {
--TOC: exclude;
& h1 {