0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2026-01-16 06:02:41 +00:00

Merge branch 'master' into Issue_1958

This commit is contained in:
David Bolack
2024-08-01 11:19:17 -05:00
48 changed files with 1821 additions and 1177 deletions

View File

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

View File

@@ -84,6 +84,81 @@ pre {
## changelog ## changelog
For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery). 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
##### calculuschild, G-Ambatte
* [x] Hotfixes for issues with v3.13.0
Fixes issues [#3559](https://github.com/naturalcrit/homebrewery/issues/3559), [#3552](https://github.com/naturalcrit/homebrewery/issues/3552), [#3554](https://github.com/naturalcrit/homebrewery/issues/3554)
}}
### Friday 28/6/2024 - v3.13.0
{{taskList
##### calculuschild
* [x] Add `:emoji:` Markdown syntax, with autosuggest; start typing after the first `:` for matching emojis from
:fab_font_awesome: FontAwesome, :df_d20: DiceFont, :ei_action: ElderberryInn, and a subset of :gi_broadsword: GameIcons
* [x] Fix `{curly injection}` to append to, rather than erase and replace target CSS
* [x] {{openSans **GET PDF**}} {{fa,fa-file-pdf}} now opens the print dialog directly, rather than redirecting to a separate page
##### Gazook
* [x] Several small style tweaks to the UI
* [x] Cleaning and refactoring several large pieces of code
##### 5e-Cleric
* [x] For error pages, add links to user account and `/share` page if available
Fixes issue [#3298](https://github.com/naturalcrit/homebrewery/issues/3298)
* [x] Change FrontCover title to use stroke outline instead of faking it with dozens of shadows
* [x] Cleaning and refactoring several large pieces of CSS
##### abquintic
* [x] Added additional {{openSans **TABLE OF CONTENTS**}} snippet options. Explicitly include or exclude items from the ToC generation via CSS properties
`--TOC:exclude` or `--TOC:include`, or change the included header depth from 3 to 6 (default 3) with `tocDepthH6`
##### MurdoMaclachlan *(new contributor!)*
* [x] Added "proficiency bonus" to Monster Stat Block snippet.
Fixes issue [#3397](https://github.com/naturalcrit/homebrewery/issues/3397)
}}
### Monday 18/3/2024 - v3.12.0 ### Monday 18/3/2024 - v3.12.0
{{taskList {{taskList

View File

@@ -0,0 +1,29 @@
// Dialog box, for popups and modal blocking messages
const React = require('react');
const { useRef, useEffect } = React;
function Dialog({ dismissKey, closeText = 'Close', blocking = false, ...rest }) {
const dialogRef = useRef(null);
useEffect(()=>{
if(!dismissKey || !localStorage.getItem(dismissKey)) {
blocking ? dialogRef.current?.showModal() : dialogRef.current?.show();
}
}, []);
const dismiss = ()=>{
dismissKey && localStorage.setItem(dismissKey, true);
dialogRef.current?.close();
};
return (
<dialog ref={dialogRef} onCancel={dismiss} {...rest}>
{rest.children}
<button className='dismiss' onClick={dismiss}>
{closeText}
</button>
</dialog>
);
};
export default Dialog;

View File

@@ -18,8 +18,6 @@ const { printCurrentBrew } = require('../../../shared/helpers.js');
const DOMPurify = require('dompurify'); const DOMPurify = require('dompurify');
const purifyConfig = { FORCE_BODY: true, SANITIZE_DOM: false }; const purifyConfig = { FORCE_BODY: true, SANITIZE_DOM: false };
const Themes = require('themes/themes.json');
const PAGE_HEIGHT = 1056; const PAGE_HEIGHT = 1056;
const INITIAL_CONTENT = dedent` const INITIAL_CONTENT = dedent`
@@ -37,7 +35,7 @@ const BrewPage = (props)=>{
index : 0, index : 0,
...props ...props
}; };
const cleanText = DOMPurify.sanitize(props.contents, purifyConfig); const cleanText = props.contents; //DOMPurify.sanitize(props.contents, purifyConfig);
return <div className={props.className} id={`p${props.index + 1}`} > return <div className={props.className} id={`p${props.index + 1}`} >
<div className='columnWrapper' dangerouslySetInnerHTML={{ __html: cleanText }} /> <div className='columnWrapper' dangerouslySetInnerHTML={{ __html: cleanText }} />
</div>; </div>;
@@ -57,6 +55,7 @@ const BrewRenderer = (props)=>{
lang : '', lang : '',
errors : [], errors : [],
currentEditorPage : 0, currentEditorPage : 0,
themeBundle : {},
...props ...props
}; };
@@ -125,10 +124,9 @@ const BrewRenderer = (props)=>{
}; };
const renderStyle = ()=>{ const renderStyle = ()=>{
if(!props.style) return; const cleanStyle = props.style; //DOMPurify.sanitize(props.style, purifyConfig);
const cleanStyle = DOMPurify.sanitize(props.style, purifyConfig); const themeStyles = props.themeBundle?.joinedStyles ?? '<style>@import url("/themes/V3/Blank/style.css");</style>';
//return <div style={{ display: 'none' }} dangerouslySetInnerHTML={{ __html: `<style>@layer styleTab {\n${sanitizeScriptTags(props.style)}\n} </style>` }} />; return <div style={{ display: 'none' }} dangerouslySetInnerHTML={{ __html: `${themeStyles} \n\n <style> ${cleanStyle} </style>` }} />;
return <div style={{ display: 'none' }} dangerouslySetInnerHTML={{ __html: `<style> ${cleanStyle} </style>` }} />;
}; };
const renderPage = (pageText, index)=>{ const renderPage = (pageText, index)=>{
@@ -188,10 +186,6 @@ const BrewRenderer = (props)=>{
document.dispatchEvent(new MouseEvent('click')); document.dispatchEvent(new MouseEvent('click'));
}; };
const rendererPath = props.renderer == 'V3' ? 'V3' : 'Legacy';
const themePath = props.theme ?? '5ePHB';
const baseThemePath = Themes[rendererPath][themePath].baseTheme;
return ( return (
<> <>
{/*render dummy page while iFrame is mounting.*/} {/*render dummy page while iFrame is mounting.*/}
@@ -203,6 +197,12 @@ const BrewRenderer = (props)=>{
</div> </div>
: null} : null}
<ErrorBar errors={props.errors} />
<div className='popups'>
<RenderWarnings />
<NotificationPopup />
</div>
{/*render in iFrame so broken code doesn't crash the site.*/} {/*render in iFrame so broken code doesn't crash the site.*/}
<Frame id='BrewRenderer' initialContent={INITIAL_CONTENT} <Frame id='BrewRenderer' initialContent={INITIAL_CONTENT}
style={{ width: '100%', height: '100%', visibility: state.visibility }} style={{ width: '100%', height: '100%', visibility: state.visibility }}
@@ -214,18 +214,6 @@ const BrewRenderer = (props)=>{
onKeyDown={handleControlKeys} onKeyDown={handleControlKeys}
tabIndex={-1} tabIndex={-1}
style={{ height: state.height }}> style={{ height: state.height }}>
<ErrorBar errors={props.errors} />
<div className='popups'>
<RenderWarnings />
<NotificationPopup />
</div>
<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 */} {/* Apply CSS from Style tab and render pages from Markdown tab */}
{state.isMounted {state.isMounted
&& &&

View File

@@ -1,28 +1,20 @@
require('./notificationPopup.less'); require('./notificationPopup.less');
const React = require('react'); const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash'); const _ = require('lodash');
const DISMISS_KEY = 'dismiss_notification12-04-23'; import Dialog from '../../../components/dialog.jsx';
const NotificationPopup = createClass({ const DISMISS_KEY = 'dismiss_notification12-04-23';
displayName : 'NotificationPopup', const DISMISS_BUTTON = <i className='fas fa-times dismiss' />;
getInitialState : function() {
return { const NotificationPopup = ()=>{
notifications : {} return <Dialog className='notificationPopup' dismissKey={DISMISS_KEY} closeText={DISMISS_BUTTON} >
}; <div className='header'>
}, <i className='fas fa-info-circle info'></i>
componentDidMount : function() { <h3>Notice</h3>
this.checkNotifications(); <small>This website is always improving and we are still adding new features and squashing bugs. Keep the following in mind:</small>
window.addEventListener('resize', this.checkNotifications); </div>
}, <ul>
componentWillUnmount : function() {
window.removeEventListener('resize', this.checkNotifications);
},
notifications : {
psa : function(){
return (
<>
<li key='psa'> <li key='psa'>
<em>Don't store IMAGES in Google Drive</em><br /> <em>Don't store IMAGES in Google Drive</em><br />
Google Drive is not an image service, and will block images from being used Google Drive is not an image service, and will block images from being used
@@ -46,35 +38,8 @@ const NotificationPopup = createClass({
See the FAQ See the FAQ
</a> to learn how to avoid losing your work! </a> to learn how to avoid losing your work!
</li> </li>
</> </ul>
); </Dialog>;
} };
},
checkNotifications : function(){
const hideDismiss = localStorage.getItem(DISMISS_KEY);
if(hideDismiss) return this.setState({ notifications: {} });
this.setState({
notifications : _.mapValues(this.notifications, (fn)=>{ return fn(); }) //Convert notification functions into their return text value
});
},
dismiss : function(){
localStorage.setItem(DISMISS_KEY, true);
this.checkNotifications();
},
render : function(){
if(_.isEmpty(this.state.notifications)) return null;
return <div className='notificationPopup'>
<i className='fas fa-times dismiss' onClick={this.dismiss}/>
<i className='fas fa-info-circle info' />
<div className='header'>
<h3>Notice</h3>
<small>This website is always improving and we are still adding new features and squashing bugs. Keep the following in mind:</small>
</div>
<ul>{_.values(this.state.notifications)}</ul>
</div>;
}
});
module.exports = NotificationPopup; module.exports = NotificationPopup;

View File

@@ -1,64 +1,60 @@
.popups{ .popups {
position : fixed; position : fixed;
top : @navbarHeight; top : @navbarHeight;
right : 15px; right : 24px;
z-index : 10001; z-index : 10001;
width : 450px; width : 450px;
} }
.notificationPopup{ .notificationPopup {
position : relative; position : relative;
display : inline-block;
width : 100%; width : 100%;
padding : 15px; padding : 15px;
padding-bottom : 10px; padding-bottom : 10px;
padding-left : 25px; padding-left : 25px;
background-color : @blue;
color : white; color : white;
a{ background-color : @blue;
color : #e0e5c1; border : none;
&[open] { display : inline-block; }
a {
font-weight : 800; font-weight : 800;
color : #E0E5C1;
} }
i.info{ i.info {
position : absolute; position : absolute;
top : 12px; top : 12px;
left : 12px; left : 12px;
opacity : 0.8;
font-size : 2.5em; font-size : 2.5em;
opacity : 0.8;
} }
i.dismiss{ button.dismiss {
position : absolute; position : absolute;
top : 10px; top : 10px;
right : 10px; right : 10px;
cursor : pointer; cursor : pointer;
background-color : transparent;
opacity : 0.6; opacity : 0.6;
&:hover{ &:hover { opacity : 1; }
opacity : 1;
} }
} .header { padding-left : 50px; }
.header { small {
padding-left : 50px;
}
small{
opacity : 0.7;
font-size : 0.6em; font-size : 0.6em;
opacity : 0.7;
} }
h3{ h3 {
font-size : 1.1em; font-size : 1.1em;
font-weight : 800; font-weight : 800;
} }
ul{ ul {
margin-top : 15px; margin-top : 15px;
font-size : 0.8em; font-size : 0.8em;
list-style-position : outside; list-style-position : outside;
list-style-type : disc; list-style-type : disc;
li{ li {
margin-top : 1.4em;
font-size : 0.8em; font-size : 0.8em;
line-height : 1.4em; line-height : 1.4em;
margin-top : 1.4em; em { font-weight : 800; }
em{
font-weight : 800;
}
} }
} }
} }

View File

@@ -381,7 +381,8 @@ const Editor = createClass({
<MetadataEditor <MetadataEditor
metadata={this.props.brew} metadata={this.props.brew}
onChange={this.props.onMetaChange} 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()} historySize={this.historySize()}
currentEditorTheme={this.state.editorTheme} currentEditorTheme={this.state.editorTheme}
updateEditorTheme={this.updateEditorTheme} updateEditorTheme={this.updateEditorTheme}
snippetBundle={this.props.snippetBundle}
cursorPos={this.codeEditor.current?.getCursorPosition() || {}} /> cursorPos={this.codeEditor.current?.getCursorPosition() || {}} />
{this.renderEditor()} {this.renderEditor()}

View File

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

View File

@@ -191,6 +191,13 @@
color : white; color : white;
} }
} }
.navDropdown .item > p {
width: 45%;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
height: 1.1em;
}
.navDropdown { .navDropdown {
box-shadow : 0px 5px 10px rgba(0, 0, 0, 0.3); box-shadow : 0px 5px 10px rgba(0, 0, 0, 0.3);
position : absolute; position : absolute;
@@ -230,14 +237,23 @@
&:hover > .preview { &:hover > .preview {
opacity: 1; opacity: 1;
} }
>img { .texture-container {
position: absolute;
width: 100%;
height: 100%;
min-height: 100%;
top: 0;
left: 0;
overflow: hidden;
> img {
mask-image : linear-gradient(90deg, transparent, black 20%); mask-image : linear-gradient(90deg, transparent, black 20%);
-webkit-mask-image : linear-gradient(90deg, transparent, black 20%); -webkit-mask-image : linear-gradient(90deg, transparent, black 20%);
position : absolute; position : absolute;
right : 0; right : 0;
top : 0px; top : 0px;
width : 50%; width : 50%;
height : 100%; min-height : 100%;
}
} }
} }
} }

View File

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

View File

@@ -66,10 +66,10 @@ const Homebrew = createClass({
<Router location={this.props.url}> <Router location={this.props.url}>
<div className='homebrew'> <div className='homebrew'>
<Routes> <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='/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/:id' element={<WithRoute el={NewPage} brew={this.props.brew} userThemes={this.props.userThemes}/>} />
<Route path='/new' element={<WithRoute el={NewPage}/>} /> <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='/user/:username' element={<WithRoute el={UserPage} brews={this.props.brews} />} />
<Route path='/changelog' element={<WithRoute el={SharePage} brew={this.props.brew} />} /> <Route path='/changelog' element={<WithRoute el={SharePage} brew={this.props.brew} />} />
<Route path='/faq' element={<WithRoute el={SharePage} brew={this.props.brew} />} /> <Route path='/faq' element={<WithRoute el={SharePage} brew={this.props.brew} />} />

View File

@@ -104,6 +104,18 @@ const ErrorNavItem = createClass({
</Nav.item>; </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'> return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops! Oops!
<div className='errorContainer'> <div className='errorContainer'>

View File

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

View File

@@ -124,6 +124,7 @@
opacity : 0.6; opacity : 0.6;
font-size : 1.3em; font-size : 1.3em;
color : white; color : white;
text-decoration : unset;
&:hover{ &:hover{
opacity : 1; opacity : 1;
} }

View File

@@ -20,10 +20,12 @@ const SplitPane = require('naturalcrit/splitPane/splitPane.jsx');
const Editor = require('../../editor/editor.jsx'); const Editor = require('../../editor/editor.jsx');
const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx'); const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx');
const LockNotification = require('./lockNotification/lockNotification.jsx');
const Markdown = require('naturalcrit/markdown.js'); const Markdown = require('naturalcrit/markdown.js');
const { DEFAULT_BREW_LOAD } = require('../../../../server/brewDefaults.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'); const googleDriveIcon = require('../../googleDrive.svg');
@@ -52,7 +54,9 @@ const EditPage = createClass({
autoSave : true, autoSave : true,
autoSaveWarning : false, autoSaveWarning : false,
unsavedTime : new Date(), unsavedTime : new Date(),
currentEditorPage : 0 currentEditorPage : 0,
displayLockMessage : this.props.brew.lock || false,
themeBundle : {}
}; };
}, },
@@ -84,6 +88,8 @@ const EditPage = createClass({
htmlErrors : Markdown.validate(prevState.brew.text) htmlErrors : Markdown.validate(prevState.brew.text)
})); }));
fetchThemeBundle(this, this.props.brew.renderer, this.props.brew.theme);
document.addEventListener('keydown', this.handleControlKeys); document.addEventListener('keydown', this.handleControlKeys);
}, },
componentWillUnmount : function() { componentWillUnmount : function() {
@@ -127,7 +133,10 @@ const EditPage = createClass({
}), ()=>{if(this.state.autoSave) this.trySave();}); }), ()=>{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)=>({ this.setState((prevState)=>({
brew : { brew : {
...prevState.brew, ...prevState.brew,
@@ -135,7 +144,6 @@ const EditPage = createClass({
}, },
isPending : true, isPending : true,
}), ()=>{if(this.state.autoSave) this.trySave();}); }), ()=>{if(this.state.autoSave) this.trySave();});
}, },
hasChanges : function(){ hasChanges : function(){
@@ -393,6 +401,7 @@ const EditPage = createClass({
{this.renderNavbar()} {this.renderNavbar()}
<div className='content'> <div className='content'>
{this.props.brew.lock && <LockNotification shareId={this.props.brew.shareId} message={this.props.brew.lock.editMessage} />}
<SplitPane onDragFinish={this.handleSplitMove}> <SplitPane onDragFinish={this.handleSplitMove}>
<Editor <Editor
ref={this.editor} ref={this.editor}
@@ -402,12 +411,15 @@ const EditPage = createClass({
onMetaChange={this.handleMetaChange} onMetaChange={this.handleMetaChange}
reportError={this.errorReported} reportError={this.errorReported}
renderer={this.state.brew.renderer} renderer={this.state.brew.renderer}
userThemes={this.props.userThemes}
snippetBundle={this.state.themeBundle.snippets}
/> />
<BrewRenderer <BrewRenderer
text={this.state.brew.text} text={this.state.brew.text}
style={this.state.brew.style} style={this.state.brew.style}
renderer={this.state.brew.renderer} renderer={this.state.brew.renderer}
theme={this.state.brew.theme} theme={this.state.brew.theme}
themeBundle={this.state.themeBundle}
errors={this.state.htmlErrors} errors={this.state.htmlErrors}
lang={this.state.brew.lang} lang={this.state.brew.lang}
currentEditorPage={this.state.currentEditorPage} currentEditorPage={this.state.currentEditorPage}

View File

@@ -0,0 +1,30 @@
require('./lockNotification.less');
const React = require('react');
import Dialog from '../../../../components/dialog.jsx';
function LockNotification(props) {
props = {
shareId : 0,
disableLock : ()=>{},
message : '',
...props
};
const removeLock = ()=>{
alert(`Not yet implemented - ID ${props.shareId}`);
};
return <Dialog className='lockNotification' blocking closeText='CONTINUE TO EDITOR' >
<h1>BREW LOCKED</h1>
<p>This brew been locked by the Administrators. It will not be accessible by any method other than the Editor until the lock is removed.</p>
<hr />
<h3>LOCK REASON</h3>
<p>{props.message || 'Unable to retrieve Lock Message'}</p>
<hr />
<p>Once you have resolved this issue, click REQUEST LOCK REMOVAL to notify the Administrators for review.</p>
<p>Click CONTINUE TO EDITOR to temporarily hide this notification; it will reappear the next time the page is reloaded.</p>
<button onClick={removeLock}>REQUEST LOCK REMOVAL</button>
</Dialog>;
};
module.exports = LockNotification;

View File

@@ -0,0 +1,27 @@
.lockNotification {
z-index : 1;
width : 80%;
padding : 10px;
margin : 5% 10%;
line-height : 1.5em;
color : black;
text-align : center;
background-color : #CCCCCC;
&::backdrop { background-color : #000000AA; }
button {
margin : 10px;
color : white;
background-color : #333333;
&:hover { background-color : #777777; }
}
h1, h3 {
font-family : 'Open Sans', sans-serif;
font-weight : 800;
}
h1 { font-size : 24px; }
h3 { font-size : 18px; }
}

View File

@@ -136,11 +136,24 @@ const errorIndex = (props)=>{
**Brew ID:** ${props.brew.brewId}`, **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 // Brew locked by Administrators error
'100' : dedent` '100' : dedent`
## This brew has been locked. ## This brew has been locked.
Please contact the Administrators to unlock this document. Only an author may request that this lock is removed.
: :

View File

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

View File

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

View File

@@ -12,18 +12,26 @@ const Account = require('../../navbar/account.navitem.jsx');
const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx'); const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx');
const { DEFAULT_BREW_LOAD } = require('../../../../server/brewDefaults.js'); const { DEFAULT_BREW_LOAD } = require('../../../../server/brewDefaults.js');
const { printCurrentBrew } = require('../../../../shared/helpers.js'); const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpers.js');
const SharePage = createClass({ const SharePage = createClass({
displayName : 'SharePage', displayName : 'SharePage',
getDefaultProps : function() { getDefaultProps : function() {
return { return {
brew : DEFAULT_BREW_LOAD brew : DEFAULT_BREW_LOAD,
};
},
getInitialState : function() {
return {
themeBundle : {}
}; };
}, },
componentDidMount : function() { componentDidMount : function() {
document.addEventListener('keydown', this.handleControlKeys); document.addEventListener('keydown', this.handleControlKeys);
fetchThemeBundle(this, this.props.brew.renderer, this.props.brew.theme);
}, },
componentWillUnmount : function() { componentWillUnmount : function() {
@@ -99,6 +107,7 @@ const SharePage = createClass({
style={this.props.brew.style} style={this.props.brew.style}
renderer={this.props.brew.renderer} renderer={this.props.brew.renderer}
theme={this.props.brew.theme} theme={this.props.brew.theme}
themeBundle={this.state.themeBundle}
allowPrint={true} allowPrint={true}
/> />
</div> </div>

View File

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

1713
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{ {
"name": "homebrewery", "name": "homebrewery",
"description": "Create authentic looking D&D homebrews using only markdown", "description": "Create authentic looking D&D homebrews using only markdown",
"version": "3.12.0", "version": "3.14.0",
"engines": { "engines": {
"npm": "^10.2.x", "npm": "^10.2.x",
"node": "^20.8.x" "node": "^20.8.x"
@@ -22,7 +22,8 @@
"circleci": "npm test && eslint **/*.{js,jsx} --max-warnings=0", "circleci": "npm test && eslint **/*.{js,jsx} --max-warnings=0",
"verify": "npm run lint && npm test", "verify": "npm run lint && npm test",
"test": "jest --runInBand", "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:coverage": "jest --coverage --silent --runInBand",
"test:dev": "jest --verbose --watch", "test:dev": "jest --verbose --watch",
"test:basic": "jest tests/markdown/basic.test.js --verbose", "test:basic": "jest tests/markdown/basic.test.js --verbose",
@@ -56,15 +57,15 @@
], ],
"coverageThreshold": { "coverageThreshold": {
"global": { "global": {
"statements": 25, "statements": 50,
"branches": 10, "branches": 40,
"functions": 22, "functions": 40,
"lines": 25 "lines": 50
}, },
"server/homebrew.api.js": { "server/homebrew.api.js": {
"statements": 65, "statements": 70,
"branches": 50, "branches": 50,
"functions": 60, "functions": 65,
"lines": 70 "lines": 70
} }
}, },
@@ -82,18 +83,18 @@
] ]
}, },
"dependencies": { "dependencies": {
"@babel/core": "^7.24.5", "@babel/core": "^7.25.2",
"@babel/plugin-transform-runtime": "^7.24.3", "@babel/plugin-transform-runtime": "^7.24.7",
"@babel/preset-env": "^7.24.5", "@babel/preset-env": "^7.25.3",
"@babel/preset-react": "^7.24.1", "@babel/preset-react": "^7.24.7",
"@googleapis/drive": "^8.8.0", "@googleapis/drive": "^8.11.0",
"body-parser": "^1.20.2", "body-parser": "^1.20.2",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"codemirror": "^5.65.6", "codemirror": "^5.65.6",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"create-react-class": "^15.7.0", "create-react-class": "^15.7.0",
"dedent-tabs": "^0.10.3", "dedent-tabs": "^0.10.3",
"dompurify": "^3.1.4", "dompurify": "^3.1.6",
"expr-eval": "^2.0.2", "expr-eval": "^2.0.2",
"express": "^4.19.2", "express": "^4.19.2",
"express-async-handler": "^1.2.0", "express-async-handler": "^1.2.0",
@@ -104,27 +105,27 @@
"less": "^3.13.1", "less": "^3.13.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"marked": "11.2.0", "marked": "11.2.0",
"marked-emoji": "^1.4.0", "marked-emoji": "^1.4.1",
"marked-extended-tables": "^1.0.8", "marked-extended-tables": "^1.0.8",
"marked-gfm-heading-id": "^3.1.3", "marked-gfm-heading-id": "^3.2.0",
"marked-smartypants-lite": "^1.0.2", "marked-smartypants-lite": "^1.0.2",
"markedLegacy": "npm:marked@^0.3.19", "markedLegacy": "npm:marked@^0.3.19",
"moment": "^2.30.1", "moment": "^2.30.1",
"mongoose": "^8.4.0", "mongoose": "^8.5.2",
"nanoid": "3.3.4", "nanoid": "3.3.4",
"nconf": "^0.12.1", "nconf": "^0.12.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-frame-component": "^4.1.3", "react-frame-component": "^4.1.3",
"react-router-dom": "6.23.1", "react-router-dom": "6.25.1",
"sanitize-filename": "1.6.3", "sanitize-filename": "1.6.3",
"superagent": "^9.0.2", "superagent": "^9.0.2",
"vitreum": "git+https://git@github.com/calculuschild/vitreum.git" "vitreum": "git+https://git@github.com/calculuschild/vitreum.git"
}, },
"devDependencies": { "devDependencies": {
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-plugin-jest": "^28.5.0", "eslint-plugin-jest": "^28.6.0",
"eslint-plugin-react": "^7.34.1", "eslint-plugin-react": "^7.35.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-expect-message": "^1.1.3", "jest-expect-message": "^1.1.3",
"postcss-less": "^6.0.0", "postcss-less": "^6.0.0",

View File

@@ -10,7 +10,7 @@ const app = express();
const config = require('./config.js'); const config = require('./config.js');
const fs = require('fs-extra'); const fs = require('fs-extra');
const { homebrewApi, getBrew } = require('./homebrew.api.js'); const { homebrewApi, getBrew, getUsersBrewThemes } = require('./homebrew.api.js');
const GoogleActions = require('./googleActions.js'); const GoogleActions = require('./googleActions.js');
const serveCompressedStaticAssets = require('./static-assets.mv.js'); const serveCompressedStaticAssets = require('./static-assets.mv.js');
const sanitizeFilename = require('sanitize-filename'); const sanitizeFilename = require('sanitize-filename');
@@ -82,7 +82,8 @@ app.get('/robots.txt', (req, res)=>{
app.get('/', (req, res, next)=>{ app.get('/', (req, res, next)=>{
req.brew = { req.brew = {
text : welcomeText, text : welcomeText,
renderer : 'V3' renderer : 'V3',
theme : '5ePHB'
}, },
req.ogMeta = { ...defaultMetaTags, req.ogMeta = { ...defaultMetaTags,
@@ -98,7 +99,8 @@ app.get('/', (req, res, next)=>{
app.get('/legacy', (req, res, next)=>{ app.get('/legacy', (req, res, next)=>{
req.brew = { req.brew = {
text : welcomeTextLegacy, text : welcomeTextLegacy,
renderer : 'legacy' renderer : 'legacy',
theme : '5ePHB'
}, },
req.ogMeta = { ...defaultMetaTags, req.ogMeta = { ...defaultMetaTags,
@@ -114,7 +116,8 @@ app.get('/legacy', (req, res, next)=>{
app.get('/migrate', (req, res, next)=>{ app.get('/migrate', (req, res, next)=>{
req.brew = { req.brew = {
text : migrateText, text : migrateText,
renderer : 'V3' renderer : 'V3',
theme : '5ePHB'
}, },
req.ogMeta = { ...defaultMetaTags, req.ogMeta = { ...defaultMetaTags,
@@ -131,7 +134,8 @@ app.get('/changelog', async (req, res, next)=>{
req.brew = { req.brew = {
title : 'Changelog', title : 'Changelog',
text : changelogText, text : changelogText,
renderer : 'V3' renderer : 'V3',
theme : '5ePHB'
}, },
req.ogMeta = { ...defaultMetaTags, req.ogMeta = { ...defaultMetaTags,
@@ -148,7 +152,8 @@ app.get('/faq', async (req, res, next)=>{
req.brew = { req.brew = {
title : 'FAQ', title : 'FAQ',
text : faqText, text : faqText,
renderer : 'V3' renderer : 'V3',
theme : '5ePHB'
}, },
req.ogMeta = { ...defaultMetaTags, req.ogMeta = { ...defaultMetaTags,
@@ -266,9 +271,11 @@ app.get('/user/:username', async (req, res, next)=>{
}); });
//Edit Page //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.brew = req.brew.toObject ? req.brew.toObject() : req.brew;
req.userThemes = await(getUsersBrewThemes(req.account?.username));
req.ogMeta = { ...defaultMetaTags, req.ogMeta = { ...defaultMetaTags,
title : req.brew.title || 'Untitled Brew', title : req.brew.title || 'Untitled Brew',
description : req.brew.description || 'No description.', description : req.brew.description || 'No description.',
@@ -280,10 +287,10 @@ app.get('/edit/:id', asyncHandler(getBrew('edit')), (req, res, next)=>{
splitTextStyleAndMetadata(req.brew); 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. 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(); return next();
}); }));
//New Page //New Page from ID
app.get('/new/:id', asyncHandler(getBrew('share')), (req, res, next)=>{ app.get('/new/:id', asyncHandler(getBrew('share')), asyncHandler(async(req, res, next)=>{
sanitizeBrew(req.brew, 'share'); sanitizeBrew(req.brew, 'share');
splitTextStyleAndMetadata(req.brew); splitTextStyleAndMetadata(req.brew);
const brew = { const brew = {
@@ -293,17 +300,31 @@ app.get('/new/:id', asyncHandler(getBrew('share')), (req, res, next)=>{
style : req.brew.style, style : req.brew.style,
renderer : req.brew.renderer, renderer : req.brew.renderer,
theme : req.brew.theme, theme : req.brew.theme,
tags : req.brew.tags tags : req.brew.tags,
}; };
req.brew = _.defaults(brew, DEFAULT_BREW); req.brew = _.defaults(brew, DEFAULT_BREW);
req.userThemes = await(getUsersBrewThemes(req.account?.username));
req.ogMeta = { ...defaultMetaTags, req.ogMeta = { ...defaultMetaTags,
title : 'New', title : 'New',
description : 'Start crafting your homebrew on the Homebrewery!' description : 'Start crafting your homebrew on the Homebrewery!'
}; };
return next(); 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 //Share Page
app.get('/share/:id', asyncHandler(getBrew('share')), asyncHandler(async (req, res, next)=>{ app.get('/share/:id', asyncHandler(getBrew('share')), asyncHandler(async (req, res, next)=>{
@@ -422,7 +443,8 @@ const renderPage = async (req, res)=>{
enable_v3 : config.get('enable_v3'), enable_v3 : config.get('enable_v3'),
enable_themes : config.get('enable_themes'), enable_themes : config.get('enable_themes'),
config : configuration, config : configuration,
ogMeta : req.ogMeta ogMeta : req.ogMeta,
userThemes : req.userThemes
}; };
const title = req.brew ? req.brew.title : ''; const title = req.brew ? req.brew.title : '';
const page = await templateFn('homebrew', title, props) 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 yaml = require('js-yaml');
const asyncHandler = require('express-async-handler'); const asyncHandler = require('express-async-handler');
const { nanoid } = require('nanoid'); const { nanoid } = require('nanoid');
const { splitTextStyleAndMetadata } = require('../shared/helpers.js');
const { DEFAULT_BREW, DEFAULT_BREW_LOAD } = require('./brewDefaults.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) => { // const getTopBrews = (cb) => {
// HomebrewModel.find().sort({ views: -1 }).limit(5).exec(function(err, brews) { // HomebrewModel.find().sort({ views: -1 }).limit(5).exec(function(err, brews) {
// cb(brews); // cb(brews);
@@ -37,6 +44,43 @@ const api = {
} }
return { id, googleId }; 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)=>{ getBrew : (accessType, stubOnly = false)=>{
// Create middleware with the accessType passed in as part of the scope // Create middleware with the accessType passed in as part of the scope
return async (req, res, next)=>{ return async (req, res, next)=>{
@@ -55,7 +99,7 @@ const api = {
stub = stub?.toObject(); stub = stub?.toObject();
if(stub?.lock?.locked && accessType != 'edit') { if(stub?.lock?.locked && accessType != 'edit') {
throw { HBErrorCode: '100', code: stub.lock.code, message: stub.lock.message, brewId: stub.shareId, brewTitle: stub.title }; throw { HBErrorCode: '100', code: stub.lock.code, message: stub.lock.shareMessage, brewId: stub.shareId, brewTitle: stub.title };
} }
// If there is a google id, try to find the google brew // If there is a google id, try to find the google brew
@@ -142,7 +186,7 @@ const api = {
return modified; return modified;
}, },
excludeStubProps : (brew)=>{ excludeStubProps : (brew)=>{
const propsToExclude = ['text', 'textBin', 'renderer', 'pageCount']; const propsToExclude = ['text', 'textBin'];
for (const prop of propsToExclude) { for (const prop of propsToExclude) {
brew[prop] = undefined; brew[prop] = undefined;
} }
@@ -209,6 +253,58 @@ const api = {
res.status(200).send(saved); 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)=>{ updateBrew : async (req, res)=>{
// Initialize brew from request and body, destructure query params, and set the initial value for the after-save method // 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); 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.put('/api/update/:id', asyncHandler(api.getBrew('edit', true)), asyncHandler(api.updateBrew));
router.delete('/api/:id', asyncHandler(api.deleteBrew)); router.delete('/api/:id', asyncHandler(api.deleteBrew));
router.get('/api/remove/:id', asyncHandler(api.deleteBrew)); router.get('/api/remove/:id', asyncHandler(api.deleteBrew));
router.get('/api/theme/:renderer/:id', asyncHandler(api.getThemeBundle));
module.exports = api; module.exports = api;

View File

@@ -14,6 +14,9 @@ describe('Tests for api', ()=>{
let saved; let saved;
beforeEach(()=>{ beforeEach(()=>{
jest.resetModules();
jest.restoreAllMocks();
saved = undefined; saved = undefined;
saveFunc = jest.fn(async function() { saveFunc = jest.fn(async function() {
saved = { ...this, _id: '1' }; saved = { ...this, _id: '1' };
@@ -46,7 +49,8 @@ describe('Tests for api', ()=>{
res = { res = {
status : jest.fn(()=>res), status : jest.fn(()=>res),
send : jest.fn(()=>{}) send : jest.fn(()=>{}),
setHeader : jest.fn(()=>{})
}; };
api = require('./homebrew.api'); api = require('./homebrew.api');
@@ -81,10 +85,6 @@ describe('Tests for api', ()=>{
}; };
}); });
afterEach(()=>{
jest.restoreAllMocks();
});
describe('getId', ()=>{ describe('getId', ()=>{
it('should return only id if google id is not present', ()=>{ it('should return only id if google id is not present', ()=>{
const { id, googleId } = api.getId({ const { id, googleId } = api.getId({
@@ -300,7 +300,7 @@ describe('Tests for api', ()=>{
}); });
it('access is denied to a locked brew', async()=>{ it('access is denied to a locked brew', async()=>{
const lockBrew = { title: 'test brew', shareId: '1', lock: { locked: true, code: 404, message: 'brew locked' } }; const lockBrew = { title: 'test brew', shareId: '1', lock: { locked: true, code: 404, shareMessage: 'brew locked' } };
model.get = jest.fn(()=>toBrewPromise(lockBrew)); model.get = jest.fn(()=>toBrewPromise(lockBrew));
api.getId = jest.fn(()=>({ id: '1', googleId: undefined })); api.getId = jest.fn(()=>({ id: '1', googleId: undefined }));
@@ -408,8 +408,8 @@ brew`);
expect(sent).not.toEqual(googleBrew); expect(sent).not.toEqual(googleBrew);
expect(result.text).toBeUndefined(); expect(result.text).toBeUndefined();
expect(result.textBin).toBeUndefined(); expect(result.textBin).toBeUndefined();
expect(result.renderer).toBeUndefined(); expect(result.renderer).toBe('v3');
expect(result.pageCount).toBeUndefined(); expect(result.pageCount).toBe(1);
}); });
}); });
@@ -540,9 +540,9 @@ brew`);
description : '', description : '',
editId : expect.any(String), editId : expect.any(String),
gDrive : false, gDrive : false,
pageCount : undefined, pageCount : 1,
published : false, published : false,
renderer : undefined, renderer : 'V3',
lang : 'en', lang : 'en',
shareId : expect.any(String), shareId : expect.any(String),
googleId : 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', ()=>{ describe('deleteBrew', ()=>{
it('should handle case where fetching the brew returns an error', async ()=>{ it('should handle case where fetching the brew returns an error', async ()=>{
api.getBrew = jest.fn(()=>async ()=>{ throw { message: 'err', HBErrorCode: '02' }; }); api.getBrew = jest.fn(()=>async ()=>{ throw { message: 'err', HBErrorCode: '02' }; });

View File

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

View File

@@ -1,5 +1,6 @@
const _ = require('lodash'); const _ = require('lodash');
const yaml = require('js-yaml'); const yaml = require('js-yaml');
const request = require('../client/homebrew/utils/request-middleware.js');
const splitTextStyleAndMetadata = (brew)=>{ const splitTextStyleAndMetadata = (brew)=>{
brew.text = brew.text.replaceAll('\r\n', '\n'); brew.text = brew.text.replaceAll('\r\n', '\n');
@@ -15,6 +16,11 @@ const splitTextStyleAndMetadata = (brew)=>{
brew.style = brew.text.slice(7, index - 1); brew.style = brew.text.slice(7, index - 1);
brew.text = brew.text.slice(index + 5); 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 = ()=>{ 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 = { module.exports = {
splitTextStyleAndMetadata, splitTextStyleAndMetadata,
printCurrentBrew printCurrentBrew,
fetchThemeBundle,
}; };

View File

@@ -3,7 +3,7 @@ const React = require('react');
const createClass = require('create-react-class'); const createClass = require('create-react-class');
const _ = require('lodash'); const _ = require('lodash');
const DISMISS_KEY = 'dismiss_render_warning'; import Dialog from '../../../client/components/dialog.jsx';
const RenderWarnings = createClass({ const RenderWarnings = createClass({
displayName : 'RenderWarnings', displayName : 'RenderWarnings',
@@ -34,9 +34,6 @@ const RenderWarnings = createClass({
}, },
}, },
checkWarnings : function(){ checkWarnings : function(){
const hideDismiss = localStorage.getItem(DISMISS_KEY);
if(hideDismiss) return this.setState({ warnings: {} });
this.setState({ this.setState({
warnings : _.reduce(this.warnings, (r, fn, type)=>{ warnings : _.reduce(this.warnings, (r, fn, type)=>{
const element = fn(); const element = fn();
@@ -45,20 +42,18 @@ const RenderWarnings = createClass({
}, {}) }, {})
}); });
}, },
dismiss : function(){
localStorage.setItem(DISMISS_KEY, true);
this.checkWarnings();
},
render : function(){ render : function(){
if(_.isEmpty(this.state.warnings)) return null; if(_.isEmpty(this.state.warnings)) return null;
return <div className='renderWarnings'> const DISMISS_KEY = 'dismiss_render_warning';
<i className='fas fa-times dismiss' onClick={this.dismiss}/> const DISMISS_TEXT = <i className='fas fa-times dismiss' />;
return <Dialog className='renderWarnings' dismissKey={DISMISS_KEY} closeText={DISMISS_TEXT}>
<i className='fas fa-exclamation-triangle ohno' /> <i className='fas fa-exclamation-triangle ohno' />
<h3>Render Warnings</h3> <h3>Render Warnings</h3>
<small>If this homebrew is rendering badly if might be because of the following:</small> <small>If this homebrew is rendering badly if might be because of the following:</small>
<ul>{_.values(this.state.warnings)}</ul> <ul>{_.values(this.state.warnings)}</ul>
</div>; </Dialog>;
} }
}); });

View File

@@ -1,53 +1,48 @@
.renderWarnings{ .renderWarnings {
position : relative; position : relative;
float : right; float : right;
display : inline-block;
width : 350px; width : 350px;
padding : 20px; padding : 20px;
padding-bottom : 10px; padding-bottom : 10px;
padding-left : 85px; padding-left : 85px;
margin-bottom : 10px; margin-bottom : 10px;
background-color : @yellow;
color : white; color : white;
a{ background-color : @yellow;
font-weight : 800; border : none;
} a { font-weight : 800; }
i.ohno{ i.ohno {
position : absolute; position : absolute;
top : 24px; top : 24px;
left : 24px; left : 24px;
opacity : 0.8;
font-size : 2.5em; font-size : 2.5em;
opacity : 0.8;
} }
i.dismiss{ button.dismiss {
position : absolute; position : absolute;
top : 10px; top : 10px;
right : 10px; right : 10px;
cursor : pointer; cursor : pointer;
background-color : transparent;
opacity : 0.6; opacity : 0.6;
&:hover{ &:hover { opacity : 1; }
opacity : 1;
} }
} small {
small{
opacity : 0.7;
font-size : 0.6em; font-size : 0.6em;
opacity : 0.7;
} }
h3{ h3 {
font-size : 1.1em; font-size : 1.1em;
font-weight : 800; font-weight : 800;
} }
ul{ ul {
margin-top : 15px; margin-top : 15px;
font-size : 0.8em; font-size : 0.8em;
list-style-position : outside; list-style-position : outside;
list-style-type : disc; list-style-type : disc;
li{ li {
font-size : 0.8em; font-size : 0.8em;
line-height : 1.6em; line-height : 1.6em;
em{ em { font-weight : 800; }
font-weight : 800;
}
} }
} }
} }

View File

@@ -8,6 +8,7 @@
@import (less) './themes/fonts/iconFonts/diceFont.less'; @import (less) './themes/fonts/iconFonts/diceFont.less';
@import (less) './themes/fonts/iconFonts/elderberryInn.less'; @import (less) './themes/fonts/iconFonts/elderberryInn.less';
@import (less) './themes/fonts/iconFonts/gameIcons.less'; @import (less) './themes/fonts/iconFonts/gameIcons.less';
@import (less) './themes/fonts/iconFonts/fontAwesome.less';
@keyframes sourceMoveAnimation { @keyframes sourceMoveAnimation {
50% {background-color: red; color: white;} 50% {background-color: red; color: white;}

View File

@@ -102,7 +102,7 @@ const mustacheSpans = {
start(src) { return src.match(/{{[^{]/)?.index; }, // Hint to Marked.js to stop and check for a match start(src) { return src.match(/{{[^{]/)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) { tokenizer(src, tokens) {
const completeSpan = /^{{[^\n]*}}/; // Regex for the complete token const completeSpan = /^{{[^\n]*}}/; // Regex for the complete token
const inlineRegex = /{{(?=((?:[:=](?:"['\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"=':{}\s]*)*))\1 *|}}/g; const inlineRegex = /{{(?=((?:[:=](?:"['\w,\-()#%=?. ]*"|[\w\-()#%.]*)|[^"=':{}\s]*)*))\1 *|}}/g;
const match = completeSpan.exec(src); const match = completeSpan.exec(src);
if(match) { if(match) {
//Find closing delimiter //Find closing delimiter
@@ -159,7 +159,7 @@ const mustacheDivs = {
start(src) { return src.match(/\n *{{[^{]/m)?.index; }, // Hint to Marked.js to stop and check for a match start(src) { return src.match(/\n *{{[^{]/m)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) { tokenizer(src, tokens) {
const completeBlock = /^ *{{[^\n}]* *\n.*\n *}}/s; // Regex for the complete token const completeBlock = /^ *{{[^\n}]* *\n.*\n *}}/s; // Regex for the complete token
const blockRegex = /^ *{{(?=((?:[:=](?:"['\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"=':{}\s]*)*))\1 *$|^ *}}$/gm; const blockRegex = /^ *{{(?=((?:[:=](?:"['\w,\-()#%=?. ]*"|[\w\-()#%.]*)|[^"=':{}\s]*)*))\1 *$|^ *}}$/gm;
const match = completeBlock.exec(src); const match = completeBlock.exec(src);
if(match) { if(match) {
//Find closing delimiter //Find closing delimiter
@@ -214,7 +214,7 @@ const mustacheInjectInline = {
level : 'inline', level : 'inline',
start(src) { return src.match(/ *{[^{\n]/)?.index; }, // Hint to Marked.js to stop and check for a match start(src) { return src.match(/ *{[^{\n]/)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) { tokenizer(src, tokens) {
const inlineRegex = /^ *{(?=((?:[:=](?:"['\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"=':{}\s]*)*))\1}/g; const inlineRegex = /^ *{(?=((?:[:=](?:"['\w,\-()#%=?. ]*"|[\w\-()#%.]*)|[^"=':{}\s]*)*))\1}/g;
const match = inlineRegex.exec(src); const match = inlineRegex.exec(src);
if(match) { if(match) {
const lastToken = tokens[tokens.length - 1]; const lastToken = tokens[tokens.length - 1];
@@ -265,7 +265,7 @@ const mustacheInjectBlock = {
level : 'block', level : 'block',
start(src) { return src.match(/\n *{[^{\n]/m)?.index; }, // Hint to Marked.js to stop and check for a match start(src) { return src.match(/\n *{[^{\n]/m)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) { tokenizer(src, tokens) {
const inlineRegex = /^ *{(?=((?:[:=](?:"['\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"=':{}\s]*)*))\1}/ym; const inlineRegex = /^ *{(?=((?:[:=](?:"['\w,\-()#%=?. ]*"|[\w\-()#%.]*)|[^"=':{}\s]*)*))\1}/ym;
const match = inlineRegex.exec(src); const match = inlineRegex.exec(src);
if(match) { if(match) {
const lastToken = tokens[tokens.length - 1]; const lastToken = tokens[tokens.length - 1];
@@ -771,7 +771,8 @@ const processStyleTags = (string)=>{
const attributes = _.remove(tags, (tag)=>(tag.includes('='))).map((tag)=>tag.replace(/="?([^"]*)"?/g, '="$1"')) const attributes = _.remove(tags, (tag)=>(tag.includes('='))).map((tag)=>tag.replace(/="?([^"]*)"?/g, '="$1"'))
?.filter((attr)=>!attr.startsWith('class="') && !attr.startsWith('style="') && !attr.startsWith('id="')) ?.filter((attr)=>!attr.startsWith('class="') && !attr.startsWith('style="') && !attr.startsWith('id="'))
.reduce((obj, attr)=>{ .reduce((obj, attr)=>{
let [key, value] = attr.split('='); const index = attr.indexOf('=');
let [key, value] = [attr.substring(0, index), attr.substring(index + 1)];
value = value.replace(/"/g, ''); value = value.replace(/"/g, '');
obj[key] = value; obj[key] = value;
return obj; return obj;
@@ -786,14 +787,17 @@ const processStyleTags = (string)=>{
}; };
}; };
//Given a string representing an HTML element, extract all of its properties (id, class, style, and other attributes)
const extractHTMLStyleTags = (htmlString)=>{ const extractHTMLStyleTags = (htmlString)=>{
const id = htmlString.match(/id="([^"]*)"/)?.[1] || null; const firstElementOnly = htmlString.split('>')[0];
const classes = htmlString.match(/class="([^"]*)"/)?.[1] || null; const id = firstElementOnly.match(/id="([^"]*)"/)?.[1] || null;
const styles = htmlString.match(/style="([^"]*)"/)?.[1] || null; const classes = firstElementOnly.match(/class="([^"]*)"/)?.[1] || null;
const attributes = htmlString.match(/[a-zA-Z]+="[^"]*"/g) const styles = firstElementOnly.match(/style="([^"]*)"/)?.[1] || null;
const attributes = firstElementOnly.match(/[a-zA-Z]+="[^"]*"/g)
?.filter((attr)=>!attr.startsWith('class="') && !attr.startsWith('style="') && !attr.startsWith('id="')) ?.filter((attr)=>!attr.startsWith('class="') && !attr.startsWith('style="') && !attr.startsWith('id="'))
.reduce((obj, attr)=>{ .reduce((obj, attr)=>{
let [key, value] = attr.split('='); const index = attr.indexOf('=');
let [key, value] = [attr.substring(0, index), attr.substring(index + 1)];
value = value.replace(/"/g, ''); value = value.replace(/"/g, '');
obj[key] = value; obj[key] = value;
return obj; return obj;

View File

@@ -47,8 +47,8 @@ const Nav = {
color : null color : null
}; };
}, },
handleClick : function(){ handleClick : function(e){
this.props.onClick(); this.props.onClick(e);
}, },
render : function(){ render : function(){
const classes = cx('navItem', this.props.color, this.props.className); const classes = cx('navItem', this.props.color, this.props.className);

View File

@@ -333,11 +333,30 @@ describe('Injection: When an injection tag follows an element', ()=>{
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p><span class="inline-block" style="color:red;">text</span>{background:blue}</p>'); expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p><span class="inline-block" style="color:red;">text</span>{background:blue}</p>');
}); });
it('Renders an parent and child element, each modified by an injector', function() {
const source = dedent`**bolded text**{color:red}
{color:blue}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p style="color:blue;"><strong style="color:red;">bolded text</strong></p>');
});
it('Renders an image with added attributes', function() { it('Renders an image with added attributes', function() {
const source = `![homebrew mug](https://i.imgur.com/hMna6G0.png) {position:absolute,bottom:20px,left:130px,width:220px,a="b and c",d=e}`; const source = `![homebrew mug](https://i.imgur.com/hMna6G0.png) {position:absolute,bottom:20px,left:130px,width:220px,a="b and c",d=e}`;
const rendered = Markdown.render(source).trimReturns(); const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><img style="position:absolute; bottom:20px; left:130px; width:220px;" src="https://i.imgur.com/hMna6G0.png" alt="homebrew mug" a="b and c" d="e"></p>`); expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><img style="position:absolute; bottom:20px; left:130px; width:220px;" src="https://i.imgur.com/hMna6G0.png" alt="homebrew mug" a="b and c" d="e"></p>`);
}); });
it('Renders an image with "=" in the url, and added attributes', function() {
const source = `![homebrew mug](https://i.imgur.com/hMna6G0.png?auth=12345&height=1024) {position:absolute,bottom:20px,left:130px,width:220px,a="b and c",d=e}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><img style="position:absolute; bottom:20px; left:130px; width:220px;" src="https://i.imgur.com/hMna6G0.png?auth=12345&height=1024" alt="homebrew mug" a="b and c" d="e"></p>`);
});
it('Renders an image and added attributes with "=" in the value, ', function() {
const source = `![homebrew mug](https://i.imgur.com/hMna6G0.png) {position:absolute,bottom:20px,left:130px,width:220px,a="b and c",d=e,otherUrl="url?auth=12345"}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><img style="position:absolute; bottom:20px; left:130px; width:220px;" src="https://i.imgur.com/hMna6G0.png" alt="homebrew mug" a="b and c" d="e" otherUrl="url?auth=12345"></p>`);
});
}); });
describe('and that element is a block', ()=>{ describe('and that element is a block', ()=>{

View File

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

View File

@@ -23,7 +23,41 @@ module.exports = [
{ {
name : 'Table of Contents', name : 'Table of Contents',
icon : 'fas fa-book', icon : 'fas fa-book',
gen : TableOfContentsGen gen : TableOfContentsGen,
experimental : true,
subsnippets : [
{
name : 'Table of Contents',
icon : 'fas fa-book',
gen : TableOfContentsGen,
experimental : true
},
{
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 : 'Index', name : 'Index',
@@ -315,7 +349,7 @@ module.exports = [
/* Ink Friendly */ /* Ink Friendly */
*:is(.page,.monster,.note,.descriptive) { *:is(.page,.monster,.note,.descriptive) {
background : white !important; background : white !important;
filter : drop-shadow(0px 0px 3px #888) !important; box-shadow : 1px 4px 14px #888 !important;
} }
.page img { .page img {

View File

@@ -2,77 +2,68 @@ const _ = require('lodash');
const dedent = require('dedent-tabs').default; const dedent = require('dedent-tabs').default;
const getTOC = (pages)=>{ const getTOC = (pages)=>{
const add1 = (title, page)=>{
res.push({ const recursiveAdd = (title, page, targetDepth, child, curDepth=0)=>{
if(curDepth > 5) return; // Something went wrong.
if(curDepth == targetDepth) {
child.push({
title : title, title : title,
page : page + 1, page : page,
children : [] children : []
}); });
}; } else {
const add2 = (title, page)=>{ if(child.length == 0) {
if(!_.last(res)) add1(null, page); child.push({
_.last(res).children.push({ title : null,
title : title, page : page,
page : page + 1,
children : []
});
};
const add3 = (title, page)=>{
if(!_.last(res)) add1(null, page);
if(!_.last(_.last(res).children)) add2(null, page);
_.last(_.last(res).children).children.push({
title : title,
page : page + 1,
children : [] children : []
}); });
}
recursiveAdd(title, page, targetDepth, _.last(child).children, curDepth+1,);
}
}; };
const res = []; const res = [];
_.each(pages, (page, pageNum)=>{
if(!page.includes('{{frontCover}}') && !page.includes('{{insideCover}}') && !page.includes('{{partCover}}') && !page.includes('{{backCover}}')) { const iframe = document.getElementById('BrewRenderer');
const lines = page.split('\n'); const iframeDocument = iframe.contentDocument || iframe.contentWindow.document;
_.each(lines, (line)=>{ const headings = iframeDocument.querySelectorAll('h1, h2, h3, h4, h5, h6');
if(_.startsWith(line, '# ')){ const headerDepth = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6'];
const title = line.replace('# ', '');
add1(title, pageNum); _.each(headings, (heading)=>{
} const onPage = parseInt(heading.closest('.page').id?.replace(/^p/, ''));
if(_.startsWith(line, '## ')){ const ToCExclude = getComputedStyle(heading).getPropertyValue('--TOC');
const title = line.replace('## ', '');
add2(title, pageNum); if(ToCExclude != 'exclude') {
} recursiveAdd(heading.textContent.trim(), onPage, headerDepth.indexOf(heading.tagName), res);
if(_.startsWith(line, '### ')){
const title = line.replace('### ', '');
add3(title, pageNum);
}
});
} }
}); });
return res; return res;
}; };
const ToCIterate = (entries, curDepth=0)=>{
const levelPad = ['- ###', ' - ####', ' - ', ' - ', ' - ', ' - '];
const toc = [];
if(entries.title !== null){
toc.push(`${levelPad[curDepth]} [{{ ${entries.title}}}{{ ${entries.page}}}](#p${entries.page})`);
}
if(entries.children.length) {
_.each(entries.children, (entry, idx)=>{
const children = ToCIterate(entry, entry.title == null ? curDepth : curDepth+1);
if(children.length) {
toc.push(...children);
}
});
}
return toc;
};
module.exports = function(props){ module.exports = function(props){
const pages = props.brew.text.split('\\page'); const pages = props.brew.text.split('\\page');
const TOC = getTOC(pages); const TOC = getTOC(pages);
const markdown = _.reduce(TOC, (r, g1, idx1)=>{ const markdown = _.reduce(TOC, (r, g1, idx1)=>{
if(g1.title !== null) { r.push(ToCIterate(g1).join('\n'));
r.push(`- ### [{{ ${g1.title}}}{{ ${g1.page}}}](#p${g1.page})`);
}
if(g1.children.length){
_.each(g1.children, (g2, idx2)=>{
if(g2.title !== null) {
r.push(` - #### [{{ ${g2.title}}}{{ ${g2.page}}}](#p${g2.page})`);
}
if(g2.children.length){
_.each(g2.children, (g3, idx3)=>{
if(g2.title !== null) {
r.push(` - [{{ ${g3.title}}}{{ ${g3.page}}}](#p${g3.page})`);
} else { // Don't over-indent if no level-2 parent entry
r.push(` - [{{ ${g3.title}}}{{ ${g3.page}}}](#p${g3.page})`);
}
});
}
});
}
return r; return r;
}, []).join('\n'); }, []).join('\n');

View File

@@ -786,6 +786,39 @@
// ***************************** // *****************************
// * TABLE OF CONTENTS // * TABLE OF CONTENTS
// *****************************/ // *****************************/
// Default Exclusions
// Anything not exlcuded is included, default Headers are H1, H2, and H3.
h4,
h5,
h6,
.page:has(.frontCover),
.page:has(.backCover),
.page:has(.insideCover),
.monster,
.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; }
.tocIncludeH1 h1 {--TOC: include; }
.tocIncludeH2 h2 {--TOC: include; }
.tocIncludeH3 h3 {--TOC: include; }
.tocIncludeH4 h4 {--TOC: include; }
.tocIncludeH5 h5 {--TOC: include; }
.tocIncludeH6 h6 {--TOC: include; }
.page:has(.partCover) {
--TOC: exclude;
& h1 {
--TOC: include;
}
}
.page { .page {
&:has(.toc)::after { display : none; } &:has(.toc)::after { display : none; }
.toc { .toc {
@@ -874,6 +907,10 @@
.page h1 + * { margin-top : 0; } .page h1 + * { margin-top : 0; }
.page .descriptive.wide + * {
margin-top: 0;
}
//***************************** //*****************************
// * RUNE TABLE // * RUNE TABLE
// *****************************/ // *****************************/

View File

@@ -3,6 +3,7 @@
@import (less) './themes/fonts/iconFonts/elderberryInn.less'; @import (less) './themes/fonts/iconFonts/elderberryInn.less';
@import (less) './themes/fonts/iconFonts/diceFont.less'; @import (less) './themes/fonts/iconFonts/diceFont.less';
@import (less) './themes/fonts/iconFonts/gameIcons.less'; @import (less) './themes/fonts/iconFonts/gameIcons.less';
@import (less) './themes/fonts/iconFonts/fontAwesome.less';
:root { :root {
//Colors //Colors

View File

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

View File

@@ -58,7 +58,7 @@
background-color: rgba(35,153,153,0.5); background-color: rgba(35,153,153,0.5);
} }
.pageLine { .pageLine {
background-color: rgba(255,255,255,0.75); background-color: rgba(255,255,255,0.5);
& ~ pre.CodeMirror-line { & ~ pre.CodeMirror-line {
color: black; color: black;
} }

View File

@@ -74,8 +74,9 @@
@font-face { @font-face {
font-family: SolberaImitationRemake; //Tweaked 5e version font-family: SolberaImitationRemake; //Tweaked 5e version
src: url('../../../fonts/5e/Solbera Imitation Tweak.woff2'); src: url('../../../fonts/5e/Solbera Imitation Tweak.woff2');
font-weight: normal; font-weight: 100 1000;
font-style: normal; font-style: normal;
font-style: italic;
} }
/* Cover Page */ /* Cover Page */

View File

@@ -7,7 +7,7 @@
} }
.df { .df {
display : inline-block; display : inline;
font-family : 'DiceFont'; font-family : 'DiceFont';
font-style : normal; font-style : normal;
font-weight : normal; font-weight : normal;
@@ -16,8 +16,11 @@
text-decoration : inherit; text-decoration : inherit;
text-transform : none; text-transform : none;
text-rendering : optimizeLegibility; text-rendering : optimizeLegibility;
-moz-osx-font-smoothing : grayscale;
/* Better Font Rendering =========== */
-webkit-font-smoothing : antialiased; -webkit-font-smoothing : antialiased;
-moz-osx-font-smoothing : grayscale;
&.F::before { content : '\f190'; } &.F::before { content : '\f190'; }
&.F-minus::before { content : '\f191'; } &.F-minus::before { content : '\f191'; }
&.F-plus::before { content : '\f192'; } &.F-plus::before { content : '\f192'; }

View File

@@ -7,15 +7,16 @@
} }
.ei { .ei {
display : inline-block; display : inline;
margin-right : 3px;
font-family : 'Elderberry-Inn'; font-family : 'Elderberry-Inn';
line-height : 1; line-height : 1;
vertical-align : baseline; vertical-align : baseline;
-moz-osx-font-smoothing : grayscale;
-webkit-font-smoothing : antialiased;
text-rendering : auto; text-rendering : auto;
/* Better Font Rendering =========== */
-webkit-font-smoothing : antialiased;
-moz-osx-font-smoothing : grayscale;
&.book::before { content : '\E900'; } &.book::before { content : '\E900'; }
&.screen::before { content : '\E901'; } &.screen::before { content : '\E901'; }

View File

@@ -0,0 +1,2 @@
/* Icon Font: Font Awesome */
.far,.fas,.fab { display : inline; }

View File

@@ -8,20 +8,16 @@
.gi { .gi {
/* use !important to prevent issues with browser extensions that change fonts */ /* use !important to prevent issues with browser extensions that change fonts */
display : inline-block; display : inline;
margin-right : 3px;
font-family : 'Game-Icons' !important; font-family : 'Game-Icons' !important;
line-height : 1; line-height : 1;
vertical-align : baseline; vertical-align : baseline;
-moz-osx-font-smoothing : grayscale;
-webkit-font-smoothing : antialiased;
text-rendering : auto; text-rendering : auto;
/* Better Font Rendering =========== */ /* Better Font Rendering =========== */
-webkit-font-smoothing : antialiased; -webkit-font-smoothing : antialiased;
-moz-osx-font-smoothing : grayscale; -moz-osx-font-smoothing : grayscale;
&.zigzag-leaf::before { content : '\e900'; } &.zigzag-leaf::before { content : '\e900'; }
&.zebra-shield::before { content : '\e901'; } &.zebra-shield::before { content : '\e901'; }
&.x-mark::before { content : '\e902'; } &.x-mark::before { content : '\e902'; }

View File

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