0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2026-01-22 11:47:52 +00:00

Merge branch 'master' into addLockRoutes-#3326

This commit is contained in:
G.Ambatte
2024-08-14 08:39:55 +12:00
committed by GitHub
38 changed files with 3366 additions and 2783 deletions

View File

@@ -3,7 +3,7 @@
"stylelint-config-recess-order",
"stylelint-config-recommended"],
"plugins": [
"stylelint-stylistic",
"@stylistic/stylelint-plugin",
"./stylelint_plugins/declaration-colon-align.js",
"./stylelint_plugins/declaration-colon-min-space-before",
"./stylelint_plugins/declaration-block-multi-line-min-declarations"
@@ -16,32 +16,32 @@
"font-family-no-missing-generic-family-keyword" : null,
"font-weight-notation" : "named-where-possible",
"font-family-name-quotes" : "always-unless-keyword",
"stylistic/indentation" : "tab",
"@stylistic/indentation" : "tab",
"no-duplicate-selectors" : true,
"stylistic/color-hex-case" : "upper",
"@stylistic/color-hex-case" : "upper",
"color-hex-length" : "long",
"stylistic/selector-combinator-space-after" : "always",
"stylistic/selector-combinator-space-before" : "always",
"stylistic/selector-attribute-operator-space-before" : "never",
"stylistic/selector-attribute-operator-space-after" : "never",
"stylistic/selector-attribute-brackets-space-inside" : "never",
"@stylistic/selector-combinator-space-after" : "always",
"@stylistic/selector-combinator-space-before" : "always",
"@stylistic/selector-attribute-operator-space-before" : "never",
"@stylistic/selector-attribute-operator-space-after" : "never",
"@stylistic/selector-attribute-brackets-space-inside" : "never",
"selector-attribute-quotes" : "always",
"selector-pseudo-element-colon-notation" : "double",
"stylistic/selector-pseudo-class-parentheses-space-inside" : "never",
"stylistic/block-opening-brace-space-before" : "always",
"@stylistic/selector-pseudo-class-parentheses-space-inside" : "never",
"@stylistic/block-opening-brace-space-before" : "always",
"naturalcrit/declaration-colon-min-space-before" : 1,
"stylistic/declaration-block-trailing-semicolon" : "always",
"stylistic/declaration-colon-space-after" : "always",
"stylistic/number-leading-zero" : "always",
"@stylistic/declaration-block-trailing-semicolon" : "always",
"@stylistic/declaration-colon-space-after" : "always",
"@stylistic/number-leading-zero" : "always",
"function-url-quotes" : ["always", { "except": ["empty"] }],
"function-url-scheme-disallowed-list" : ["data","http"],
"comment-whitespace-inside" : "always",
"stylistic/string-quotes" : "single",
"stylistic/media-feature-range-operator-space-before" : "always",
"stylistic/media-feature-range-operator-space-after" : "always",
"stylistic/media-feature-parentheses-space-inside" : "never",
"stylistic/media-feature-colon-space-before" : "always",
"stylistic/media-feature-colon-space-after" : "always",
"@stylistic/string-quotes" : "single",
"@stylistic/media-feature-range-operator-space-before" : "always",
"@stylistic/media-feature-range-operator-space-after" : "always",
"@stylistic/media-feature-parentheses-space-inside" : "never",
"@stylistic/media-feature-colon-space-before" : "always",
"@stylistic/media-feature-colon-space-after" : "always",
"naturalcrit/declaration-colon-align" : true,
"naturalcrit/declaration-block-multi-line-min-declarations": 1
}

View File

@@ -1,4 +1,4 @@
FROM node:18-alpine
FROM node:20-alpine
RUN apk --no-cache add git
ENV NODE_ENV=docker

View File

@@ -84,6 +84,34 @@ pre {
## changelog
For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery).
### Monday 7/29/2024 - v3.14.0
{{taskList
##### abquintic, calculuschild
* [x] Alternative Brew Themes, including importing other brews as a base theme.
- In the :fas_circle_info: **Properties** menu, find the new {{openSans **THEME**}} dropdown. It lists Brew Themes, including a new **Blank** theme as a simpler basis for custom styling.
- Brews tagged with `meta:theme` will appear in the Brew Themes list. Selecting one loads its :fas_paintbrush: **Style** tab contents as the CSS basis for the current brew, allowing one brew to style multiple documents.
- Brews with `meta:theme` can also select their own Theme, i.e. layering Themes on top of each other.
- The next goal is to make **Published** Themes shareable between users.
Fixes issues [#1899](https://github.com/naturalcrit/homebrewery/issues/1899), [#3085](https://github.com/naturalcrit/homebrewery/issues/3085)
##### G-Ambatte
* [x] Fix Drop-cap font becoming corrupted when Bold
Fixes issues [#3551](https://github.com/naturalcrit/homebrewery/issues/3551)
* [x] Fixes to UI styling
Fixes issues [#3568](https://github.com/naturalcrit/homebrewery/issues/3568)
}}
### Saturday 6/7/2024 - v3.13.1
{{taskList
@@ -131,8 +159,6 @@ Fixes issue [#3298](https://github.com/naturalcrit/homebrewery/issues/3298)
Fixes issue [#3397](https://github.com/naturalcrit/homebrewery/issues/3397)
}}
\column
### Monday 18/3/2024 - v3.12.0
{{taskList

View File

@@ -18,8 +18,6 @@ const { printCurrentBrew } = require('../../../shared/helpers.js');
const DOMPurify = require('dompurify');
const purifyConfig = { FORCE_BODY: true, SANITIZE_DOM: false };
const Themes = require('themes/themes.json');
const PAGE_HEIGHT = 1056;
const INITIAL_CONTENT = dedent`
@@ -57,6 +55,7 @@ const BrewRenderer = (props)=>{
lang : '',
errors : [],
currentEditorPage : 0,
themeBundle : {},
...props
};
@@ -125,10 +124,9 @@ const BrewRenderer = (props)=>{
};
const renderStyle = ()=>{
if(!props.style) return;
const cleanStyle = props.style; //DOMPurify.sanitize(props.style, purifyConfig);
//return <div style={{ display: 'none' }} dangerouslySetInnerHTML={{ __html: `<style>@layer styleTab {\n${sanitizeScriptTags(props.style)}\n} </style>` }} />;
return <div style={{ display: 'none' }} dangerouslySetInnerHTML={{ __html: `<style> ${cleanStyle} </style>` }} />;
const themeStyles = props.themeBundle?.joinedStyles ?? '<style>@import url("/themes/V3/Blank/style.css");</style>';
return <div style={{ display: 'none' }} dangerouslySetInnerHTML={{ __html: `${themeStyles} \n\n <style> ${cleanStyle} </style>` }} />;
};
const renderPage = (pageText, index)=>{
@@ -188,10 +186,6 @@ const BrewRenderer = (props)=>{
document.dispatchEvent(new MouseEvent('click'));
};
const rendererPath = props.renderer == 'V3' ? 'V3' : 'Legacy';
const themePath = props.theme ?? '5ePHB';
const baseThemePath = Themes[rendererPath][themePath].baseTheme;
return (
<>
{/*render dummy page while iFrame is mounting.*/}
@@ -220,13 +214,6 @@ const BrewRenderer = (props)=>{
onKeyDown={handleControlKeys}
tabIndex={-1}
style={{ height: state.height }}>
<link href={`/themes/${rendererPath}/Blank/style.css`} type='text/css' rel='stylesheet'/>
{baseThemePath &&
<link href={`/themes/${rendererPath}/${baseThemePath}/style.css`} type='text/css' rel='stylesheet'/>
}
<link href={`/themes/${rendererPath}/${themePath}/style.css`} type='text/css' rel='stylesheet'/>
{/* Apply CSS from Style tab and render pages from Markdown tab */}
{state.isMounted
&&

View File

@@ -367,7 +367,7 @@ const Editor = createClass({
view={this.state.view}
value={this.props.brew.style ?? DEFAULT_STYLE_TEXT}
onChange={this.props.onStyleChange}
enableFolding={false}
enableFolding={true}
editorTheme={this.state.editorTheme}
rerenderParent={this.rerenderParent} />
</>;
@@ -381,7 +381,8 @@ const Editor = createClass({
<MetadataEditor
metadata={this.props.brew}
onChange={this.props.onMetaChange}
reportError={this.props.reportError}/>
reportError={this.props.reportError}
userThemes={this.props.userThemes}/>
</>;
}
},
@@ -424,6 +425,7 @@ const Editor = createClass({
historySize={this.historySize()}
currentEditorTheme={this.state.editorTheme}
updateEditorTheme={this.updateEditorTheme}
snippetBundle={this.props.snippetBundle}
cursorPos={this.codeEditor.current?.getCursorPosition() || {}} />
{this.renderEditor()}

View File

@@ -8,6 +8,7 @@ const Nav = require('naturalcrit/nav/nav.jsx');
const Combobox = require('client/components/combobox.jsx');
const StringArrayEditor = require('../stringArrayEditor/stringArrayEditor.jsx');
const Themes = require('themes/themes.json');
const validations = require('./validations.js');
@@ -27,6 +28,7 @@ const MetadataEditor = createClass({
return {
metadata : {
editId : null,
shareId : null,
title : '',
description : '',
thumbnail : '',
@@ -98,7 +100,7 @@ const MetadataEditor = createClass({
if(renderer == 'legacy')
this.props.metadata.theme = '5ePHB';
}
this.props.onChange(this.props.metadata);
this.props.onChange(this.props.metadata, 'renderer');
},
handlePublish : function(val){
this.props.onChange({
@@ -110,7 +112,7 @@ const MetadataEditor = createClass({
handleTheme : function(theme){
this.props.metadata.renderer = theme.renderer;
this.props.metadata.theme = theme.path;
this.props.onChange(this.props.metadata);
this.props.onChange(this.props.metadata, 'theme');
},
handleLanguage : function(languageCode){
@@ -191,37 +193,42 @@ const MetadataEditor = createClass({
renderThemeDropdown : function(){
if(!global.enable_themes) return;
const mergedThemes = _.merge(Themes, this.props.userThemes);
const listThemes = (renderer)=>{
return _.map(_.values(Themes[renderer]), (theme)=>{
return <div className='item' key={''} onClick={()=>this.handleTheme(theme)} title={''}>
{`${theme.renderer} : ${theme.name}`}
<img src={`/themes/${theme.renderer}/${theme.path}/dropdownTexture.png`}/>
return _.map(_.values(mergedThemes[renderer]), (theme)=>{
if(theme.path == this.props.metadata.shareId) return;
const preview = theme.thumbnail || `/themes/${theme.renderer}/${theme.path}/dropdownPreview.png`;
const texture = theme.thumbnail || `/themes/${theme.renderer}/${theme.path}/dropdownTexture.png`;
return <div className='item' key={`${renderer}_${theme.name}`} onClick={()=>this.handleTheme(theme)} title={''}>
{theme.author ?? renderer} : {theme.name}
<div className='texture-container'>
<img src={texture}/>
</div>
<div className='preview'>
<h6>{`${theme.name}`} preview</h6>
<img src={`/themes/${theme.renderer}/${theme.path}/dropdownPreview.png`}/>
<h6>{theme.name} preview</h6>
<img src={preview}/>
</div>
</div>;
});
};
const currentTheme = Themes[`${_.upperFirst(this.props.metadata.renderer)}`][this.props.metadata.theme];
const currentRenderer = this.props.metadata.renderer;
const currentTheme = mergedThemes[`${_.upperFirst(this.props.metadata.renderer)}`][this.props.metadata.theme]
?? { name: `!!! THEME MISSING !!! ID=${this.props.metadata.theme}` };
let dropdown;
if(this.props.metadata.renderer == 'legacy') {
if(currentRenderer == 'legacy') {
dropdown =
<Nav.dropdown className='disabled value' trigger='disabled'>
<div>
{`Themes are not supported in the Legacy Renderer`} <i className='fas fa-caret-down'></i>
</div>
<div> {`Themes are not supported in the Legacy Renderer`} <i className='fas fa-caret-down'></i> </div>
</Nav.dropdown>;
} else {
dropdown =
<Nav.dropdown className='value' trigger='click'>
<div>
{`${_.upperFirst(currentTheme.renderer)} : ${currentTheme.name}`} <i className='fas fa-caret-down'></i>
</div>
{/*listThemes('Legacy')*/}
{listThemes('V3')}
<div> {currentTheme.author ?? _.upperFirst(currentRenderer)} : {currentTheme.name} <i className='fas fa-caret-down'></i> </div>
{listThemes(currentRenderer)}
</Nav.dropdown>;
}

View File

@@ -5,46 +5,42 @@
z-index : 5;
box-sizing : border-box;
width : 100%;
padding : 25px;
background-color : #999;
height : calc(100vh - 54px); // 54px is the height of the navbar + snippet bar. probably a better way to dynamic get this.
padding : 25px;
overflow-y : auto;
background-color : #999999;
.sectionHead {
font-weight: 1000;
margin : 20px 0;
font-weight : 1000;
&:first-of-type {
margin-top: 0;
}
&:first-of-type { margin-top : 0; }
}
& > div {
margin-bottom: 10px;
}
& > div { margin-bottom : 10px; }
.field-group {
display : flex;
width: 100%;
flex-wrap : wrap;
gap : 10px;
width : 100%;
}
.field-column {
display : flex;
flex-direction: column;
flex : 5 0 200px;
flex-direction : column;
gap : 10px;
}
.field {
position : relative;
display : flex;
flex-wrap : wrap;
width : 100%;
min-width : 200px;
position : relative;
& > label {
width : 80px;
font-size : 11px;
@@ -55,100 +51,90 @@
& > .value {
flex : 1 1 auto;
width : 50px;
&:invalid {
background : #ffb9b9;
}
&:invalid { background : #FFB9B9; }
}
input[type='text'], textarea {
border : 1px solid gray;
&:focus {
outline: 1px solid #444;
}
&:focus { outline : 1px solid #444444; }
}
&.thumbnail {
height : 1.4em;
label{
line-height: 2.0em;
}
label { line-height : 2.0em; }
.value {
overflow : hidden;
text-overflow : ellipsis;
}
button {
border: 1px solid #999;
color: white;
padding : 0px 5px;
color : white;
background-color : black;
&:hover{
background-color: #777;
}
border : 1px solid #999999;
&:hover { background-color : #777777; }
}
}
&.description {
flex : 1;
textarea.value {
resize : none;
height : auto;
font-family : 'Open Sans', sans-serif;
font-size : 0.8em;
resize : none;
}
}
&.language .language-dropdown {
max-width : 150px;
z-index : 200;
max-width : 150px;
}
small {
display : inline-block;
font-size : 0.6em;
font-style : italic;
line-height : 1.4em;
display : inline-block;
}
}
.thumbnail-preview {
position : relative;
flex : 1 1;
justify-self : center;
width : 80px;
height : min-content;
flex: 1 1;
max-height : 115px;
aspect-ratio : 1 / 1;
object-fit : contain;
background-color: #AAA;
background-color : #AAAAAA;
}
.systems.field .value {
label {
vertical-align : middle;
margin-right : 15px;
cursor : pointer;
font-size : 0.7em;
font-weight : 800;
user-select : none;
white-space : nowrap;
display : inline-flex;
align-items : center;
}
a {
margin-right : 15px;
font-size : 0.7em;
font-weight : 800;
display : inline-flex;
}
input{
white-space : nowrap;
vertical-align : middle;
cursor : pointer;
user-select : none;
}
a {
display : inline-flex;
font-size : 0.7em;
font-weight : 800;
}
input {
margin : 3px;
vertical-align : middle;
cursor : pointer;
}
}
.publish.field .value {
position : relative;
margin-bottom : 15px;
button{
width:100%;
}
button { width : 100%; }
button.publish {
.button(@blueLight);
}
@@ -170,74 +156,85 @@
.themes.field {
font-size : 13.33px;
.navDropdownContainer {
background-color : white;
position : relative;
z-index : 100;
background-color : white;
&.disabled {
font-style : italic;
font-style : italic;
background-color : darkgray;
color : dimgray;
background-color : darkgray;
}
& > div:first-child {
border : 2px solid rgb(118,118,118);
padding : 6px 3px;
background-color : inherit;
i {
float : right;
}
border : 2px solid rgb(118,118,118);
i { float : right; }
&:hover {
background-color : @blue;
color : white;
background-color : @blue;
}
}
.navDropdown .item > p {
width : 45%;
height : 1.1em;
overflow : hidden;
text-overflow : ellipsis;
white-space : nowrap;
}
.navDropdown {
box-shadow : 0px 5px 10px rgba(0, 0, 0, 0.3);
position : absolute;
width : 100%;
box-shadow : 0px 5px 10px rgba(0, 0, 0, 0.3);
.item {
padding : 3px 3px;
border-top : 1px solid rgb(118, 118, 118);
position : relative;
padding : 3px 3px;
overflow : visible;
background-color : white;
border-top : 1px solid rgb(118, 118, 118);
.preview {
display : flex;
flex-direction: column;
background : #ccc;
border-radius : 5px;
box-shadow : 0 0 5px black;
width : 200px;
color :black;
position : absolute;
top : 0;
right : 0;
z-index : 1;
display : flex;
flex-direction : column;
width : 200px;
overflow : hidden;
color : black;
background : #CCCCCC;
border-radius : 5px;
box-shadow : 0 0 5px black;
opacity : 0;
transition : opacity 250ms ease;
z-index : 1;
overflow :hidden;
h6 {
font-weight : 900;
padding-block : 0.5em;
padding-inline : 1em;
padding-block :.5em;
font-weight : 900;
border-bottom : 2px solid hsl(0,0%,40%);
}
}
&:hover {
background-color : @blue;
color : white;
background-color : @blue;
}
&:hover > .preview {
opacity: 1;
}
>img {
mask-image : linear-gradient(90deg, transparent, black 20%);
-webkit-mask-image : linear-gradient(90deg, transparent, black 20%);
&:hover > .preview { opacity : 1; }
.texture-container {
position : absolute;
right : 0;
top : 0px;
width : 50%;
top : 0;
left : 0;
width : 100%;
height : 100%;
min-height : 100%;
overflow : hidden;
> img {
position : absolute;
top : 0px;
right : 0;
width : 50%;
min-height : 100%;
-webkit-mask-image : linear-gradient(90deg, transparent, black 20%);
mask-image : linear-gradient(90deg, transparent, black 20%);
}
}
}
}
@@ -248,21 +245,19 @@
flex : 1 0;
flex-wrap : wrap;
> * {
flex: 0 0 auto;
}
> * { flex : 0 0 auto; }
#groupedIcon {
#backgroundColors;
display: inline-block;
height: ~"calc(100% + 0.6em)";
position : relative;
top : -0.3em;
right : -0.3em;
cursor: pointer;
display : inline-block;
min-width : 20px;
text-align: center;
height : ~'calc(100% + 0.6em)';
color : white;
text-align : center;
cursor : pointer;
i {
position : relative;
@@ -270,37 +265,28 @@
transform : translateY(-50%);
}
&:not(:last-child) {
border-right: 1px solid black;
}
&:not(:last-child) { border-right : 1px solid black; }
&:last-child {
border-radius: 0 0.5em 0.5em 0;
}
&:last-child { border-radius : 0 0.5em 0.5em 0; }
}
.badge {
background-color: #dddddd;
border-radius: .5em;
font-size: .9em;
padding : 0.3em;
margin : 2px;
padding: .3em;
font-size : 0.9em;
background-color : #DDDDDD;
border-radius : 0.5em;
.icon {
#groupedIcon
}
#groupedIcon; }
}
.input-group {
height: ~"calc(.9em + 4px + .6em)";
height : ~'calc(.9em + 4px + .6em)';
input {
border-radius: .5em 0 0 .5em;
}
input { border-radius : 0.5em 0 0 0.5em; }
input:last-child {
border-radius: .5em;
}
input:last-child { border-radius : 0.5em; }
.value {
width : 7.5vw;
@@ -308,20 +294,16 @@
height : 100%;
}
.invalid:focus {
background-color: pink;
}
.invalid:focus { background-color : pink; }
.icon {
#groupedIcon;
height: 97%;
font-size: .8em;
top : -0.54em;
right : 1px;
top: -.54em;
height : 97%;
font-size : 0.8em;
i {
font-size: 1.125em;
}
i { font-size : 1.125em; }
}
}
}

View File

@@ -6,9 +6,6 @@ const _ = require('lodash');
const cx = require('classnames');
//Import all themes
const Themes = require('themes/themes.json');
const ThemeSnippets = {};
ThemeSnippets['Legacy_5ePHB'] = require('themes/Legacy/5ePHB/snippets.js');
ThemeSnippets['V3_5ePHB'] = require('themes/V3/5ePHB/snippets.js');
@@ -40,7 +37,8 @@ const Snippetbar = createClass({
foldCode : ()=>{},
unfoldCode : ()=>{},
updateEditorTheme : ()=>{},
cursorPos : {}
cursorPos : {},
snippetBundle : []
};
},
@@ -53,21 +51,15 @@ const Snippetbar = createClass({
},
componentDidMount : async function() {
const rendererPath = this.props.renderer == 'V3' ? 'V3' : 'Legacy';
const themePath = this.props.theme ?? '5ePHB';
let snippets = _.cloneDeep(ThemeSnippets[`${rendererPath}_${themePath}`]);
snippets = this.compileSnippets(rendererPath, themePath, snippets);
const snippets = this.compileSnippets();
this.setState({
snippets : snippets
});
},
componentDidUpdate : async function(prevProps) {
if(prevProps.renderer != this.props.renderer || prevProps.theme != this.props.theme) {
const rendererPath = this.props.renderer == 'V3' ? 'V3' : 'Legacy';
const themePath = this.props.theme ?? '5ePHB';
let snippets = _.cloneDeep(ThemeSnippets[`${rendererPath}_${themePath}`]);
snippets = this.compileSnippets(rendererPath, themePath, snippets);
if(prevProps.renderer != this.props.renderer || prevProps.theme != this.props.theme || prevProps.snippetBundle != this.props.snippetBundle) {
const snippets = this.compileSnippets();
this.setState({
snippets : snippets
});
@@ -75,26 +67,26 @@ const Snippetbar = createClass({
},
mergeCustomizer : function(valueA, valueB, key) {
mergeCustomizer : function(oldValue, newValue, key) {
if(key == 'snippets') {
const result = _.reverse(_.unionBy(_.reverse(valueB), _.reverse(valueA), 'name')); // Join snippets together, with preference for the current theme over the base theme
const result = _.reverse(_.unionBy(_.reverse(newValue), _.reverse(oldValue), 'name')); // Join snippets together, with preference for the child theme over the parent theme
return _.filter(result, 'gen'); //Only keep snippets with a 'gen' property.
}
},
compileSnippets : function(rendererPath, themePath, snippets) {
let compiledSnippets = snippets;
const baseSnippetsPath = Themes[rendererPath][themePath].baseSnippets;
compileSnippets : function() {
let compiledSnippets = [];
const objB = _.keyBy(compiledSnippets, 'groupName');
let oldSnippets = _.keyBy(compiledSnippets, 'groupName');
if(baseSnippetsPath) {
const objA = _.keyBy(_.cloneDeep(ThemeSnippets[`${rendererPath}_${baseSnippetsPath}`]), 'groupName');
compiledSnippets = _.values(_.mergeWith(objA, objB, this.mergeCustomizer));
compiledSnippets = this.compileSnippets(rendererPath, baseSnippetsPath, _.cloneDeep(compiledSnippets));
} else {
const objA = _.keyBy(_.cloneDeep(ThemeSnippets[`${rendererPath}_Blank`]), 'groupName');
compiledSnippets = _.values(_.mergeWith(objA, objB, this.mergeCustomizer));
for (let snippets of this.props.snippetBundle) {
if(typeof(snippets) == 'string') // load staticThemes as needed; they were sent as just a file name
snippets = ThemeSnippets[snippets];
const newSnippets = _.keyBy(_.cloneDeep(snippets), 'groupName');
compiledSnippets = _.values(_.mergeWith(oldSnippets, newSnippets, this.mergeCustomizer));
oldSnippets = _.keyBy(compiledSnippets, 'groupName');
}
return compiledSnippets;
},

View File

@@ -66,13 +66,14 @@ const Homebrew = createClass({
<Router location={this.props.url}>
<div className='homebrew'>
<Routes>
<Route path='/edit/:id' element={<WithRoute el={EditPage} brew={this.props.brew} />} />
<Route path='/edit/:id' element={<WithRoute el={EditPage} brew={this.props.brew} userThemes={this.props.userThemes}/>} />
<Route path='/share/:id' element={<WithRoute el={SharePage} brew={this.props.brew} />} />
<Route path='/new/:id' element={<WithRoute el={NewPage} brew={this.props.brew} />} />
<Route path='/new' element={<WithRoute el={NewPage}/>} />
<Route path='/new/:id' element={<WithRoute el={NewPage} brew={this.props.brew} userThemes={this.props.userThemes}/>} />
<Route path='/new' element={<WithRoute el={NewPage} userThemes={this.props.userThemes}/> } />
<Route path='/user/:username' element={<WithRoute el={UserPage} brews={this.props.brews} />} />
<Route path='/changelog' element={<WithRoute el={SharePage} brew={this.props.brew} />} />
<Route path='/faq' element={<WithRoute el={SharePage} brew={this.props.brew} />} />
<Route path='/changelog' element={<WithRoute el={SharePage} brew={this.props.brew} disableMeta={true} />} />
<Route path='/faq' element={<WithRoute el={SharePage} brew={this.props.brew} disableMeta={true} />} />
<Route path='/migrate' element={<WithRoute el={SharePage} brew={this.props.brew} disableMeta={true} />} />
<Route path='/account' element={<WithRoute el={AccountPage} brew={this.props.brew} accountDetails={this.props.brew.accountDetails} />} />
<Route path='/legacy' element={<WithRoute el={HomePage} brew={this.props.brew} />} />
<Route path='/error' element={<WithRoute el={ErrorPage} brew={this.props.brew} />} />

View File

@@ -104,6 +104,18 @@ const ErrorNavItem = createClass({
</Nav.item>;
}
if(HBErrorCode === '09') {
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops!
<div className='errorContainer' onClick={clearError}>
Looks like there was a problem retreiving
the theme, or a theme that it inherits,
for this brew. Verify that brew <a className='lowercase' target='_blank' rel='noopener noreferrer' href={`/share/${response.body.brewId}`}>
{response.body.brewId}</a> still exists!
</div>
</Nav.item>;
}
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops!
<div className='errorContainer'>

View File

@@ -21,6 +21,9 @@
font-size : 10px;
font-weight : 800;
text-transform : uppercase;
.lowercase {
text-transform : none;
}
a{
color : @teal;
}

View File

@@ -25,7 +25,7 @@ const LockNotification = require('./lockNotification/lockNotification.jsx');
const Markdown = require('naturalcrit/markdown.js');
const { DEFAULT_BREW_LOAD } = require('../../../../server/brewDefaults.js');
const { printCurrentBrew } = require('../../../../shared/helpers.js');
const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpers.js');
const googleDriveIcon = require('../../googleDrive.svg');
@@ -55,7 +55,8 @@ const EditPage = createClass({
autoSaveWarning : false,
unsavedTime : new Date(),
currentEditorPage : 0,
displayLockMessage : this.props.brew.lock || false
displayLockMessage : this.props.brew.lock || false,
themeBundle : {}
};
},
@@ -87,6 +88,8 @@ const EditPage = createClass({
htmlErrors : Markdown.validate(prevState.brew.text)
}));
fetchThemeBundle(this, this.props.brew.renderer, this.props.brew.theme);
document.addEventListener('keydown', this.handleControlKeys);
},
componentWillUnmount : function() {
@@ -130,7 +133,10 @@ const EditPage = createClass({
}), ()=>{if(this.state.autoSave) this.trySave();});
},
handleMetaChange : function(metadata){
handleMetaChange : function(metadata, field=undefined){
if(field == 'theme' || field == 'renderer') // Fetch theme bundle only if theme or renderer was changed
fetchThemeBundle(this, metadata.renderer, metadata.theme);
this.setState((prevState)=>({
brew : {
...prevState.brew,
@@ -138,7 +144,6 @@ const EditPage = createClass({
},
isPending : true,
}), ()=>{if(this.state.autoSave) this.trySave();});
},
hasChanges : function(){
@@ -406,12 +411,15 @@ const EditPage = createClass({
onMetaChange={this.handleMetaChange}
reportError={this.errorReported}
renderer={this.state.brew.renderer}
userThemes={this.props.userThemes}
snippetBundle={this.state.themeBundle.snippets}
/>
<BrewRenderer
text={this.state.brew.text}
style={this.state.brew.style}
renderer={this.state.brew.renderer}
theme={this.state.brew.theme}
themeBundle={this.state.themeBundle}
errors={this.state.htmlErrors}
lang={this.state.brew.lang}
currentEditorPage={this.state.currentEditorPage}

View File

@@ -136,6 +136,19 @@ const errorIndex = (props)=>{
**Brew ID:** ${props.brew.brewId}`,
// Theme load error
'09' : dedent`
## No Homebrewery theme document could be found.
The server could not locate the Homebrewery document. It was likely deleted by
its owner.
:
**Requested access:** ${props.brew.accessType}
**Brew ID:** ${props.brew.brewId}`,
// Brew locked by Administrators error
'100' : dedent`
## This brew has been locked.

View File

@@ -13,6 +13,7 @@ const HelpNavItem = require('../../navbar/help.navitem.jsx');
const RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
const AccountNavItem = require('../../navbar/account.navitem.jsx');
const ErrorNavItem = require('../../navbar/error-navitem.jsx');
const { fetchThemeBundle } = require('../../../../shared/helpers.js');
const SplitPane = require('naturalcrit/splitPane/splitPane.jsx');
@@ -34,12 +35,17 @@ const HomePage = createClass({
brew : this.props.brew,
welcomeText : this.props.brew.text,
error : undefined,
currentEditorPage : 0
currentEditorPage : 0,
themeBundle : {}
};
},
editor : React.createRef(null),
componentDidMount : function() {
fetchThemeBundle(this, this.props.brew.renderer, this.props.brew.theme);
},
handleSave : function(){
request.post('/api')
.send(this.state.brew)
@@ -89,12 +95,14 @@ const HomePage = createClass({
onTextChange={this.handleTextChange}
renderer={this.state.brew.renderer}
showEditButtons={false}
snippetBundle={this.state.themeBundle.snippets}
/>
<BrewRenderer
text={this.state.brew.text}
style={this.state.brew.style}
renderer={this.state.brew.renderer}
currentEditorPage={this.state.currentEditorPage}
themeBundle={this.state.themeBundle}
/>
</SplitPane>
</div>

View File

@@ -19,7 +19,7 @@ const Editor = require('../../editor/editor.jsx');
const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx');
const { DEFAULT_BREW } = require('../../../../server/brewDefaults.js');
const { printCurrentBrew } = require('../../../../shared/helpers.js');
const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpers.js');
const BREWKEY = 'homebrewery-new';
const STYLEKEY = 'homebrewery-new-style';
@@ -44,7 +44,8 @@ const NewPage = createClass({
saveGoogle : (global.account && global.account.googleId ? true : false),
error : null,
htmlErrors : Markdown.validate(brew.text),
currentEditorPage : 0
currentEditorPage : 0,
themeBundle : {}
};
},
@@ -77,6 +78,8 @@ const NewPage = createClass({
saveGoogle : (saveStorage == 'GOOGLE-DRIVE' && this.state.saveGoogle)
});
fetchThemeBundle(this, this.props.brew.renderer, this.props.brew.theme);
localStorage.setItem(BREWKEY, brew.text);
if(brew.style)
localStorage.setItem(STYLEKEY, brew.style);
@@ -122,7 +125,10 @@ const NewPage = createClass({
localStorage.setItem(STYLEKEY, style);
},
handleMetaChange : function(metadata){
handleMetaChange : function(metadata, field=undefined){
if(field == 'theme' || field == 'renderer') // Fetch theme bundle only if theme or renderer was changed
fetchThemeBundle(this, metadata.renderer, metadata.theme);
this.setState((prevState)=>({
brew : { ...prevState.brew, ...metadata },
}), ()=>{
@@ -142,8 +148,6 @@ const NewPage = createClass({
isSaving : true
});
console.log('saving new brew');
let brew = this.state.brew;
// Split out CSS to Style if CSS codefence exists
if(brew.text.startsWith('```css') && brew.text.indexOf('```\n\n') > 0) {
@@ -153,12 +157,10 @@ const NewPage = createClass({
}
brew.pageCount=((brew.renderer=='legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^\\page$/gm)) || []).length + 1;
const res = await request
.post(`/api${this.state.saveGoogle ? '?saveToGoogle=true' : ''}`)
.send(brew)
.catch((err)=>{
console.log(err);
this.setState({ isSaving: false, error: err });
});
if(!res) return;
@@ -214,12 +216,15 @@ const NewPage = createClass({
onStyleChange={this.handleStyleChange}
onMetaChange={this.handleMetaChange}
renderer={this.state.brew.renderer}
userThemes={this.props.userThemes}
snippetBundle={this.state.themeBundle.snippets}
/>
<BrewRenderer
text={this.state.brew.text}
style={this.state.brew.style}
renderer={this.state.brew.renderer}
theme={this.state.brew.theme}
themeBundle={this.state.themeBundle}
errors={this.state.htmlErrors}
lang={this.state.brew.lang}
currentEditorPage={this.state.currentEditorPage}

View File

@@ -12,18 +12,27 @@ const Account = require('../../navbar/account.navitem.jsx');
const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx');
const { DEFAULT_BREW_LOAD } = require('../../../../server/brewDefaults.js');
const { printCurrentBrew } = require('../../../../shared/helpers.js');
const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpers.js');
const SharePage = createClass({
displayName : 'SharePage',
getDefaultProps : function() {
return {
brew : DEFAULT_BREW_LOAD
brew : DEFAULT_BREW_LOAD,
disableMeta : false
};
},
getInitialState : function() {
return {
themeBundle : {}
};
},
componentDidMount : function() {
document.addEventListener('keydown', this.handleControlKeys);
fetchThemeBundle(this, this.props.brew.renderer, this.props.brew.theme);
},
componentWillUnmount : function() {
@@ -60,13 +69,21 @@ const SharePage = createClass({
},
render : function(){
const titleStyle = this.props.disableMeta ? { cursor: 'default' } : {};
const titleEl = <Nav.item className='brewTitle' style={titleStyle}>{this.props.brew.title}</Nav.item>;
return <div className='sharePage sitePage'>
<Meta name='robots' content='noindex, nofollow' />
<Navbar>
<Nav.section className='titleSection'>
{
this.props.disableMeta ?
titleEl
:
<MetadataNav brew={this.props.brew}>
<Nav.item className='brewTitle'>{this.props.brew.title}</Nav.item>
{titleEl}
</MetadataNav>
}
</Nav.section>
<Nav.section>
@@ -99,6 +116,7 @@ const SharePage = createClass({
style={this.props.brew.style}
renderer={this.props.brew.renderer}
theme={this.props.brew.theme}
themeBundle={this.state.themeBundle}
allowPrint={true}
/>
</div>

View File

@@ -4,6 +4,7 @@
"secret" : "secret",
"web_port" : 8000,
"enable_v3" : true,
"enable_themes" : true,
"local_environments" : ["docker", "local"],
"publicUrl" : "https://homebrewery.naturalcrit.com"
}

4994
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "homebrewery",
"description": "Create authentic looking D&D homebrews using only markdown",
"version": "3.13.1",
"version": "3.14.0",
"engines": {
"npm": "^10.2.x",
"node": "^20.8.x"
@@ -22,7 +22,8 @@
"circleci": "npm test && eslint **/*.{js,jsx} --max-warnings=0",
"verify": "npm run lint && npm test",
"test": "jest --runInBand",
"test:api-unit": "jest server/*.spec.js --verbose",
"test:api-unit": "jest \"server/.*.spec.js\" --verbose",
"test:api-unit:themes": "jest \"server/.*.spec.js\" -t \"theme bundle\" --verbose",
"test:coverage": "jest --coverage --silent --runInBand",
"test:dev": "jest --verbose --watch",
"test:basic": "jest tests/markdown/basic.test.js --verbose",
@@ -56,15 +57,15 @@
],
"coverageThreshold": {
"global": {
"statements": 25,
"branches": 10,
"functions": 22,
"lines": 25
"statements": 50,
"branches": 40,
"functions": 40,
"lines": 50
},
"server/homebrew.api.js": {
"statements": 65,
"statements": 70,
"branches": 50,
"functions": 60,
"functions": 65,
"lines": 70
}
},
@@ -82,9 +83,9 @@
]
},
"dependencies": {
"@babel/core": "^7.24.7",
"@babel/core": "^7.25.2",
"@babel/plugin-transform-runtime": "^7.24.7",
"@babel/preset-env": "^7.24.7",
"@babel/preset-env": "^7.25.3",
"@babel/preset-react": "^7.24.7",
"@googleapis/drive": "^8.11.0",
"body-parser": "^1.20.2",
@@ -93,7 +94,7 @@
"cookie-parser": "^1.4.6",
"create-react-class": "^15.7.0",
"dedent-tabs": "^0.10.3",
"dompurify": "^3.1.5",
"dompurify": "^3.1.6",
"expr-eval": "^2.0.2",
"express": "^4.19.2",
"express-async-handler": "^1.2.0",
@@ -104,34 +105,34 @@
"less": "^3.13.1",
"lodash": "^4.17.21",
"marked": "11.2.0",
"marked-emoji": "^1.4.1",
"marked-emoji": "^1.4.2",
"marked-extended-tables": "^1.0.8",
"marked-gfm-heading-id": "^3.2.0",
"marked-smartypants-lite": "^1.0.2",
"markedLegacy": "npm:marked@^0.3.19",
"moment": "^2.30.1",
"mongoose": "^8.4.5",
"mongoose": "^8.5.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.24.1",
"react-router-dom": "6.26.0",
"sanitize-filename": "1.6.3",
"superagent": "^9.0.2",
"vitreum": "git+https://git@github.com/calculuschild/vitreum.git"
},
"devDependencies": {
"@stylistic/stylelint-plugin": "^3.0.0",
"eslint": "^8.57.0",
"eslint-plugin-jest": "^28.6.0",
"eslint-plugin-react": "^7.34.3",
"eslint-plugin-jest": "^28.8.0",
"eslint-plugin-react": "^7.35.0",
"jest": "^29.7.0",
"jest-expect-message": "^1.1.3",
"postcss-less": "^6.0.0",
"stylelint": "^15.11.0",
"stylelint-config-recess-order": "^4.6.0",
"stylelint-config-recommended": "^13.0.0",
"stylelint-stylistic": "^0.4.3",
"stylelint": "^16.8.0",
"stylelint-config-recess-order": "^5.0.1",
"stylelint-config-recommended": "^14.0.1",
"supertest": "^7.0.0"
}
}

View File

@@ -9,7 +9,7 @@ const yaml = require('js-yaml');
const app = express();
const config = require('./config.js');
const { homebrewApi, getBrew } = require('./homebrew.api.js');
const { homebrewApi, getBrew, getUsersBrewThemes } = require('./homebrew.api.js');
const GoogleActions = require('./googleActions.js');
const serveCompressedStaticAssets = require('./static-assets.mv.js');
const sanitizeFilename = require('sanitize-filename');
@@ -81,7 +81,8 @@ app.get('/robots.txt', (req, res)=>{
app.get('/', (req, res, next)=>{
req.brew = {
text : welcomeText,
renderer : 'V3'
renderer : 'V3',
theme : '5ePHB'
},
req.ogMeta = { ...defaultMetaTags,
@@ -97,7 +98,8 @@ app.get('/', (req, res, next)=>{
app.get('/legacy', (req, res, next)=>{
req.brew = {
text : welcomeTextLegacy,
renderer : 'legacy'
renderer : 'legacy',
theme : '5ePHB'
},
req.ogMeta = { ...defaultMetaTags,
@@ -113,7 +115,8 @@ app.get('/legacy', (req, res, next)=>{
app.get('/migrate', (req, res, next)=>{
req.brew = {
text : migrateText,
renderer : 'V3'
renderer : 'V3',
theme : '5ePHB'
},
req.ogMeta = { ...defaultMetaTags,
@@ -130,7 +133,8 @@ app.get('/changelog', async (req, res, next)=>{
req.brew = {
title : 'Changelog',
text : changelogText,
renderer : 'V3'
renderer : 'V3',
theme : '5ePHB'
},
req.ogMeta = { ...defaultMetaTags,
@@ -147,7 +151,8 @@ app.get('/faq', async (req, res, next)=>{
req.brew = {
title : 'FAQ',
text : faqText,
renderer : 'V3'
renderer : 'V3',
theme : '5ePHB'
},
req.ogMeta = { ...defaultMetaTags,
@@ -265,9 +270,11 @@ app.get('/user/:username', async (req, res, next)=>{
});
//Edit Page
app.get('/edit/:id', asyncHandler(getBrew('edit')), (req, res, next)=>{
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.',
@@ -279,10 +286,10 @@ app.get('/edit/:id', asyncHandler(getBrew('edit')), (req, res, next)=>{
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
app.get('/new/:id', asyncHandler(getBrew('share')), (req, res, 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 = {
@@ -292,17 +299,31 @@ app.get('/new/:id', asyncHandler(getBrew('share')), (req, res, next)=>{
style : req.brew.style,
renderer : req.brew.renderer,
theme : req.brew.theme,
tags : req.brew.tags
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)=>{
@@ -418,7 +439,8 @@ const renderPage = async (req, res)=>{
enable_v3 : config.get('enable_v3'),
enable_themes : config.get('enable_themes'),
config : configuration,
ogMeta : req.ogMeta
ogMeta : req.ogMeta,
userThemes : req.userThemes
};
const title = req.brew ? req.brew.title : '';
const page = await templateFn('homebrew', title, props)

View File

@@ -8,9 +8,16 @@ const Markdown = require('../shared/naturalcrit/markdown.js');
const yaml = require('js-yaml');
const asyncHandler = require('express-async-handler');
const { nanoid } = require('nanoid');
const { splitTextStyleAndMetadata } = require('../shared/helpers.js');
const { DEFAULT_BREW, DEFAULT_BREW_LOAD } = require('./brewDefaults.js');
const Themes = require('../themes/themes.json');
const isStaticTheme = (renderer, themeName)=>{
return Themes[renderer]?.[themeName] !== undefined;
};
// const getTopBrews = (cb) => {
// HomebrewModel.find().sort({ views: -1 }).limit(5).exec(function(err, brews) {
// cb(brews);
@@ -37,6 +44,43 @@ const api = {
}
return { id, googleId };
},
//Get array of any of this user's brews tagged with `meta:theme`
getUsersBrewThemes : async (username)=>{
if(!username)
return {};
const fields = [
'title',
'tags',
'shareId',
'thumbnail',
'textBin',
'text',
'authors',
'renderer'
];
const userThemes = {};
const brews = await HomebrewModel.getByUser(username, true, fields, { tags: { $in: ['meta:theme', 'meta:Theme'] } });
if(brews) {
for (const brew of brews) {
userThemes[brew.renderer] ??= {};
userThemes[brew.renderer][brew.shareId] = {
name : brew.title,
renderer : brew.renderer,
baseTheme : brew.theme,
baseSnippets : false,
author : brew.authors[0],
path : brew.shareId,
thumbnail : brew.thumbnail || '/assets/naturalCritLogoWhite.svg'
};
}
}
return userThemes;
},
getBrew : (accessType, stubOnly = false)=>{
// Create middleware with the accessType passed in as part of the scope
return async (req, res, next)=>{
@@ -142,7 +186,7 @@ const api = {
return modified;
},
excludeStubProps : (brew)=>{
const propsToExclude = ['text', 'textBin', 'renderer', 'pageCount'];
const propsToExclude = ['text', 'textBin'];
for (const prop of propsToExclude) {
brew[prop] = undefined;
}
@@ -209,6 +253,58 @@ const api = {
res.status(200).send(saved);
},
getThemeBundle : async(req, res)=>{
/* getThemeBundle: Collects the theme and all parent themes
returns an object containing an array of css, and an array of snippets, in render order
req.params.id : The shareId ( User theme ) or name ( static theme )
req.params.renderer : The Markdown renderer used for this theme */
req.params.renderer = _.upperFirst(req.params.renderer);
let currentTheme;
const completeStyles = [];
const completeSnippets = [];
while (req.params.id) {
//=== User Themes ===//
if(!isStaticTheme(req.params.renderer, req.params.id)) {
await api.getBrew('share')(req, res, ()=>{})
.catch((err)=>{
if(err.HBErrorCode == '05')
err = { ...err, name: 'ThemeLoad Error', message: 'Theme Not Found', HBErrorCode: '09' };
throw err;
});
currentTheme = req.brew;
splitTextStyleAndMetadata(currentTheme);
// If there is anything in the snippets or style members, append them to the appropriate array
if(currentTheme?.snippets) completeSnippets.push(JSON.parse(currentTheme.snippets));
if(currentTheme?.style) completeStyles.push(`/* From Brew: ${req.protocol}://${req.get('host')}/share/${req.params.id} */\n\n${currentTheme.style}`);
req.params.id = currentTheme.theme;
req.params.renderer = currentTheme.renderer;
}
//=== Static Themes ===//
else {
const localSnippets = `${req.params.renderer}_${req.params.id}`; // Just log the name for loading on client
const localStyle = `@import url(\"/themes/${req.params.renderer}/${req.params.id}/style.css\");`;
completeSnippets.push(localSnippets);
completeStyles.push(`/* From Theme ${req.params.id} */\n\n${localStyle}`);
req.params.id = Themes[req.params.renderer][req.params.id].baseTheme;
}
}
const returnObj = {
// Reverse the order of the arrays so they are listed oldest parent to youngest child.
styles : completeStyles.reverse(),
snippets : completeSnippets.reverse()
};
res.setHeader('Content-Type', 'application/json');
return res.status(200).send(returnObj);
},
updateBrew : async (req, res)=>{
// Initialize brew from request and body, destructure query params, and set the initial value for the after-save method
const brewFromClient = api.excludePropsFromUpdate(req.body);
@@ -369,5 +465,6 @@ router.put('/api/:id', asyncHandler(api.getBrew('edit', true)), asyncHandler(api
router.put('/api/update/:id', asyncHandler(api.getBrew('edit', true)), asyncHandler(api.updateBrew));
router.delete('/api/:id', asyncHandler(api.deleteBrew));
router.get('/api/remove/:id', asyncHandler(api.deleteBrew));
router.get('/api/theme/:renderer/:id', asyncHandler(api.getThemeBundle));
module.exports = api;

View File

@@ -14,6 +14,9 @@ describe('Tests for api', ()=>{
let saved;
beforeEach(()=>{
jest.resetModules();
jest.restoreAllMocks();
saved = undefined;
saveFunc = jest.fn(async function() {
saved = { ...this, _id: '1' };
@@ -46,7 +49,8 @@ describe('Tests for api', ()=>{
res = {
status : jest.fn(()=>res),
send : jest.fn(()=>{})
send : jest.fn(()=>{}),
setHeader : jest.fn(()=>{})
};
api = require('./homebrew.api');
@@ -81,10 +85,6 @@ describe('Tests for api', ()=>{
};
});
afterEach(()=>{
jest.restoreAllMocks();
});
describe('getId', ()=>{
it('should return only id if google id is not present', ()=>{
const { id, googleId } = api.getId({
@@ -408,8 +408,8 @@ brew`);
expect(sent).not.toEqual(googleBrew);
expect(result.text).toBeUndefined();
expect(result.textBin).toBeUndefined();
expect(result.renderer).toBeUndefined();
expect(result.pageCount).toBeUndefined();
expect(result.renderer).toBe('v3');
expect(result.pageCount).toBe(1);
});
});
@@ -540,9 +540,9 @@ brew`);
description : '',
editId : expect.any(String),
gDrive : false,
pageCount : undefined,
pageCount : 1,
published : false,
renderer : undefined,
renderer : 'V3',
lang : 'en',
shareId : expect.any(String),
googleId : expect.any(String),
@@ -581,6 +581,121 @@ brew`);
});
});
describe('Theme bundle', ()=>{
it('should return Theme Bundle for a User Theme', async ()=>{
const brews = {
userThemeAID : { title: 'User Theme A', renderer: 'V3', theme: null, shareId: 'userThemeAID', style: 'User Theme A Style' }
};
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
model.get = jest.fn((getParams)=>toBrewPromise(brews[getParams.shareId]));
const req = { params: { renderer: 'V3', id: 'userThemeAID' }, get: ()=>{ return 'localhost'; }, protocol: 'https' };
await api.getThemeBundle(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith({
styles : ['/* From Brew: https://localhost/share/userThemeAID */\n\nUser Theme A Style'],
snippets : []
});
});
it('should return Theme Bundle for nested User Themes', async ()=>{
const brews = {
userThemeAID : { title: 'User Theme A', renderer: 'V3', theme: 'userThemeBID', shareId: 'userThemeAID', style: 'User Theme A Style' },
userThemeBID : { title: 'User Theme B', renderer: 'V3', theme: 'userThemeCID', shareId: 'userThemeBID', style: 'User Theme B Style' },
userThemeCID : { title: 'User Theme C', renderer: 'V3', theme: null, shareId: 'userThemeCID', style: 'User Theme C Style' }
};
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
model.get = jest.fn((getParams)=>toBrewPromise(brews[getParams.shareId]));
const req = { params: { renderer: 'V3', id: 'userThemeAID' }, get: ()=>{ return 'localhost'; }, protocol: 'https' };
await api.getThemeBundle(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith({
styles : [
'/* From Brew: https://localhost/share/userThemeCID */\n\nUser Theme C Style',
'/* From Brew: https://localhost/share/userThemeBID */\n\nUser Theme B Style',
'/* From Brew: https://localhost/share/userThemeAID */\n\nUser Theme A Style'
],
snippets : []
});
});
it('should return Theme Bundle for a Static Theme', async ()=>{
const req = { params: { renderer: 'V3', id: '5ePHB' }, get: ()=>{ return 'localhost'; }, protocol: 'https' };
await api.getThemeBundle(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith({
styles : [
`/* From Theme Blank */\n\n@import url("/themes/V3/Blank/style.css");`,
`/* From Theme 5ePHB */\n\n@import url("/themes/V3/5ePHB/style.css");`
],
snippets : [
'V3_Blank',
'V3_5ePHB'
]
});
});
it('should return Theme Bundle for nested User and Static Themes together', async ()=>{
const brews = {
userThemeAID : { title: 'User Theme A', renderer: 'V3', theme: 'userThemeBID', shareId: 'userThemeAID', style: 'User Theme A Style' },
userThemeBID : { title: 'User Theme B', renderer: 'V3', theme: 'userThemeCID', shareId: 'userThemeBID', style: 'User Theme B Style' },
userThemeCID : { title: 'User Theme C', renderer: 'V3', theme: '5eDMG', shareId: 'userThemeCID', style: 'User Theme C Style' }
};
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
model.get = jest.fn((getParams)=>toBrewPromise(brews[getParams.shareId]));
const req = { params: { renderer: 'V3', id: 'userThemeAID' }, get: ()=>{ return 'localhost'; }, protocol: 'https' };
await api.getThemeBundle(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith({
styles : [
`/* From Theme Blank */\n\n@import url("/themes/V3/Blank/style.css");`,
`/* From Theme 5ePHB */\n\n@import url("/themes/V3/5ePHB/style.css");`,
`/* From Theme 5eDMG */\n\n@import url("/themes/V3/5eDMG/style.css");`,
'/* From Brew: https://localhost/share/userThemeCID */\n\nUser Theme C Style',
'/* From Brew: https://localhost/share/userThemeBID */\n\nUser Theme B Style',
'/* From Brew: https://localhost/share/userThemeAID */\n\nUser Theme A Style'
],
snippets : [
'V3_Blank',
'V3_5ePHB',
'V3_5eDMG'
]
});
});
it('should fail for an invalid Theme in the chain', async()=>{
const brews = {
userThemeAID : { title: 'User Theme A', renderer: 'V3', theme: 'missingTheme', shareId: 'userThemeAID', style: 'User Theme A Style' },
};
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
model.get = jest.fn((getParams)=>toBrewPromise(brews[getParams.shareId]));
const req = { params: { renderer: 'V3', id: 'userThemeAID' }, get: ()=>{ return 'localhost'; }, protocol: 'https' };
let err;
await api.getThemeBundle(req, res)
.catch((e)=>err = e);
expect(err).toEqual({
HBErrorCode : '09',
accessType : 'share',
brewId : 'missingTheme',
message : 'Theme Not Found',
name : 'ThemeLoad Error',
status : 404 });
});
});
describe('deleteBrew', ()=>{
it('should handle case where fetching the brew returns an error', async ()=>{
api.getBrew = jest.fn(()=>async ()=>{ throw { message: 'err', HBErrorCode: '02' }; });

View File

@@ -52,8 +52,8 @@ HomebrewSchema.statics.get = async function(query, fields=null){
return brew;
};
HomebrewSchema.statics.getByUser = async function(username, allowAccess=false, fields=null){
const query = { authors: username, published: true };
HomebrewSchema.statics.getByUser = async function(username, allowAccess=false, fields=null, filter=null){
const query = { authors: username, published: true, ...filter };
if(allowAccess){
delete query.published;
}

View File

@@ -1,5 +1,6 @@
const _ = require('lodash');
const yaml = require('js-yaml');
const request = require('../client/homebrew/utils/request-middleware.js');
const splitTextStyleAndMetadata = (brew)=>{
brew.text = brew.text.replaceAll('\r\n', '\n');
@@ -15,6 +16,11 @@ const splitTextStyleAndMetadata = (brew)=>{
brew.style = brew.text.slice(7, index - 1);
brew.text = brew.text.slice(index + 5);
}
if(brew.text.startsWith('```snippets')) {
const index = brew.text.indexOf('```\n\n');
brew.snippets = brew.text.slice(11, index - 1);
brew.text = brew.text.slice(index + 5);
}
};
const printCurrentBrew = ()=>{
@@ -28,7 +34,24 @@ const printCurrentBrew = ()=>{
}
};
const fetchThemeBundle = async (obj, renderer, theme)=>{
const res = await request
.get(`/api/theme/${renderer}/${theme}`)
.catch((err)=>{
obj.setState({ error: err });
});
if(!res) return;
const themeBundle = res.body;
themeBundle.joinedStyles = themeBundle.styles.map((style)=>`<style>${style}</style>`).join('\n\n');
obj.setState((prevState)=>({
...prevState,
themeBundle : themeBundle
}));
};
module.exports = {
splitTextStyleAndMetadata,
printCurrentBrew
printCurrentBrew,
fetchThemeBundle,
};

View File

@@ -39,8 +39,10 @@ if(typeof window !== 'undefined'){
//Autocompletion
require('codemirror/addon/hint/show-hint.js');
const foldCode = require('./fold-code');
foldCode.registerHomebreweryHelper(CodeMirror);
const foldPagesCode = require('./fold-pages');
foldPagesCode.registerHomebreweryHelper(CodeMirror);
const foldCSSCode = require('./fold-css');
foldCSSCode.registerHomebreweryHelper(CodeMirror);
}
const CodeEditor = createClass({
@@ -411,11 +413,11 @@ const CodeEditor = createClass({
foldOptions : function(cm){
return {
scanUp : true,
rangeFinder : CodeMirror.fold.homebrewery,
rangeFinder : this.props.language === 'css' ? CodeMirror.fold.homebrewerycss : CodeMirror.fold.homebrewery,
widget : (from, to)=>{
let text = '';
let currentLine = from.line;
const maxLength = 50;
let maxLength = 50;
let foldPreviewText = '';
while (currentLine <= to.line && text.length <= maxLength) {
@@ -430,10 +432,15 @@ const CodeEditor = createClass({
}
}
text = foldPreviewText || `Lines ${from.line+1}-${to.line+1}`;
text = text.replace('{', '').trim();
// Truncate data URLs at `data:`
const startOfData = text.indexOf('data:');
if(startOfData > 0)
maxLength = Math.min(startOfData + 5, maxLength);
text = text.trim();
if(text.length > maxLength)
text = `${text.substr(0, maxLength)}...`;
text = `${text.slice(0, maxLength)}...`;
return `\u21A4 ${text} \u21A6`;
}
@@ -450,3 +457,4 @@ const CodeEditor = createClass({
});
module.exports = CodeEditor;

View File

@@ -0,0 +1,44 @@
module.exports = {
registerHomebreweryHelper : function(CodeMirror) {
CodeMirror.registerHelper('fold', 'homebrewerycss', function(cm, start) {
// BRACE FOLDING
const startMatcher = /\{[ \t]*$/;
const endMatcher = /\}[ \t]*$/;
const activeLine = cm.getLine(start.line);
if(activeLine.match(startMatcher)) {
const lastLineNo = cm.lastLine();
let end = start.line + 1;
let braceCount = 1;
while (end < lastLineNo) {
const curLine = cm.getLine(end);
if(curLine.match(startMatcher)) braceCount++;
if(curLine.match(endMatcher)) braceCount--;
if(braceCount == 0) break;
++end;
}
return {
from : CodeMirror.Pos(start.line, 0),
to : CodeMirror.Pos(end, cm.getLine(end).length)
};
}
// @import and data-url folding
const importMatcher = /^@import.*?;/;
const dataURLMatcher = /url\(.*?data\:.*\)/;
if(activeLine.match(importMatcher) || activeLine.match(dataURLMatcher)) {
return {
from : CodeMirror.Pos(start.line, 0),
to : CodeMirror.Pos(start.line, activeLine.length)
};
}
return null;
});
}
};

View File

@@ -28,17 +28,18 @@ const mathParser = new MathParser({
round : true,
floor : true,
ceil : true,
abs : true,
sin : false, cos : false, tan : false, asin : false, acos : false,
atan : false, sinh : false, cosh : false, tanh : false, asinh : false,
acosh : false, atanh : false, sqrt : false, cbrt : false, log : false,
log2 : false, ln : false, lg : false, log10 : false, expm1 : false,
log1p : false, abs : false, trunc : false, join : false, sum : false,
log1p : false, trunc : false, join : false, sum : false, indexOf : false,
'-' : false, '+' : false, exp : false, not : false, length : false,
'!' : false, sign : false, random : false, fac : false, min : false,
max : false, hypot : false, pyt : false, pow : false, atan2 : false,
'if' : false, gamma : false, roundTo : false, map : false, fold : false,
filter : false, indexOf : false,
filter : false,
remainder : false, factorial : false,
comparison : false, concatenate : false,
@@ -46,6 +47,16 @@ const mathParser = new MathParser({
array : false, fndef : false
}
});
// Add sign function
mathParser.functions.sign = function (a) {
if(a >= 0) return '+';
return '-';
};
// Add signed function
mathParser.functions.signed = function (a) {
if(a >= 0) return `+${a}`;
return `${a}`;
};
//Processes the markdown within an HTML block if it's just a class-wrapper
renderer.html = function (html) {
@@ -441,7 +452,7 @@ const replaceVar = function(input, hoist=false, allowUnresolved=false) {
const label = match[2];
//v=====--------------------< HANDLE MATH >-------------------=====v//
const mathRegex = /[a-z]+\(|[+\-*/^()]/g;
const mathRegex = /[a-z]+\(|[+\-*/^(),]/g;
const matches = label.split(mathRegex);
const mathVars = matches.filter((match)=>isNaN(match))?.map((s)=>s.trim()); // Capture any variable names
@@ -451,7 +462,7 @@ const replaceVar = function(input, hoist=false, allowUnresolved=false) {
mathVars?.forEach((variable)=>{
const foundVar = lookupVar(variable, globalPageNumber, hoist);
if(foundVar && foundVar.resolved && foundVar.content && !isNaN(foundVar.content)) // Only subsitute math values if fully resolved, not empty strings, and numbers
replacedLabel = replacedLabel.replaceAll(variable, foundVar.content);
replacedLabel = replacedLabel.replaceAll(new RegExp(`(?<!\\w)(${variable})(?!\\w)`, 'g'), foundVar.content);
});
try {

View File

@@ -1,5 +1,5 @@
const stylelint = require('stylelint');
const { isNumber } = require('stylelint/lib/utils/validateTypes');
const { isNumber } = require('stylelint/lib/utils/validateTypes.cjs');
const { report, ruleMessages, validateOptions } = stylelint.utils;
const ruleName = 'naturalcrit/declaration-block-multi-line-min-declarations';

View File

@@ -1,5 +1,5 @@
const stylelint = require('stylelint');
const { isNumber } = require('stylelint/lib/utils/validateTypes');
const { isNumber } = require('stylelint/lib/utils/validateTypes.cjs');
const { report, ruleMessages, validateOptions } = stylelint.utils;
const ruleName = 'naturalcrit/declaration-colon-min-space-before';

View File

@@ -371,3 +371,35 @@ describe('Cross-page variables', ()=>{
expect(rendered, `Input:\n${[source0, source1].join('\n\\page\n')}`, { showPrefix: false }).toBe('<p>two</p><p>one</p>\\page<p>two</p>');
});
});
describe('Math function parameter handling', ()=>{
it('allows variables in single-parameter functions', function() {
const source = '[var]:4.1\n\n$[floor(var)]';
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>4</p>`);
});
it('allows one variable and a number in two-parameter functions', function() {
const source = '[var]:4\n\n$[min(1,var)]';
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>1</p>`);
});
it('allows two variables in two-parameter functions', function() {
const source = '[var1]:4\n\n[var2]:8\n\n$[min(var1,var2)]';
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>4</p>`);
});
});
describe('Variable names that are subsets of other names', ()=>{
it('do not conflict with function names', function() {
const source = `[a]: -1\n\n$[abs(a)]`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered).toBe('<p>1</p>');
});
it('do not conflict with other variable names', function() {
const source = `[ab]: 2\n\n[aba]: 8\n\n[ba]: 4\n\n$[ab + aba + ba]`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered).toBe('<p>14</p>');
});
});

View File

@@ -1,6 +1,6 @@
{
"name" : "5e PHB",
"renderer" : "V3",
"baseTheme" : false,
"baseTheme" : "Blank",
"baseSnippets" : false
}

View File

@@ -349,7 +349,7 @@ module.exports = [
/* Ink Friendly */
*:is(.page,.monster,.note,.descriptive) {
background : white !important;
filter : drop-shadow(0px 0px 3px #888) !important;
box-shadow : 1px 4px 14px #888 !important;
}
.page img {

View File

@@ -35,7 +35,7 @@ const getTOC = (pages)=>{
const ToCExclude = getComputedStyle(heading).getPropertyValue('--TOC');
if(ToCExclude != 'exclude') {
recursiveAdd(heading.innerText.trim(), onPage, headerDepth.indexOf(heading.tagName), res);
recursiveAdd(heading.textContent.trim(), onPage, headerDepth.indexOf(heading.tagName), res);
}
});
return res;

View File

@@ -382,6 +382,14 @@
.useColumns(0.96, @fillMode: balance);
}
//only for IOS devices
@supports (-webkit-touch-callout: none) {
.page .monster.frame {
background-repeat : no-repeat;
background-size : cover;
}
}
// *****************************
// * FOOTER
// *****************************/
@@ -459,6 +467,7 @@
margin-left : 1.5em;
}
}
// *****************************
// * SPELL LIST
// *****************************/
@@ -883,6 +892,9 @@ h6,
.useColumns(0.96, @fillMode: balance);
}
}
.toc.wide li {
break-inside: auto;
}
}
// *****************************
@@ -907,6 +919,10 @@ h6,
.page h1 + * { margin-top : 0; }
.page .descriptive.wide + * {
margin-top: 0;
}
//*****************************
// * RUNE TABLE
// *****************************/

View File

@@ -1,6 +1,6 @@
{
"name" : "Journal",
"renderer" : "V3",
"baseTheme" : false,
"baseTheme" : "Blank",
"baseSnippets" : "5ePHB"
}

View File

@@ -7,6 +7,7 @@
@noteBorderImage : url('/assets/noteBorder.png');
@descriptiveBoxImage : url('/assets/descriptiveBorder.png');
@monsterBlockBackground : url('/assets/parchmentBackgroundGrayscale.jpg');
@monsterBlockOverlay : url('/assets/parchmentBackgroundOverlayed.jpg');
@monsterBorderImage : url('/assets/monsterBorderFancy.png');
@codeBorderImage : url('/assets/codeBorder.png');
@classTableDecoration : url('/assets/classTableDecoration.png');

View File

@@ -18,7 +18,7 @@
"5ePHB": {
"name": "5e PHB",
"renderer": "V3",
"baseTheme": false,
"baseTheme": "Blank",
"baseSnippets": false,
"path": "5ePHB"
},
@@ -32,7 +32,7 @@
"Journal": {
"name": "Journal",
"renderer": "V3",
"baseTheme": false,
"baseTheme": "Blank",
"baseSnippets": "5ePHB",
"path": "Journal"
}