mirror of
https://github.com/naturalcrit/homebrewery.git
synced 2026-09-24 18:12:58 +00:00
Merge branch 'master' into coverageImprovement_helpers
This commit is contained in:
+63
-8
@@ -93,23 +93,77 @@ pre {
|
||||
## changelog
|
||||
For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery).
|
||||
|
||||
### Saturday 4/20/2026 - v3.22.0
|
||||
|
||||
### Monday 9/07/2026 - v3.23.0
|
||||
{{taskList
|
||||
##### firstmatekidd (new contributor!)
|
||||
* [x] Add nav buttons to tab order for accesibility
|
||||
* [x] Add title to brewRenderer iframe for accessibility
|
||||
* [x] Add aria labels to toolbars
|
||||
|
||||
Partially fixes issue [#3032](https://github.com/naturalcrit/homebrewery/issues/3032)
|
||||
|
||||
##### ReinaSweet (new contributor!)
|
||||
* [x] Added security to prevent scripts running in brews
|
||||
|
||||
##### Gazook89
|
||||
* [x] Lazy load brew images for faster first load
|
||||
* [x] Fix snippet menus getting cut off behind divider bar
|
||||
|
||||
Fixes issue [#3840](https://github.com/naturalcrit/homebrewery/issues/3840)
|
||||
|
||||
##### Abquintic
|
||||
* [x] Fix variables not hoisting in later pages
|
||||
|
||||
##### 5e-Cleric
|
||||
* [x] Major update to editor framework (Codemirror 6)
|
||||
Fixes issues [#3511](https://github.com/naturalcrit/homebrewery/issues/3511), [#4590](https://github.com/naturalcrit/homebrewery/issues/4590), [#4563](https://github.com/naturalcrit/homebrewery/issues/4653), [#4655](https://github.com/naturalcrit/homebrewery/issues/4655)
|
||||
* [x] Fix to Admin page tab names
|
||||
* [x] Add image preview when hovering over image syntax
|
||||
|
||||
Fixes issue [#2037](https://github.com/naturalcrit/homebrewery/issues/2037)
|
||||
|
||||
* [x] Remember editor theme across tabs
|
||||
|
||||
Fixes issue [#4858](https://github.com/naturalcrit/homebrewery/issues/4858)
|
||||
|
||||
* [x] Add ability to remove co-authors if you are the owner
|
||||
|
||||
Fixes part of issue [#4101](https://github.com/naturalcrit/homebrewery/issues/4101)
|
||||
|
||||
##### G-Ambatte
|
||||
* [x] Fix white page crash on certain browsers
|
||||
* [x] Fix editor panel shrinking when openingdev tools
|
||||
|
||||
Fixes issue [#4866](https://github.com/naturalcrit/homebrewery/issues/4866)
|
||||
|
||||
* [x] Added security to prevent scripts running in brews
|
||||
|
||||
Fixes issue [#4904](https://github.com/naturalcrit/homebrewery/issues/4904)
|
||||
|
||||
##### Abquintic, G-Ambatte, Gazook89, 5e-Cleric
|
||||
* [x] Multiple background fixes and code cleanups
|
||||
}}
|
||||
|
||||
### Wednesday 5/13/2026 - v3.22.1
|
||||
{{taskList
|
||||
##### Frederlk (new Contributor!)
|
||||
* [x] Add "share current page" to {{openSans **SHARE :fas_share_nodes:**}} menu
|
||||
|
||||
##### Calculuschild, 5e-Cleric
|
||||
* [x] Fix brews triggering unneeded save when loaded
|
||||
|
||||
##### G-Ambatte
|
||||
* [x] Fix brews with `$` in text getting out of sync on save
|
||||
|
||||
##### 5e-Cleric, Gazook89
|
||||
* [x] Fix various issues with Codemirror 6
|
||||
|
||||
Fixes issues [#4771](https://github.com/naturalcrit/homebrewery/issues/4771), [#4583](https://github.com/naturalcrit/homebrewery/issues/4783)
|
||||
}}
|
||||
|
||||
\page
|
||||
|
||||
### Saturday 4/04/2026 - v3.21.0
|
||||
|
||||
{{taskList
|
||||
##### Gazook89
|
||||
* [x] Allow custom {{openSans **:fas_table_list: SNIPPETS**}} to be inserted mid-line
|
||||
* [x] Allow custom {{openSans **:fas_table_list: SNIPPETS**}} dropdown to be inserted mid-line
|
||||
|
||||
##### abquintic
|
||||
* [x] Move example snippet images out of imgur (for folks without imgur access)
|
||||
@@ -131,6 +185,7 @@ Fixes issues [#543](https://github.com/naturalcrit/homebrewery/issues/543), [#24
|
||||
* [x] Multiple other backend fixes and refactors
|
||||
}}
|
||||
|
||||
|
||||
### Friday 1/11/2026 - v3.20.1
|
||||
|
||||
{{taskList
|
||||
@@ -326,7 +381,7 @@ Fixes issue [#1729](https://github.com/naturalcrit/homebrewery/issues/1729)
|
||||
Fixes issue [#4079](https://github.com/naturalcrit/homebrewery/issues/4079)
|
||||
|
||||
##### Calculuschild
|
||||
* [x] `꞉꞉꞉꞉` now produces `<br>` instead of a `<div>`
|
||||
* [x] `꞉꞉꞉꞉` now produces `<br>` instead of a `<div>`
|
||||
* [x] Fix typos in tables freezing the editor
|
||||
|
||||
Fixes issue [#4059](https://github.com/naturalcrit/homebrewery/issues/4059)
|
||||
|
||||
@@ -43,9 +43,9 @@ const themes = { default: defaultCM5Theme, ...cm5Themes, darkbrewery };
|
||||
const themeCompartment = new Compartment();
|
||||
const highlightCompartment = new Compartment();
|
||||
|
||||
import { generalKeymap, markdownKeymap } from './extensions/customKeyMaps.js';
|
||||
import { generalKeymap, markdownKeymap, cssKeymap, formatCSS } from './extensions/customKeyMaps.js';
|
||||
import foldOnPages from './extensions/customFolding.js';
|
||||
import { customHighlightPlugin, customHighlightStyle } from './extensions/customHighlight.js';
|
||||
import { customHighlightStyle , customHighlightPlugin } from './extensions/customHighlight.js';
|
||||
import { legacyCustomHighlightStyle } from './extensions/legacyCustomHighlight.js';
|
||||
|
||||
const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
|
||||
@@ -186,7 +186,7 @@ const CodeEditor = forwardRef(
|
||||
//keyboard shortcut
|
||||
keymap.of([...defaultKeymap, foldKeymap, ...searchKeymap]),
|
||||
generalKeymap,
|
||||
...(tab !== 'brewStyles' ? [markdownKeymap] : []),
|
||||
...(tab === 'brewStyles' ? [cssKeymap] : [markdownKeymap]),
|
||||
|
||||
//multiple cursors and selections
|
||||
drawSelection(),
|
||||
@@ -356,6 +356,8 @@ const CodeEditor = forwardRef(
|
||||
}, 400);
|
||||
},
|
||||
|
||||
formatCode : ()=>formatCSS(viewRef.current),
|
||||
|
||||
undo : ()=>undo(viewRef.current),
|
||||
redo : ()=>redo(viewRef.current),
|
||||
|
||||
|
||||
@@ -15,6 +15,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideacross {
|
||||
0% {
|
||||
bottom: -200px;
|
||||
left: -200px;
|
||||
}
|
||||
|
||||
100% {
|
||||
bottom:100%;
|
||||
left:100%;
|
||||
}
|
||||
}
|
||||
|
||||
:where(.codeEditor) {
|
||||
width : 100%;
|
||||
height : calc(100% - 25px);
|
||||
@@ -23,6 +35,27 @@
|
||||
.cm-editor {
|
||||
height : 100%;
|
||||
outline : none !important;
|
||||
|
||||
&.cm-flash {
|
||||
position:relative;
|
||||
&::after {
|
||||
position:absolute;
|
||||
content:'';
|
||||
bottom:-200px;
|
||||
left:-200px;
|
||||
translate:-50%;
|
||||
width:200%;
|
||||
height:100px;
|
||||
background:linear-gradient(0deg, #89eafc00 0px, #89ebfc8e 50px, #89eafc00 100px, transparent);
|
||||
display:block;
|
||||
|
||||
rotate:30deg;
|
||||
|
||||
@media screen and (prefers-reduced-motion: no-preference) {
|
||||
animation: .5s linear 1 slideacross ;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.brewSnippets .cm-snippetLine,
|
||||
|
||||
@@ -350,11 +350,10 @@ class ImageWidget extends WidgetType {
|
||||
|
||||
toDOM() {
|
||||
const img = document.createElement('img');
|
||||
img.loading = "lazy";
|
||||
img.loading = 'lazy';
|
||||
img.className = 'cm-preview';
|
||||
img.src = this.url;
|
||||
|
||||
|
||||
img.onerror = ()=>{
|
||||
img.src = 'client/icons/broken-image.jpg';
|
||||
};
|
||||
|
||||
@@ -3,7 +3,60 @@ import { keymap } from '@codemirror/view';
|
||||
import { undo, redo, indentMore, indentLess, deleteLine } from '@codemirror/commands';
|
||||
import { EditorSelection } from '@codemirror/state';
|
||||
import { Prec } from '@codemirror/state';
|
||||
import * as prettier from 'prettier/standalone';
|
||||
import * as postcssPlugin from 'prettier/plugins/postcss';
|
||||
|
||||
export async function formatCSS(view) {
|
||||
try {
|
||||
const { from, to, empty } = view.state.selection.main;
|
||||
const fullDoc = view.state.doc.toString();
|
||||
const selection = view.state.doc.sliceString(from, to);
|
||||
const code = empty ? fullDoc : selection;
|
||||
|
||||
let formatted = await prettier.format(code, {
|
||||
parser: 'css',
|
||||
plugins: [postcssPlugin],
|
||||
|
||||
// formatting options
|
||||
tabWidth: 2,
|
||||
useTabs: false,
|
||||
printWidth: 100,
|
||||
singleQuote: false,
|
||||
trailingComma: 'all',
|
||||
bracketSpacing: true,
|
||||
endOfLine: 'lf'
|
||||
});
|
||||
|
||||
//format manually single declaration rules to span one line.
|
||||
//Prettier can't do it by default, this is crude but it works
|
||||
formatted = formatted.replace(
|
||||
/([^{]+)\{\s*\n\s*([^;\n]+:[^;\n]+;)\s*\n\s*\}(\s*)/g,
|
||||
(_, selector, decl, whitespace) =>
|
||||
`${selector} { ${decl.trim()} }${whitespace}`
|
||||
);
|
||||
if(formatted === code) return true;
|
||||
|
||||
const dom = view.dom;
|
||||
dom.classList.add('cm-flash');
|
||||
|
||||
setTimeout(()=>{
|
||||
dom.classList.remove('cm-flash');
|
||||
|
||||
view.dispatch({
|
||||
changes : {
|
||||
from : empty ? 0 : from,
|
||||
to : empty ? view.state.doc.length : to,
|
||||
insert : formatted
|
||||
}
|
||||
});
|
||||
|
||||
}, 500);
|
||||
} catch (err) {
|
||||
console.error('Error formatting css: ', err);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
const insertTab = (view)=>{
|
||||
// If any selection spans multiple lines, delegates to CodeMirror's indentMore
|
||||
// Otherwise inserts two spaces at each cursor/selection
|
||||
@@ -30,7 +83,7 @@ const insertTab = (view)=>{
|
||||
changes,
|
||||
selection : EditorSelection.create(
|
||||
view.state.selection.ranges.map((range)=>EditorSelection.cursor(
|
||||
mappedChanges.changes.mapPos(range.from, 1) + 2
|
||||
mappedChanges.changes.mapPos(range.from, -1) + 2
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -188,6 +241,11 @@ export const generalKeymap = Prec.high(keymap.of([
|
||||
{ key: 'Mod-d', run: deleteLine }, //annoyingly overrides "selectNextOccurrence" because users asked
|
||||
]));
|
||||
|
||||
export const cssKeymap = Prec.highest(keymap.of([
|
||||
{ key: 'Mod-Shift-f', run: formatCSS },
|
||||
{ key: 'Alt-Shift-f', run: formatCSS },
|
||||
]));
|
||||
|
||||
export const markdownKeymap = Prec.highest(keymap.of([
|
||||
|
||||
{ key: 'Mod-b', run: wrapSelection('**', '**') }, // makeBold
|
||||
|
||||
@@ -6,7 +6,7 @@ import React, { useState, useRef, useMemo, useEffect } from 'react';
|
||||
import _ from 'lodash';
|
||||
|
||||
import MarkdownLegacy from '@shared/markdownLegacy.js';
|
||||
import Markdown from '@shared/markdown.js';
|
||||
import { hbfm } from 'hbmarkedwrapper';
|
||||
import ErrorBar from './errorBar/errorBar.jsx';
|
||||
import ToolBar from './toolBar/toolBar.jsx';
|
||||
|
||||
@@ -23,7 +23,6 @@ import safeHTML from './safeHTML.js';
|
||||
const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
|
||||
const PAGEBREAK_REGEX_LEGACY = /\\page(?:break)?/m;
|
||||
const COLUMNBREAK_REGEX_LEGACY = /\\column(:?break)?/m;
|
||||
const PAGE_HEIGHT = 1056;
|
||||
|
||||
const TOOLBAR_STATE_KEY = 'HB_renderer_toolbarState';
|
||||
|
||||
@@ -47,31 +46,25 @@ const BrewPage = (props)=>{
|
||||
};
|
||||
const pageRef = useRef(null);
|
||||
const cleanText = safeHTML(props.contents);
|
||||
const pageNum = props.index + 1;
|
||||
|
||||
useEffect(()=>{
|
||||
if(!pageRef.current) return;
|
||||
|
||||
// Observer for tracking pages within the `.pages` div
|
||||
// Observer for tracking which pages are at least 30% visible in the iframe
|
||||
const visibleObserver = new IntersectionObserver(
|
||||
(entries)=>{
|
||||
entries.forEach((entry)=>{
|
||||
if(entry.isIntersecting)
|
||||
props.onVisibilityChange(props.index + 1, true, false); // add page to array of visible pages.
|
||||
else
|
||||
props.onVisibilityChange(props.index + 1, false, false);
|
||||
});
|
||||
},
|
||||
(entries)=>entries.forEach((entry)=>{
|
||||
props.onVisibilityChange(pageNum, entry.isIntersecting, false); // add page to array of visible pages.
|
||||
}),
|
||||
{ threshold: .3, rootMargin: '0px 0px 0px 0px' } // detect when >30% of page is within bounds.
|
||||
);
|
||||
|
||||
// Observer for tracking the page at the center of the iframe.
|
||||
const centerObserver = new IntersectionObserver(
|
||||
(entries)=>{
|
||||
entries.forEach((entry)=>{
|
||||
if(entry.isIntersecting)
|
||||
props.onVisibilityChange(props.index + 1, true, true); // Set this page as the center page
|
||||
});
|
||||
},
|
||||
(entries)=>entries.forEach((entry)=>{
|
||||
if(entry.isIntersecting)
|
||||
props.onVisibilityChange(pageNum, true, true); // Set this page as the center page
|
||||
}),
|
||||
{ threshold: 0, rootMargin: '-50% 0px -50% 0px' } // Detect when the page is at the center
|
||||
);
|
||||
|
||||
@@ -92,7 +85,7 @@ const BrewPage = (props)=>{
|
||||
|
||||
//v=====--------------------< Brew Renderer Component >-------------------=====v//
|
||||
let renderedPages = [];
|
||||
let pageTemplates = [];
|
||||
const pageTemplates = [];
|
||||
let rawPages = [];
|
||||
|
||||
const BrewRenderer = (props)=>{
|
||||
@@ -100,22 +93,21 @@ const BrewRenderer = (props)=>{
|
||||
text : '',
|
||||
style : '',
|
||||
renderer : 'legacy',
|
||||
theme : '5ePHB',
|
||||
lang : '',
|
||||
errors : [],
|
||||
currentEditorCursorPageNum : 1,
|
||||
currentEditorViewPageNum : 1,
|
||||
currentBrewRendererPageNum : 1,
|
||||
themeBundle : {},
|
||||
onPageChange : ()=>{},
|
||||
...props
|
||||
};
|
||||
|
||||
const [visiblePages, setVisiblePages] = useState([]);
|
||||
const [centerPage , setCenterPage ] = useState(1);
|
||||
|
||||
const [state, setState] = useState({
|
||||
isMounted : false,
|
||||
visibility : 'hidden',
|
||||
visiblePages : [],
|
||||
centerPage : 1
|
||||
isMounted : false,
|
||||
visibility : 'hidden'
|
||||
});
|
||||
|
||||
const [displayOptions, setDisplayOptions] = useState({
|
||||
@@ -135,9 +127,7 @@ const BrewRenderer = (props)=>{
|
||||
|
||||
const [headerState, setHeaderState] = useState(false);
|
||||
|
||||
const mainRef = useRef(null);
|
||||
const pagesRef = useRef(null);
|
||||
const urlRef = useRef('');
|
||||
|
||||
if(props.renderer == 'legacy') {
|
||||
rawPages = props.text.split(PAGEBREAK_REGEX_LEGACY);
|
||||
@@ -146,20 +136,16 @@ const BrewRenderer = (props)=>{
|
||||
}
|
||||
|
||||
const handlePageVisibilityChange = (pageNum, isVisible, isCenter)=>{
|
||||
setState((prevState)=>{
|
||||
const updatedVisiblePages = new Set(prevState.visiblePages);
|
||||
if(!isCenter)
|
||||
isVisible ? updatedVisiblePages.add(pageNum) : updatedVisiblePages.delete(pageNum);
|
||||
|
||||
return {
|
||||
...prevState,
|
||||
visiblePages : [...updatedVisiblePages].sort((a, b)=>a - b),
|
||||
centerPage : isCenter ? pageNum : prevState.centerPage
|
||||
};
|
||||
setVisiblePages((prev)=>{
|
||||
const updatedVisiblePages = new Set(prev);
|
||||
isVisible ? updatedVisiblePages.add(pageNum) : updatedVisiblePages.delete(pageNum);
|
||||
return [...updatedVisiblePages].sort((a, b)=>a - b);
|
||||
});
|
||||
|
||||
if(isCenter)
|
||||
if(isCenter) {
|
||||
setCenterPage(pageNum);
|
||||
props.onPageChange(pageNum);
|
||||
}
|
||||
};
|
||||
|
||||
const isInView = (index)=>{
|
||||
@@ -203,7 +189,7 @@ const BrewRenderer = (props)=>{
|
||||
return <BrewPage className='page phb' index={index} key={index} contents={html} style={styles} onVisibilityChange={handlePageVisibilityChange} />;
|
||||
} else {
|
||||
if(pageText.startsWith('\\page')) {
|
||||
const firstLineTokens = Markdown.marked.lexer(pageText.split('\n', 1)[0])[0].tokens;
|
||||
const firstLineTokens = hbfm.marked.lexer(pageText.split('\n', 1)[0])[0].tokens;
|
||||
const injectedTags = firstLineTokens?.find((obj)=>obj.injectedTags !== undefined)?.injectedTags;
|
||||
if(injectedTags) {
|
||||
styles = { ...styles, ...injectedTags.styles };
|
||||
@@ -231,7 +217,7 @@ const BrewRenderer = (props)=>{
|
||||
// DO NOT REMOVE!!! REQUIRED FOR BACKWARDS COMPATIBILITY WITH NON-UPGRADABLE VERSIONS OF CHROME.
|
||||
pageText += `\n\n \n\\column\n `; //Artificial column break at page end to emulate column-fill:auto (until `wide` is used, when column-fill:balance will reappear)
|
||||
|
||||
const html = Markdown.render(pageText, index);
|
||||
const html = hbfm.render(pageText, index);
|
||||
|
||||
return <BrewPage className={classes} index={index} key={index} contents={html} style={styles} attributes={attributes} onVisibilityChange={handlePageVisibilityChange} />;
|
||||
}
|
||||
@@ -341,19 +327,19 @@ const BrewRenderer = (props)=>{
|
||||
: null}
|
||||
|
||||
<ErrorBar errors={props.errors} />
|
||||
<div className='popups' ref={mainRef}>
|
||||
<div className='popups'>
|
||||
<RenderWarnings />
|
||||
<NotificationPopup />
|
||||
</div>
|
||||
|
||||
<ToolBar displayOptions={displayOptions} onDisplayOptionsChange={handleDisplayOptionsChange} visiblePages={state.visiblePages.length > 0 ? state.visiblePages : [state.centerPage]} totalPages={rawPages.length} headerState={headerState} setHeaderState={setHeaderState}/>
|
||||
<ToolBar displayOptions={displayOptions} onDisplayOptionsChange={handleDisplayOptionsChange} visiblePages={visiblePages.length > 0 ? visiblePages : [centerPage]} totalPages={rawPages.length} headerState={headerState} setHeaderState={setHeaderState}/>
|
||||
|
||||
{/*render in iFrame so broken code doesn't crash the site.*/}
|
||||
<Frame id='BrewRenderer' title="Rendered Brew Content" initialContent={INITIAL_CONTENT}
|
||||
<Frame id='BrewRenderer' title='Rendered Brew Content' initialContent={INITIAL_CONTENT}
|
||||
style={{ width: '100%', height: '100%', visibility: state.visibility }}
|
||||
contentDidMount={frameDidMount}
|
||||
onClick={()=>{emitClick();}}
|
||||
sandbox="allow-same-origin allow-modals allow-top-navigation"
|
||||
sandbox='allow-same-origin allow-modals allow-top-navigation'
|
||||
>
|
||||
<div className='brewRenderer'
|
||||
onKeyDown={handleControlKeys}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import './notificationPopup.less';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import request from '../../utils/request-middleware.js';
|
||||
import Markdown from '@shared/markdown.js';
|
||||
import { hbfm } from 'hbmarkedwrapper';
|
||||
|
||||
import Dialog from '@components/dialog.jsx';
|
||||
|
||||
@@ -44,7 +44,7 @@ const NotificationPopup = ()=>{
|
||||
return notifications.map((notification)=>(
|
||||
<li key={notification.dismissKey} >
|
||||
<em>{notification.title}</em><br />
|
||||
<p dangerouslySetInnerHTML={{ __html: Markdown.render(notification.text) }}></p>
|
||||
<p dangerouslySetInnerHTML={{ __html: hbfm.render(notification.text) }}></p>
|
||||
</li>
|
||||
));
|
||||
};
|
||||
|
||||
@@ -111,6 +111,10 @@ const Editor = forwardRef(
|
||||
useEffect(()=>{ if(liveScroll) brewJump(currentEditorViewPageNum, false); }, [currentEditorViewPageNum, liveScroll]);
|
||||
useEffect(()=>{ if(liveScroll) brewJump(currentEditorCursorPageNum, false); }, [currentEditorCursorPageNum, liveScroll]);
|
||||
|
||||
const handleFormatCode = () => {
|
||||
codeEditor.current?.formatCode();
|
||||
};
|
||||
|
||||
const handleControlKeys = (e)=>{
|
||||
if(!(e.ctrlKey && e.metaKey && e.shiftKey)) return;
|
||||
const LEFTARROW_KEY = 37;
|
||||
@@ -319,6 +323,7 @@ const Editor = forwardRef(
|
||||
redo={redo}
|
||||
foldCode={foldCode}
|
||||
unfoldCode={unfoldCode}
|
||||
formatCode={isStyle() ? handleFormatCode : null}
|
||||
historySize={historySize()}
|
||||
currentEditorTheme={currentEditorTheme}
|
||||
updateEditorTheme={updateEditorTheme}
|
||||
|
||||
@@ -157,11 +157,11 @@ const MetadataEditor = createReactClass({
|
||||
renderPublish : function(){
|
||||
if(this.props.metadata.published){
|
||||
return <button className='unpublish' onClick={()=>this.handlePublish(false)}>
|
||||
<i className='fas fa-ban' /> unpublish
|
||||
<i className='fas fa-ban' aria-hidden='true' /> unpublish
|
||||
</button>;
|
||||
} else {
|
||||
return <button className='publish' onClick={()=>this.handlePublish(true)}>
|
||||
<i className='fas fa-globe' /> publish
|
||||
<i className='fas fa-globe' aria-hidden='true' /> publish
|
||||
</button>;
|
||||
}
|
||||
},
|
||||
@@ -359,26 +359,28 @@ const MetadataEditor = createReactClass({
|
||||
<h1>Properties Editor</h1>
|
||||
|
||||
<div className='field title'>
|
||||
<label>title</label>
|
||||
<input type='text' className='value'
|
||||
<label for='title_field'>title</label>
|
||||
<input type='text' id='title_field' className='value'
|
||||
defaultValue={this.props.metadata.title}
|
||||
onChange={(e)=>this.handleFieldChange('title', e)} />
|
||||
</div>
|
||||
<div className='field-group'>
|
||||
<div className='field-column'>
|
||||
<div className='field description'>
|
||||
<label>description</label>
|
||||
<textarea defaultValue={this.props.metadata.description} className='value'
|
||||
<label for='description_field'>description</label>
|
||||
<textarea id='description_field' defaultValue={this.props.metadata.description} className='value'
|
||||
onChange={(e)=>this.handleFieldChange('description', e)} />
|
||||
</div>
|
||||
<div className='field thumbnail'>
|
||||
<label>thumbnail</label>
|
||||
<label for='thumbnail_field'>thumbnail</label>
|
||||
<input type='text'
|
||||
id='thumbnail_field'
|
||||
defaultValue={this.props.metadata.thumbnail}
|
||||
placeholder='https://my.thumbnail.url'
|
||||
className='value'
|
||||
onChange={(e)=>this.handleFieldChange('thumbnail', e)} />
|
||||
<button className='display' onClick={this.toggleThumbnailDisplay}>
|
||||
<button className='display' onClick={this.toggleThumbnailDisplay}
|
||||
aria-label={`${this.state.showThumbnail ? 'hide thumbnail' : 'show thumbnail'}`}>
|
||||
<i className={`fas fa-caret-${this.state.showThumbnail ? 'right' : 'left'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -215,7 +215,7 @@
|
||||
}
|
||||
a {
|
||||
color:black;
|
||||
text-decoration:unset;
|
||||
text-underline-offset:0.2em;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ const Snippetbar = createReactClass({
|
||||
historySize : ()=>{},
|
||||
foldCode : ()=>{},
|
||||
unfoldCode : ()=>{},
|
||||
formatCode : ()=>{},
|
||||
updateEditorTheme : ()=>{},
|
||||
cursorPos : {},
|
||||
themeBundle : [],
|
||||
@@ -246,53 +247,57 @@ const Snippetbar = createReactClass({
|
||||
return (
|
||||
<div className='editors'>
|
||||
{this.props.view !== 'meta' && <><div className='historyTools'>
|
||||
<div className={`editorTool snippetGroup history ${this.state.historyExists ? 'active' : ''}`}
|
||||
<button className={`editorTool snippetGroup history ${this.state.historyExists ? 'active' : ''}`}
|
||||
onClick={this.toggleHistoryMenu} >
|
||||
<i className='fas fa-clock-rotate-left' />
|
||||
{ this.state.showHistory && this.renderHistoryItems() }
|
||||
</div>
|
||||
<div className={`editorTool undo ${this.props.historySize.done ? 'active' : ''}`}
|
||||
</button>
|
||||
<button className={`editorTool undo ${this.props.historySize.done ? 'active' : ''}`}
|
||||
onClick={this.props.undo} >
|
||||
<i className='fas fa-undo' />
|
||||
</div>
|
||||
<div className={`editorTool redo ${this.props.historySize.undone ? 'active' : ''}`}
|
||||
</button>
|
||||
<button className={`editorTool redo ${this.props.historySize.undone ? 'active' : ''}`}
|
||||
onClick={this.props.redo} >
|
||||
<i className='fas fa-redo' />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className='codeTools'>
|
||||
<div className={`editorTool foldAll ${this.props.foldCode ? 'active' : ''}`}
|
||||
<button className={`editorTool foldAll ${this.props.foldCode ? 'active' : ''}`}
|
||||
onClick={this.props.foldCode} >
|
||||
<i className='fas fa-compress-alt' />
|
||||
</div>
|
||||
<div className={`editorTool unfoldAll ${this.props.unfoldCode ? 'active' : ''}`}
|
||||
</button>
|
||||
<button className={`editorTool unfoldAll ${this.props.unfoldCode ? 'active' : ''}`}
|
||||
onClick={this.props.unfoldCode} >
|
||||
<i className='fas fa-expand-alt' />
|
||||
</div>
|
||||
<div className={`editorTheme ${this.state.themeSelector ? 'active' : ''}`}
|
||||
</button>
|
||||
<button className={`editorTool formatCode ${this.props.formatCode ? 'active' : ''}`}
|
||||
onClick={this.props.formatCode} >
|
||||
<i className='fas fa-wand-magic-sparkles' />
|
||||
</button>
|
||||
<button className={`editorTheme ${this.state.themeSelector ? 'active' : ''}`}
|
||||
onClick={this.toggleThemeSelector} >
|
||||
<i className='fas fa-palette' />
|
||||
{this.state.themeSelector && this.renderThemeSelector()}
|
||||
</div>
|
||||
</button>
|
||||
</div></>}
|
||||
|
||||
<div className='tabs'>
|
||||
<div className={cx('text', { selected: this.props.view === 'text' })}
|
||||
<button className={cx('text', { selected: this.props.view === 'text' })}
|
||||
onClick={()=>this.props.onViewChange('text')}>
|
||||
<i className='fa fa-beer' />
|
||||
</div>
|
||||
<div className={cx('style', { selected: this.props.view === 'style' })}
|
||||
</button>
|
||||
<button className={cx('style', { selected: this.props.view === 'style' })}
|
||||
onClick={()=>this.props.onViewChange('style')}>
|
||||
<i className='fa fa-paint-brush' />
|
||||
</div>
|
||||
<div className={cx('snippet', { selected: this.props.view === 'snippet' })}
|
||||
</button>
|
||||
<button className={cx('snippet', { selected: this.props.view === 'snippet' })}
|
||||
onClick={()=>this.props.onViewChange('snippet')}>
|
||||
<i className='fas fa-th-list' />
|
||||
</div>
|
||||
<div className={cx('meta', { selected: this.props.view === 'meta' })}
|
||||
</button>
|
||||
<button className={cx('meta', { selected: this.props.view === 'meta' })}
|
||||
onClick={()=>this.props.onViewChange('meta')}>
|
||||
<i className='fas fa-info-circle' />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
position : relative;
|
||||
display : flex;
|
||||
flex-wrap : wrap-reverse;
|
||||
reading-flow : flex-visual;
|
||||
justify-content : space-between;
|
||||
height : auto;
|
||||
color : black;
|
||||
@@ -21,9 +22,10 @@
|
||||
.editors {
|
||||
display : flex;
|
||||
justify-content : flex-end;
|
||||
min-width : 250px; //must be controlled every time an item is added, must be hardcoded for the wrapping as it is applied
|
||||
min-width : 275px; //must be controlled every time an item is added, must be hardcoded for the wrapping as it is applied
|
||||
font-size: .85rem;
|
||||
&:only-child {min-width : unset; margin-left : auto;}
|
||||
reading-order : 2;
|
||||
|
||||
>div {
|
||||
display : flex;
|
||||
@@ -32,7 +34,7 @@
|
||||
|
||||
&:first-child { border-left : none; }
|
||||
|
||||
& > div {
|
||||
& > button {
|
||||
position : relative;
|
||||
width : @menuHeight;
|
||||
height : @menuHeight;
|
||||
@@ -75,6 +77,11 @@
|
||||
color : grey;
|
||||
&.active { color : inherit; }
|
||||
}
|
||||
&.formatCode {
|
||||
.tooltipLeft('Clean your Code');
|
||||
color : grey;
|
||||
&.active { color : inherit; }
|
||||
}
|
||||
&.history {
|
||||
.tooltipLeft('History');
|
||||
position : relative;
|
||||
@@ -127,6 +134,7 @@
|
||||
display : flex;
|
||||
justify-content : flex-start;
|
||||
min-width : 565.95px; //must be controlled every time an item is added, must be hardcoded for the wrapping as it is applied
|
||||
reading-order : 1;
|
||||
}
|
||||
|
||||
// removed caret for top level items, by request (makes buttons too wide).
|
||||
@@ -208,16 +216,18 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
@container editor (width < 816px) {
|
||||
.snippetBar {
|
||||
@container editor (width < 841px) {
|
||||
.snippetBar {
|
||||
.editors {
|
||||
flex : 1;
|
||||
justify-content : space-between;
|
||||
border-bottom : 1px solid;
|
||||
reading-order : 1;
|
||||
}
|
||||
.snippets {
|
||||
flex : 1;
|
||||
justify-content : space-evenly;
|
||||
reading-order : 2;
|
||||
}
|
||||
.editors > div.history > .dropdown { right : unset; }
|
||||
}
|
||||
|
||||
@@ -181,6 +181,7 @@ const TagInput = ({ tooltip, label, valuePatterns, values = [], unique = true, p
|
||||
{t.value}
|
||||
<button
|
||||
type='button'
|
||||
aria-label={`remove ${t.value} tag`}
|
||||
onClick={(e)=>{
|
||||
e.stopPropagation();
|
||||
removeTag(i);
|
||||
|
||||
@@ -1,46 +1,28 @@
|
||||
import './navbar.less';
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
|
||||
import Nav from './nav.jsx';
|
||||
import PatreonNavItem from './patreon.navitem.jsx';
|
||||
|
||||
const Navbar = createReactClass({
|
||||
displayName : 'Navbar',
|
||||
getInitialState : function() {
|
||||
return {
|
||||
// showNonChromeWarning: false, // uncomment if needed
|
||||
ver : global.version || '0.0.0'
|
||||
};
|
||||
},
|
||||
const Navbar = ({ children })=>{
|
||||
const version = global.version || '0.0.0';
|
||||
|
||||
/*
|
||||
renderChromeWarning : function(){
|
||||
if(!this.state.showNonChromeWarning) return;
|
||||
return <Nav.item className='warning' icon='fa-exclamation-triangle'>
|
||||
Optimized for Chrome
|
||||
<div className='dropdown'>
|
||||
If you are experiencing rendering issues, use Chrome instead
|
||||
</div>
|
||||
</Nav.item>
|
||||
},
|
||||
*/
|
||||
render : function(){
|
||||
return <Nav.base>
|
||||
return (
|
||||
<Nav.base>
|
||||
<Nav.section>
|
||||
<Nav.logo />
|
||||
<Nav.item href='/' className='homebrewLogo'>
|
||||
<div>The Homebrewery</div>
|
||||
</Nav.item>
|
||||
<Nav.item newTab={true} href='/changelog' color='purple' icon='far fa-file-alt'>
|
||||
{`v${this.state.ver}`}
|
||||
{`v${version}`}
|
||||
</Nav.item>
|
||||
<PatreonNavItem />
|
||||
{/*this.renderChromeWarning()*/}
|
||||
{/* this.renderChromeWarning() */}
|
||||
</Nav.section>
|
||||
{this.props.children}
|
||||
</Nav.base>;
|
||||
}
|
||||
});
|
||||
{children}
|
||||
</Nav.base>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
@@ -1,9 +1,8 @@
|
||||
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
|
||||
import './listPage.less';
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import _ from 'lodash';
|
||||
import React, { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import moment from 'moment';
|
||||
import _ from 'lodash';
|
||||
|
||||
import BrewItem from './brewItem/brewItem.jsx';
|
||||
|
||||
@@ -14,132 +13,116 @@ const USERPAGE_GROUP_VISIBILITY_PREFIX = 'HB_listPage_visibility_group';
|
||||
const DEFAULT_SORT_TYPE = 'alpha';
|
||||
const DEFAULT_SORT_DIR = 'asc';
|
||||
|
||||
const ListPage = createReactClass({
|
||||
displayName : 'ListPage',
|
||||
getDefaultProps : function() {
|
||||
return {
|
||||
brewCollection : [
|
||||
{
|
||||
title : '',
|
||||
class : '',
|
||||
brews : []
|
||||
}
|
||||
],
|
||||
navItems : <></>,
|
||||
reportError : null
|
||||
const ListPage = ({ brewCollection = [{ title: '', class: '', brews: [] }], navItems = <></>, reportError = null, query })=>{
|
||||
const [filterString, setFilterString] = useState(query?.filter || '');
|
||||
const [filterTags, setFilterTags] = useState([]);
|
||||
const [sortType, setSortType] = useState(query?.sort || null);
|
||||
const [sortDir, setSortDir] = useState(query?.dir || null);
|
||||
const [groupVisibility, setGroupVisibility] = useState({});
|
||||
|
||||
const groupVisibilityRef = useRef(groupVisibility);
|
||||
const sortTypeRef = useRef(sortType);
|
||||
const sortDirRef = useRef(sortDir);
|
||||
|
||||
useEffect(()=>{
|
||||
groupVisibilityRef.current = groupVisibility;
|
||||
}, [groupVisibility]);
|
||||
|
||||
useEffect(()=>{
|
||||
sortTypeRef.current = sortType;
|
||||
}, [sortType]);
|
||||
|
||||
useEffect(()=>{
|
||||
sortDirRef.current = sortDir;
|
||||
}, [sortDir]);
|
||||
|
||||
useEffect(()=>{
|
||||
window.onbeforeunload = saveToLocalStorage;
|
||||
if(typeof window === 'undefined') return;
|
||||
|
||||
const newSortType = sortType ?? (localStorage.getItem(USERPAGE_SORT_TYPE) || DEFAULT_SORT_TYPE);
|
||||
const newSortDir = sortDir ?? (localStorage.getItem(USERPAGE_SORT_DIR) || DEFAULT_SORT_DIR);
|
||||
updateUrl(filterString, newSortType, newSortDir);
|
||||
|
||||
const namedBrewCollection = brewCollection.reduce((visibility, brewGroup)=>{
|
||||
visibility[brewGroup.class] = (localStorage.getItem(`${USERPAGE_GROUP_VISIBILITY_PREFIX}_${brewGroup.class}`) ?? 'true') == 'true';
|
||||
return visibility;
|
||||
}, {});
|
||||
|
||||
setGroupVisibility(namedBrewCollection);
|
||||
setSortType(newSortType);
|
||||
setSortDir(newSortDir);
|
||||
|
||||
return ()=>{
|
||||
window.onbeforeunload = null;
|
||||
};
|
||||
},
|
||||
getInitialState : function() {
|
||||
// HIDE ALL GROUPS UNTIL LOADED
|
||||
const brewCollection = this.props.brewCollection.map((brewGroup)=>{
|
||||
brewGroup.visible = false;
|
||||
return brewGroup;
|
||||
}, []);
|
||||
|
||||
const saveToLocalStorage = ()=>{
|
||||
brewCollection.forEach((brewGroup)=>{
|
||||
localStorage.setItem(`${USERPAGE_GROUP_VISIBILITY_PREFIX}_${brewGroup.class}`, `${groupVisibilityRef.current[brewGroup.class]}`);
|
||||
});
|
||||
localStorage.setItem(USERPAGE_SORT_TYPE, sortTypeRef.current);
|
||||
localStorage.setItem(USERPAGE_SORT_DIR, sortDirRef.current);
|
||||
};
|
||||
|
||||
return {
|
||||
filterString : this.props.query?.filter || '',
|
||||
filterTags : [],
|
||||
sortType : this.props.query?.sort || null,
|
||||
sortDir : this.props.query?.dir || null,
|
||||
query : this.props.query,
|
||||
brewCollection : brewCollection
|
||||
};
|
||||
},
|
||||
|
||||
componentDidMount : function() {
|
||||
// SAVE TO LOCAL STORAGE WHEN LEAVING PAGE
|
||||
window.onbeforeunload = this.saveToLocalStorage;
|
||||
|
||||
// LOAD FROM LOCAL STORAGE
|
||||
if(typeof window !== 'undefined') {
|
||||
const newSortType = (this.state.sortType ?? (localStorage.getItem(USERPAGE_SORT_TYPE) || DEFAULT_SORT_TYPE));
|
||||
const newSortDir = (this.state.sortDir ?? (localStorage.getItem(USERPAGE_SORT_DIR) || DEFAULT_SORT_DIR));
|
||||
this.updateUrl(this.state.filterString, newSortType, newSortDir);
|
||||
|
||||
const brewCollection = this.props.brewCollection.map((brewGroup)=>{
|
||||
brewGroup.visible = (localStorage.getItem(`${USERPAGE_GROUP_VISIBILITY_PREFIX}_${brewGroup.class}`) ?? 'true')=='true';
|
||||
return brewGroup;
|
||||
});
|
||||
|
||||
this.setState({
|
||||
brewCollection : brewCollection,
|
||||
sortType : newSortType,
|
||||
sortDir : newSortDir
|
||||
});
|
||||
};
|
||||
},
|
||||
|
||||
componentWillUnmount : function() {
|
||||
window.onbeforeunload = function(){};
|
||||
},
|
||||
|
||||
saveToLocalStorage : function() {
|
||||
this.state.brewCollection.map((brewGroup)=>{
|
||||
localStorage.setItem(`${USERPAGE_GROUP_VISIBILITY_PREFIX}_${brewGroup.class}`, `${brewGroup.visible}`);
|
||||
});
|
||||
localStorage.setItem(USERPAGE_SORT_TYPE, this.state.sortType);
|
||||
localStorage.setItem(USERPAGE_SORT_DIR, this.state.sortDir);
|
||||
},
|
||||
|
||||
renderBrews : function(brews){
|
||||
const renderBrews = (brews)=>{
|
||||
if(!brews || !brews.length) return <div className='noBrews'>No Brews.</div>;
|
||||
|
||||
return _.map(brews, (brew, idx)=>{
|
||||
return <BrewItem brew={brew} key={idx} reportError={this.props.reportError} updateListFilter={ (tag)=>{ this.updateUrl(this.state.filterString, this.state.sortType, this.state.sortDir, tag); }}/>;
|
||||
});
|
||||
},
|
||||
return _.map(brews, (brew, idx)=>(
|
||||
<BrewItem
|
||||
brew={brew}
|
||||
key={idx}
|
||||
reportError={reportError}
|
||||
updateListFilter={(tag)=>{
|
||||
updateUrl(filterString, sortType, sortDir, tag);
|
||||
}}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
sortBrewOrder : function(brew){
|
||||
if(!brew.title){brew.title = 'No Title';}
|
||||
const sortBrewOrder = (brew)=>{
|
||||
const title = brew.title || 'No Title';
|
||||
const mapping = {
|
||||
'alpha' : _.deburr(brew.title.trim().toLowerCase()),
|
||||
'alpha' : _.deburr(title.trim().toLowerCase()),
|
||||
'created' : moment(brew.createdAt).format(),
|
||||
'updated' : moment(brew.updatedAt).format(),
|
||||
'views' : brew.views,
|
||||
'latest' : moment(brew.lastViewed).format()
|
||||
'latest' : moment(brew.lastViewed).format(),
|
||||
};
|
||||
return mapping[this.state.sortType];
|
||||
},
|
||||
return mapping[sortType];
|
||||
};
|
||||
|
||||
handleSortOptionChange : function(event){
|
||||
this.updateUrl(this.state.filterString, event.target.value, this.state.sortDir);
|
||||
this.setState({
|
||||
sortType : event.target.value
|
||||
});
|
||||
},
|
||||
const handleSortOptionChange = (event)=>{
|
||||
updateUrl(filterString, event.target.value, sortDir);
|
||||
setSortType(event.target.value);
|
||||
};
|
||||
|
||||
handleSortDirChange : function(event){
|
||||
const newDir = this.state.sortDir == 'asc' ? 'desc' : 'asc';
|
||||
const handleSortDirChange = (event)=>{
|
||||
const newDir = sortDir == 'asc' ? 'desc' : 'asc';
|
||||
|
||||
this.updateUrl(this.state.filterString, this.state.sortType, newDir);
|
||||
this.setState({
|
||||
sortDir : newDir
|
||||
});
|
||||
},
|
||||
updateUrl(filterString, sortType, newDir);
|
||||
setSortDir(newDir);
|
||||
};
|
||||
|
||||
renderSortOption : function(sortTitle, sortValue){
|
||||
return <div className={`sort-option ${(this.state.sortType == sortValue ? 'active' : '')}`}>
|
||||
<button
|
||||
value={`${sortValue}`}
|
||||
onClick={this.state.sortType == sortValue ? this.handleSortDirChange : this.handleSortOptionChange}
|
||||
>
|
||||
{`${sortTitle}`}
|
||||
</button>
|
||||
{this.state.sortType == sortValue &&
|
||||
<i className={`sortDir fas ${this.state.sortDir == 'asc' ? 'fa-sort-up' : 'fa-sort-down'}`}></i>
|
||||
}
|
||||
</div>;
|
||||
},
|
||||
const renderSortOption = (sortTitle, sortValue)=>{
|
||||
return (
|
||||
<div className={`sort-option ${sortType == sortValue ? 'active' : ''}`}>
|
||||
<button value={`${sortValue}`} onClick={sortType == sortValue ? handleSortDirChange : handleSortOptionChange}>
|
||||
{`${sortTitle}`}
|
||||
</button>
|
||||
{sortType == sortValue && <i className={`sortDir fas ${sortDir == 'asc' ? 'fa-sort-up' : 'fa-sort-down'}`}></i>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
handleFilterTextChange : function(e){
|
||||
this.setState({
|
||||
filterString : e.target.value,
|
||||
});
|
||||
this.updateUrl(e.target.value, this.state.sortType, this.state.sortDir);
|
||||
const handleFilterTextChange = (e)=>{
|
||||
setFilterString(e.target.value);
|
||||
updateUrl(e.target.value, sortType, sortDir);
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
updateUrl : function(filterTerm, sortType, sortDir, filterTag=''){
|
||||
const updateUrl = (filterTerm, sortType, sortDir, filterTag = '')=>{
|
||||
const url = new URL(window.location.href);
|
||||
const urlParams = new URLSearchParams(url.search);
|
||||
|
||||
@@ -148,135 +131,162 @@ const ListPage = createReactClass({
|
||||
|
||||
let filterTags = urlParams.getAll('tag');
|
||||
if(filterTag != '') {
|
||||
if(filterTags.findIndex((tag)=>{return tag.toLowerCase()==filterTag.toLowerCase();}) == -1){
|
||||
if(
|
||||
filterTags.findIndex((tag)=>{
|
||||
return tag.toLowerCase() == filterTag.toLowerCase();
|
||||
}) == -1
|
||||
) {
|
||||
filterTags.push(filterTag);
|
||||
} else {
|
||||
filterTags = filterTags.filter((tag)=>{ return tag.toLowerCase() != filterTag.toLowerCase(); });
|
||||
filterTags = filterTags.filter((tag)=>{
|
||||
return tag.toLowerCase() != filterTag.toLowerCase();
|
||||
});
|
||||
}
|
||||
}
|
||||
urlParams.delete('tag');
|
||||
// Add tags to URL in the order they were clicked
|
||||
filterTags.forEach((tag)=>{ urlParams.append('tag', tag); });
|
||||
filterTags.forEach((tag)=>urlParams.append('tag', tag));
|
||||
// Sort tags before updating state
|
||||
filterTags.sort((a, b)=>{
|
||||
return a.indexOf(':') - b.indexOf(':') != 0 ? a.indexOf(':') - b.indexOf(':') : a.toLowerCase().localeCompare(b.toLowerCase());
|
||||
});
|
||||
|
||||
this.setState({
|
||||
filterTags
|
||||
});
|
||||
setFilterTags(filterTags);
|
||||
|
||||
if(!filterTerm)
|
||||
urlParams.delete('filter');
|
||||
else
|
||||
urlParams.set('filter', filterTerm);
|
||||
if(!filterTerm) urlParams.delete('filter');
|
||||
else urlParams.set('filter', filterTerm);
|
||||
|
||||
url.search = urlParams;
|
||||
window.history.replaceState(null, null, url);
|
||||
},
|
||||
};
|
||||
|
||||
renderFilterOption : function(){
|
||||
return <div className='filter-option'>
|
||||
<label>
|
||||
<i className='fas fa-search'></i>
|
||||
<input
|
||||
type='search'
|
||||
placeholder='filter title/description'
|
||||
onChange={this.handleFilterTextChange}
|
||||
value={this.state.filterString}
|
||||
/>
|
||||
</label>
|
||||
</div>;
|
||||
},
|
||||
const renderFilterOption = ()=>{
|
||||
return (
|
||||
<div className='filter-option'>
|
||||
<label>
|
||||
<i className='fas fa-search'></i>
|
||||
<input type='search' placeholder='filter title/description/tags' onChange={handleFilterTextChange} value={filterString} />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderTagsOptions : function(){
|
||||
if(this.state.filterTags?.length == 0) return;
|
||||
return <div className='tags-container'>
|
||||
{_.map(this.state.filterTags, (tag, idx)=>{
|
||||
const matches = tag.match(/^(?:([^:]+):)?([^:]+)$/);
|
||||
return <span key={idx} className={matches[1]} onClick={()=>{ this.updateUrl(this.state.filterString, this.state.sortType, this.state.sortDir, tag); }}>{matches[2]}</span>;
|
||||
})}
|
||||
</div>;
|
||||
},
|
||||
const renderTagsOptions = ()=>{
|
||||
if(filterTags?.length == 0) return;
|
||||
return (
|
||||
<div className='tags-container'>
|
||||
{_.map(filterTags, (tag, idx)=>{
|
||||
const matches = tag.match(/^(?:([^:]+):)?([^:]+)$/);
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
className={matches[1]}
|
||||
onClick={()=>{
|
||||
updateUrl(filterString, sortType, sortDir, tag);
|
||||
}}>
|
||||
{matches[2]}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderSortOptions : function(){
|
||||
return <div className='sort-container'>
|
||||
<h6>Sort by :</h6>
|
||||
{this.renderSortOption('Title', 'alpha')}
|
||||
{this.renderSortOption('Created Date', 'created')}
|
||||
{this.renderSortOption('Updated Date', 'updated')}
|
||||
{this.renderSortOption('Views', 'views')}
|
||||
{/* {this.renderSortOption('Latest', 'latest')} */}
|
||||
const renderSortOptions = ()=>{
|
||||
return (
|
||||
<div className='sort-container'>
|
||||
<h6>Sort by :</h6>
|
||||
{renderSortOption('Title', 'alpha')}
|
||||
{renderSortOption('Created Date', 'created')}
|
||||
{renderSortOption('Updated Date', 'updated')}
|
||||
{renderSortOption('Views', 'views')}
|
||||
{/* {renderSortOption('Latest', 'latest')} */}
|
||||
{renderFilterOption()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
{this.renderFilterOption()}
|
||||
</div>;
|
||||
},
|
||||
|
||||
getSortedBrews : function(brews){
|
||||
const testString = _.deburr(this.state.filterString).toLowerCase();
|
||||
const getSortedBrews = (brews)=>{
|
||||
const testString = _.deburr(filterString).toLowerCase();
|
||||
|
||||
brews = _.filter(brews, (brew)=>{
|
||||
// Filter by user entered text
|
||||
const brewStrings = _.deburr([
|
||||
brew.title,
|
||||
brew.description,
|
||||
brew.tags].join('\n')
|
||||
.toLowerCase());
|
||||
|
||||
const brewStrings = _.deburr([brew.title, brew.description, brew.tags].join('\n').toLowerCase());
|
||||
const filterTextTest = brewStrings.includes(testString);
|
||||
|
||||
// Filter by user selected tags
|
||||
let filterTagTest = true;
|
||||
if(this.state.filterTags.length > 0){
|
||||
filterTagTest = Array.isArray(brew.tags) && this.state.filterTags?.every((tag)=>{
|
||||
return brew.tags.findIndex((brewTag)=>{
|
||||
return brewTag.toLowerCase() == tag.toLowerCase();
|
||||
}) >= 0;
|
||||
});
|
||||
if(filterTags.length > 0) {
|
||||
filterTagTest =
|
||||
Array.isArray(brew.tags) &&
|
||||
filterTags?.every((tag)=>{
|
||||
return (
|
||||
brew.tags.findIndex((brewTag)=>{
|
||||
return brewTag.toLowerCase() == tag.toLowerCase();
|
||||
}) >= 0
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return filterTextTest && filterTagTest;
|
||||
});
|
||||
|
||||
return _.orderBy(brews, (brew)=>{ return this.sortBrewOrder(brew); }, this.state.sortDir);
|
||||
},
|
||||
return _.orderBy(
|
||||
brews,
|
||||
(brew)=>{
|
||||
return sortBrewOrder(brew);
|
||||
},
|
||||
sortDir,
|
||||
);
|
||||
};
|
||||
|
||||
toggleBrewCollectionState : function(brewGroupClass) {
|
||||
this.setState((prevState)=>({
|
||||
brewCollection : prevState.brewCollection.map(
|
||||
(brewGroup)=>brewGroup.class === brewGroupClass ? { ...brewGroup, visible: !brewGroup.visible } : brewGroup
|
||||
)
|
||||
}));
|
||||
},
|
||||
const sortedBrewCollection = useMemo(()=>{
|
||||
return brewCollection.map((brewGroup)=>({ ...brewGroup, brews: getSortedBrews(brewGroup.brews) }));
|
||||
}, [brewCollection, filterString, filterTags, sortType, sortDir]);
|
||||
|
||||
renderBrewCollection : function(brewCollection){
|
||||
if(brewCollection == []) return <div className='brewCollection'>
|
||||
<h1>No Brews</h1>
|
||||
</div>;
|
||||
const toggleBrewCollectionState = (brewGroupClass)=>{
|
||||
setGroupVisibility((prevVisibility)=>({ ...prevVisibility, [brewGroupClass]: !prevVisibility[brewGroupClass] }));
|
||||
};
|
||||
|
||||
const renderBrewCollection = (brewCollection)=>{
|
||||
if(brewCollection.length === 0)
|
||||
return (
|
||||
<div className='brewCollection'>
|
||||
<h1>No Brews</h1>
|
||||
</div>
|
||||
);
|
||||
return _.map(brewCollection, (brewGroup, idx)=>{
|
||||
return <div key={idx} className={`brewCollection ${brewGroup.class ?? ''}`}>
|
||||
<h1 className={brewGroup.visible ? 'active' : 'inactive'} onClick={()=>{this.toggleBrewCollectionState(brewGroup.class);}}>{brewGroup.title || 'No Title'}</h1>
|
||||
{brewGroup.visible ? this.renderBrews(this.getSortedBrews(brewGroup.brews)) : <></>}
|
||||
</div>;
|
||||
});
|
||||
},
|
||||
const sortedBrewGroup = sortedBrewCollection[idx];
|
||||
const visible = groupVisibility[brewGroup.class];
|
||||
|
||||
render : function(){
|
||||
return <div className='listPage sitePage'>
|
||||
{/*<style>@layer V3_5ePHB, bundle;</style>*/}
|
||||
<link href='/themes/V3/Blank/style.css' type='text/css' rel='stylesheet'/>
|
||||
<link href='/themes/V3/5ePHB/style.css' type='text/css' rel='stylesheet'/>
|
||||
{this.props.navItems}
|
||||
{this.renderSortOptions()}
|
||||
{this.renderTagsOptions()}
|
||||
return (
|
||||
<div key={idx} className={`brewCollection ${brewGroup.class ?? ''}`}>
|
||||
<h1
|
||||
className={visible ? 'active' : 'inactive'}
|
||||
onClick={()=>{
|
||||
toggleBrewCollectionState(brewGroup.class);
|
||||
}}>
|
||||
{brewGroup.title || 'No Title'}
|
||||
</h1>
|
||||
{visible ? renderBrews(sortedBrewGroup.brews) : <></>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='listPage sitePage'>
|
||||
<link href='/themes/V3/Blank/style.css' type='text/css' rel='stylesheet' />
|
||||
<link href='/themes/V3/5ePHB/style.css' type='text/css' rel='stylesheet' />
|
||||
{navItems}
|
||||
{renderSortOptions()}
|
||||
{renderTagsOptions()}
|
||||
|
||||
<div className='content V3'>
|
||||
<div className='page'>
|
||||
{this.renderBrewCollection(this.state.brewCollection)}
|
||||
</div>
|
||||
<div className='page'>{renderBrewCollection(brewCollection)}</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
});
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListPage;
|
||||
|
||||
@@ -2,26 +2,27 @@
|
||||
import './editPage.less';
|
||||
|
||||
// Common imports
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef, useEffectEvent } from 'react';
|
||||
import request from '../../utils/request-middleware.js';
|
||||
import Markdown from '@shared/markdown.js';
|
||||
import { hbfm } from 'hbmarkedwrapper';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js';
|
||||
import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js';
|
||||
|
||||
import useCommonEditPageFunctions from '../../utils/commonEditPageFunctions.jsx'
|
||||
|
||||
import SplitPane from '@components/splitPane/splitPane.jsx';
|
||||
import Editor from '../../editor/editor.jsx';
|
||||
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
|
||||
|
||||
import Nav from '@navbar/nav.jsx';
|
||||
import Navbar from '@navbar/navbar.jsx';
|
||||
import NewBrewItem from '@navbar/newbrew.navitem.jsx';
|
||||
import AccountNavItem from '@navbar/account.navitem.jsx';
|
||||
import ErrorNavItem from '@navbar/error-navitem.jsx';
|
||||
import HelpNavItem from '@navbar/help.navitem.jsx';
|
||||
import VaultNavItem from '@navbar/vault.navitem.jsx';
|
||||
import PrintNavItem from '@navbar/print.navitem.jsx';
|
||||
import Nav from '@navbar/nav.jsx';
|
||||
import Navbar from '@navbar/navbar.jsx';
|
||||
import NewBrewItem from '@navbar/newbrew.navitem.jsx';
|
||||
import AccountNavItem from '@navbar/account.navitem.jsx';
|
||||
import ErrorNavItem from '@navbar/error-navitem.jsx';
|
||||
import HelpNavItem from '@navbar/help.navitem.jsx';
|
||||
import VaultNavItem from '@navbar/vault.navitem.jsx';
|
||||
import PrintNavItem from '@navbar/print.navitem.jsx';
|
||||
import RecentNavItems from '@navbar/recent.navitem.jsx';
|
||||
const { both: RecentNavItem } = RecentNavItems;
|
||||
|
||||
@@ -37,19 +38,13 @@ import LockNotification from './lockNotification/lockNotification.jsx';
|
||||
import { updateHistory, versionHistoryGarbageCollection } from '../../utils/versionHistory.js';
|
||||
import googleDriveIcon from '../../googleDrive.svg';
|
||||
|
||||
const SAVE_TIMEOUT = 10000;
|
||||
const UNSAVED_WARNING_TIMEOUT = 900000; //Warn user afer 15 minutes of unsaved changes
|
||||
const UNSAVED_WARNING_POPUP_TIMEOUT = 4000; //Show the warning for 4 seconds
|
||||
|
||||
|
||||
const AUTOSAVE_KEY = 'HB_editor_autoSaveOn';
|
||||
const BREWKEY = 'HB_newPage_content';
|
||||
const STYLEKEY = 'HB_newPage_style';
|
||||
const SNIPKEY = 'HB_newPage_snippets';
|
||||
const METAKEY = 'HB_newPage_meta';
|
||||
|
||||
const useLocalStorage = false;
|
||||
const neverSaved = false;
|
||||
const sandbox = false;
|
||||
|
||||
const EditPage = (props)=>{
|
||||
props = {
|
||||
@@ -58,95 +53,20 @@ const EditPage = (props)=>{
|
||||
};
|
||||
|
||||
const [currentBrew, setCurrentBrew] = useState(props.brew);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [lastSavedTime, setLastSavedTime] = useState(new Date());
|
||||
const [saveGoogle, setSaveGoogle] = useState(!!props.brew.googleId);
|
||||
const [error, setError] = useState(null);
|
||||
const [HTMLErrors, setHTMLErrors] = useState(Markdown.validate(props.brew.text));
|
||||
const [HTMLErrors, setHTMLErrors] = useState(hbfm.validate(props.brew.text));
|
||||
const [currentEditorViewPageNum, setCurrentEditorViewPageNum] = useState(1);
|
||||
const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1);
|
||||
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
|
||||
const [themeBundle, setThemeBundle] = useState({});
|
||||
const [unsavedChanges, setUnsavedChanges] = useState(false);
|
||||
const [alertTrashedGoogleBrew, setAlertTrashedGoogleBrew] = useState(props.brew.trashed);
|
||||
const [alertLoginToTransfer, setAlertLoginToTransfer] = useState(false);
|
||||
const [alertNoGoogleToTransfer, setAlertNoGoogleToTransfer] = useState(false);
|
||||
const [alertOwnershipToTransfer, setAlertOwnershipToTransfer] = useState(false);
|
||||
const [confirmGoogleTransfer, setConfirmGoogleTransfer] = useState(false);
|
||||
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true);
|
||||
const [warnUnsavedChanges, setWarnUnsavedChanges] = useState(true);
|
||||
|
||||
const editorRef = useRef(null);
|
||||
const lastSavedBrew = useRef(_.cloneDeep(props.brew));
|
||||
const saveTimeout = useRef(null);
|
||||
const warnUnsavedTimeout = useRef(null);
|
||||
const trySaveRef = useRef(null); // CTRL+S listener lives outside React and needs ref to use trySave with latest copy of brew
|
||||
const unsavedChangesRef = useRef(unsavedChanges); // Similarly, onBeforeUnload lives outside React and needs ref to unsavedChanges
|
||||
|
||||
useEffect(()=>{
|
||||
const autoSavePref = JSON.parse(localStorage.getItem(AUTOSAVE_KEY) ?? true);
|
||||
setAutoSaveEnabled(autoSavePref);
|
||||
setWarnUnsavedChanges(!autoSavePref);
|
||||
setHTMLErrors(Markdown.validate(currentBrew.text));
|
||||
fetchThemeBundle(setError, setThemeBundle, currentBrew.renderer, currentBrew.theme);
|
||||
|
||||
const handleControlKeys = (e)=>{
|
||||
if(!(e.ctrlKey || e.metaKey)) return;
|
||||
if(e.keyCode === 83) trySaveRef.current(true, true, saveGoogle);
|
||||
if(e.keyCode === 80) printCurrentBrew();
|
||||
if([83, 80].includes(e.keyCode)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleControlKeys);
|
||||
window.onbeforeunload = ()=>{
|
||||
if(unsavedChangesRef.current)
|
||||
return 'You have unsaved changes!';
|
||||
};
|
||||
return ()=>{
|
||||
document.removeEventListener('keydown', handleControlKeys);
|
||||
window.onBeforeUnload = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(()=>{
|
||||
trySaveRef.current = trySave;
|
||||
unsavedChangesRef.current = unsavedChanges;
|
||||
});
|
||||
|
||||
useEffect(()=>{
|
||||
const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current);
|
||||
setUnsavedChanges(hasChange);
|
||||
|
||||
if(autoSaveEnabled) trySave(false, hasChange, saveGoogle);
|
||||
}, [currentBrew]);
|
||||
|
||||
const handleSplitMove = ()=>{
|
||||
editorRef.current?.update();
|
||||
};
|
||||
|
||||
const handleBrewChange = (field)=>(value, subfield)=>{ //'text', 'style', 'snippets', 'metadata'
|
||||
if(subfield == 'renderer' || subfield == 'theme')
|
||||
fetchThemeBundle(setError, setThemeBundle, value.renderer, value.theme);
|
||||
|
||||
//If there are HTML errors, run the validator on every change to give quick feedback
|
||||
if(HTMLErrors.length && (field == 'text' || field == 'snippets'))
|
||||
setHTMLErrors(Markdown.validate(value));
|
||||
|
||||
if(field == 'metadata') setCurrentBrew((prev)=>({ ...prev, ...value }));
|
||||
else setCurrentBrew((prev)=>({ ...prev, [field]: value }));
|
||||
|
||||
if(useLocalStorage) {
|
||||
if(field == 'text') localStorage.setItem(BREWKEY, value);
|
||||
if(field == 'style') localStorage.setItem(STYLEKEY, value);
|
||||
if(field == 'snippets') localStorage.setItem(SNIPKEY, value);
|
||||
if(field == 'metadata') localStorage.setItem(METAKEY, JSON.stringify({
|
||||
renderer : value.renderer,
|
||||
theme : value.theme,
|
||||
lang : value.lang
|
||||
}));
|
||||
}
|
||||
};
|
||||
const editorRef = useRef(null);
|
||||
const lastSavedBrew = useRef(_.cloneDeep(props.brew));
|
||||
|
||||
const updateBrew = (newData)=>setCurrentBrew((prevBrew)=>({
|
||||
...prevBrew,
|
||||
@@ -155,15 +75,13 @@ const EditPage = (props)=>{
|
||||
snippets : newData.snippets
|
||||
}));
|
||||
|
||||
const resetWarnUnsavedTimer = ()=>{
|
||||
setTimeout(()=>setWarnUnsavedChanges(false), UNSAVED_WARNING_POPUP_TIMEOUT); // Hide the warning after 4 seconds
|
||||
clearTimeout(warnUnsavedTimeout.current);
|
||||
warnUnsavedTimeout.current = setTimeout(()=>setWarnUnsavedChanges(true), UNSAVED_WARNING_TIMEOUT); // 15 minutes between unsaved work warnings
|
||||
};
|
||||
|
||||
const handleGoogleClick = ()=>{
|
||||
if(currentBrew.authors.length > 0 && global.account?.username !== currentBrew.authors[0]) {
|
||||
setAlertOwnershipToTransfer(true);
|
||||
return;
|
||||
}
|
||||
if(!global.account?.googleId) {
|
||||
setAlertLoginToTransfer(true);
|
||||
setAlertNoGoogleToTransfer(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -174,38 +92,21 @@ const EditPage = (props)=>{
|
||||
const closeAlerts = (e)=>{
|
||||
e.stopPropagation(); //Only handle click once so alert doesn't reopen
|
||||
setAlertTrashedGoogleBrew(false);
|
||||
setAlertLoginToTransfer(false);
|
||||
setAlertNoGoogleToTransfer(false);
|
||||
setConfirmGoogleTransfer(false);
|
||||
setAlertOwnershipToTransfer(false);
|
||||
};
|
||||
|
||||
const toggleGoogleStorage = ()=>{
|
||||
const toggleGoogleStorage = (e)=>{
|
||||
closeAlerts(e);
|
||||
const newSaveGoogle = !saveGoogle;
|
||||
setSaveGoogle((prev)=>!prev);
|
||||
setError(null);
|
||||
trySave(true, true, newSaveGoogle);
|
||||
};
|
||||
|
||||
const trySave = (immediate = false, hasChanges = true, saveToGoogle = false)=>{
|
||||
clearTimeout(saveTimeout.current);
|
||||
if(isSaving) return;
|
||||
if(!hasChanges && !immediate) return;
|
||||
const newTimeout = immediate ? 0 : SAVE_TIMEOUT;
|
||||
|
||||
saveTimeout.current = setTimeout(async ()=>{
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
await save(currentBrew, saveToGoogle)
|
||||
.catch((err)=>{
|
||||
setError(err);
|
||||
});
|
||||
setIsSaving(false);
|
||||
setLastSavedTime(new Date());
|
||||
if(!autoSaveEnabled) resetWarnUnsavedTimer();
|
||||
}, newTimeout);
|
||||
};
|
||||
|
||||
const save = async (brew, saveToGoogle)=>{
|
||||
setHTMLErrors(Markdown.validate(brew.text));
|
||||
setHTMLErrors(hbfm.validate(brew.text));
|
||||
|
||||
await updateHistory(brew).catch(console.error);
|
||||
await versionHistoryGarbageCollection().catch(console.error);
|
||||
@@ -260,91 +161,52 @@ const EditPage = (props)=>{
|
||||
<Nav.item className='googleDriveStorage' onClick={handleGoogleClick}>
|
||||
<img src={googleDriveIcon} className={saveGoogle ? '' : 'inactive'} alt='Google Drive icon' />
|
||||
|
||||
{alertOwnershipToTransfer && (
|
||||
<div className='errorContainer'>
|
||||
You must be the Owner to transfer between the Homebrewery and Google Drive!
|
||||
The owner of this file is {currentBrew.authors[0]}.
|
||||
<br></br>
|
||||
<div className='confirm' onClick={closeAlerts}> Okay </div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{alertNoGoogleToTransfer && (
|
||||
<div className='errorContainer'>
|
||||
You must be signed in to a Google account to transfer between the Homebrewery and Google Drive!
|
||||
<a target='_blank' rel='noopener noreferrer' href={`https://www.naturalcrit.com/login?redirect=${window.location.href}`}>
|
||||
<div className='confirm' onClick={closeAlerts}> Sign In </div>
|
||||
</a>
|
||||
<div className='deny' onClick={closeAlerts}> Not Now </div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{alertTrashedGoogleBrew && (
|
||||
<div className='errorContainer'>
|
||||
This brew is currently in your Trash folder on Google Drive!<br />
|
||||
If you want to keep it, make sure to move it before it is deleted permanently!<br />
|
||||
<div className='confirm' onClick={toggleGoogleStorage}> Save my brew </div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmGoogleTransfer && (
|
||||
<div className='errorContainer' onClick={closeAlerts}>
|
||||
<div className='errorContainer'>
|
||||
{saveGoogle
|
||||
? 'Would you like to transfer this brew from your Google Drive storage back to the Homebrewery?'
|
||||
: 'Would you like to transfer this brew from the Homebrewery to your personal Google Drive storage?'}
|
||||
<br />
|
||||
<div className='confirm' onClick={toggleGoogleStorage}> Yes </div>
|
||||
<div className='deny'> No </div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{alertLoginToTransfer && (
|
||||
<div className='errorContainer' onClick={closeAlerts}>
|
||||
You must be signed in to a Google account to transfer between the homebrewery and Google Drive!
|
||||
<a target='_blank' rel='noopener noreferrer' href={`https://www.naturalcrit.com/login?redirect=${window.location.href}`}>
|
||||
<div className='confirm'> Sign In </div>
|
||||
</a>
|
||||
<div className='deny'> Not Now </div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{alertTrashedGoogleBrew && (
|
||||
<div className='errorContainer' onClick={closeAlerts}>
|
||||
This brew is currently in your Trash folder on Google Drive!<br />
|
||||
If you want to keep it, make sure to move it before it is deleted permanently!<br />
|
||||
<div className='confirm'> OK </div>
|
||||
<div className='deny' onClick={closeAlerts}> No </div>
|
||||
</div>
|
||||
)}
|
||||
</Nav.item>
|
||||
);
|
||||
|
||||
const renderSaveButton = ()=>{
|
||||
// #1 - Currently saving, show SAVING
|
||||
if(isSaving)
|
||||
return <Nav.item className='save' icon='fas fa-spinner fa-spin'>saving...</Nav.item>;
|
||||
|
||||
// #2 - Unsaved changes exist, autosave is OFF and warning timer has expired, show AUTOSAVE WARNING
|
||||
if(unsavedChanges && warnUnsavedChanges) {
|
||||
resetWarnUnsavedTimer();
|
||||
const elapsedTime = Math.round((new Date() - lastSavedTime) / 1000 / 60);
|
||||
const text = elapsedTime === 0
|
||||
? 'Autosave is OFF.'
|
||||
: `Autosave is OFF, and you haven't saved for ${elapsedTime} minutes.`;
|
||||
|
||||
return <Nav.item className='save error' icon='fas fa-exclamation-circle'>
|
||||
Reminder...
|
||||
<div className='errorContainer'>{text}</div>
|
||||
</Nav.item>;
|
||||
}
|
||||
|
||||
// #3 - Unsaved changes exist, click to save, show SAVE NOW
|
||||
if(unsavedChanges)
|
||||
return <Nav.item className='save' onClick={()=>trySave(true, true, saveGoogle)} color='blue' icon='fas fa-save'>save now</Nav.item>;
|
||||
|
||||
// #4 - No unsaved changes, autosave is ON, show AUTO-SAVED
|
||||
if(autoSaveEnabled)
|
||||
return <Nav.item className='save saved'>auto-saved</Nav.item>;
|
||||
|
||||
// #5 - No unsaved changes, and has never been saved, hide the button
|
||||
if(neverSaved)
|
||||
return <Nav.item className='save neverSaved' disabled={true}>save now</Nav.item>;
|
||||
|
||||
// DEFAULT - No unsaved changes, show SAVED
|
||||
return <Nav.item className='save saved'>saved</Nav.item>;
|
||||
};
|
||||
|
||||
const toggleAutoSave = ()=>{
|
||||
clearTimeout(warnUnsavedTimeout.current);
|
||||
clearTimeout(saveTimeout.current);
|
||||
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(!autoSaveEnabled));
|
||||
setAutoSaveEnabled(!autoSaveEnabled);
|
||||
setWarnUnsavedChanges(autoSaveEnabled);
|
||||
};
|
||||
|
||||
const renderAutoSaveButton = ()=>(
|
||||
<Nav.item onClick={toggleAutoSave}>
|
||||
Autosave <i className={autoSaveEnabled ? 'fas fa-power-off active' : 'fas fa-power-off'}></i>
|
||||
</Nav.item>
|
||||
);
|
||||
|
||||
const clearError = ()=>{
|
||||
setError(null);
|
||||
setIsSaving(false);
|
||||
};
|
||||
|
||||
const renderNavbar = ()=>{
|
||||
return <Navbar>
|
||||
<Nav.section>
|
||||
@@ -370,6 +232,34 @@ const EditPage = (props)=>{
|
||||
</Navbar>;
|
||||
};
|
||||
|
||||
const {
|
||||
handleSplitMove,
|
||||
handleBrewChange,
|
||||
toggleAutoSave,
|
||||
clearError,
|
||||
renderSaveButton,
|
||||
autoSaveEnabled,
|
||||
trySave
|
||||
} = useCommonEditPageFunctions({
|
||||
saveGoogle,
|
||||
setError,
|
||||
setThemeBundle,
|
||||
HTMLErrors,
|
||||
setHTMLErrors,
|
||||
currentBrew,
|
||||
setCurrentBrew,
|
||||
useLocalStorage,
|
||||
BREWKEY,
|
||||
STYLEKEY,
|
||||
SNIPKEY,
|
||||
METAKEY,
|
||||
hbfm,
|
||||
sandbox,
|
||||
lastSavedBrew,
|
||||
editorRef,
|
||||
save,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='editPage sitePage'>
|
||||
<Meta name='robots' content='noindex, nofollow' />
|
||||
@@ -399,12 +289,10 @@ const EditPage = (props)=>{
|
||||
text={currentBrew.text}
|
||||
style={currentBrew.style}
|
||||
renderer={currentBrew.renderer}
|
||||
theme={currentBrew.theme}
|
||||
themeBundle={themeBundle}
|
||||
errors={HTMLErrors}
|
||||
lang={currentBrew.lang}
|
||||
onPageChange={setCurrentBrewRendererPageNum}
|
||||
currentEditorViewPageNum={currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={currentBrewRendererPageNum}
|
||||
allowPrint={true}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import './errorPage.less';
|
||||
import React from 'react';
|
||||
import UIPage from '../basePages/uiPage/uiPage.jsx';
|
||||
import Markdown from '@shared/markdown.js';
|
||||
import { hbfm } from 'hbmarkedwrapper';
|
||||
import ErrorIndex from './errors/errorIndex.js';
|
||||
|
||||
const ErrorPage = ({ brew })=>{
|
||||
@@ -16,7 +16,7 @@ const ErrorPage = ({ brew })=>{
|
||||
<h4>{brew?.text || 'No error text'}</h4>
|
||||
</div>
|
||||
<hr />
|
||||
<div dangerouslySetInnerHTML={{ __html: Markdown.render(errorText) }} />
|
||||
<div dangerouslySetInnerHTML={{ __html: hbfm.render(errorText) }} />
|
||||
</div>
|
||||
</UIPage>
|
||||
);
|
||||
|
||||
@@ -2,26 +2,27 @@
|
||||
import './homePage.less';
|
||||
|
||||
// Common imports
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef, useEffectEvent } from 'react';
|
||||
import request from '../../utils/request-middleware.js';
|
||||
import Markdown from '@shared/markdown.js';
|
||||
import { hbfm } from 'hbmarkedwrapper';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { DEFAULT_BREW } from '../../../../server/brewDefaults.js';
|
||||
import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js';
|
||||
|
||||
import useCommonEditPageFunctions from '../../utils/commonEditPageFunctions.jsx'
|
||||
|
||||
import SplitPane from '@components/splitPane/splitPane.jsx';
|
||||
import Editor from '../../editor/editor.jsx';
|
||||
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
|
||||
|
||||
import Nav from '@navbar/nav.jsx';
|
||||
import Navbar from '@navbar/navbar.jsx';
|
||||
import NewBrewItem from '@navbar/newbrew.navitem.jsx';
|
||||
import AccountNavItem from '@navbar/account.navitem.jsx';
|
||||
import ErrorNavItem from '@navbar/error-navitem.jsx';
|
||||
import HelpNavItem from '@navbar/help.navitem.jsx';
|
||||
import VaultNavItem from '@navbar/vault.navitem.jsx';
|
||||
import PrintNavItem from '@navbar/print.navitem.jsx';
|
||||
import Nav from '@navbar/nav.jsx';
|
||||
import Navbar from '@navbar/navbar.jsx';
|
||||
import NewBrewItem from '@navbar/newbrew.navitem.jsx';
|
||||
import AccountNavItem from '@navbar/account.navitem.jsx';
|
||||
import ErrorNavItem from '@navbar/error-navitem.jsx';
|
||||
import HelpNavItem from '@navbar/help.navitem.jsx';
|
||||
import VaultNavItem from '@navbar/vault.navitem.jsx';
|
||||
import PrintNavItem from '@navbar/print.navitem.jsx';
|
||||
import RecentNavItems from '@navbar/recent.navitem.jsx';
|
||||
const { both: RecentNavItem } = RecentNavItems;
|
||||
|
||||
@@ -30,149 +31,45 @@ const { both: RecentNavItem } = RecentNavItems;
|
||||
import Headtags from '@vitreum/headtags.js';
|
||||
const Meta = Headtags.Meta;
|
||||
|
||||
const BREWKEY = 'homebrewery-new';
|
||||
const STYLEKEY = 'homebrewery-new-style';
|
||||
const SNIPKEY = 'homebrewery-new-snippets';
|
||||
const METAKEY = 'homebrewery-new-meta';
|
||||
const BREWKEY = 'HB_newPage_content';
|
||||
const STYLEKEY = 'HB_newPage_style';
|
||||
const SNIPKEY = 'HB_newPage_snippets';
|
||||
const METAKEY = 'HB_newPage_meta';
|
||||
|
||||
const useLocalStorage = false;
|
||||
const neverSaved = true;
|
||||
const sandbox = true;
|
||||
|
||||
const HomePage =(props)=>{
|
||||
props = {
|
||||
brew : DEFAULT_BREW,
|
||||
ver : '0.0.0',
|
||||
...props
|
||||
};
|
||||
|
||||
const [currentBrew, setCurrentBrew] = useState(props.brew);
|
||||
const [saveGoogle, setSaveGoogle] = useState(global.account?.googleId ? true : false);
|
||||
const [error, setError] = useState(undefined);
|
||||
const [HTMLErrors, setHTMLErrors] = useState(Markdown.validate(props.brew.text));
|
||||
const [HTMLErrors, setHTMLErrors] = useState(hbfm.validate(props.brew.text));
|
||||
const [currentEditorViewPageNum, setCurrentEditorViewPageNum] = useState(1);
|
||||
const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1);
|
||||
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
|
||||
const [themeBundle, setThemeBundle] = useState({});
|
||||
const [unsavedChanges, setUnsavedChanges] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [autoSaveEnabled, setAutoSaveEnable] = useState(false);
|
||||
|
||||
const editorRef = useRef(null);
|
||||
const lastSavedBrew = useRef(_.cloneDeep(props.brew));
|
||||
const unsavedChangesRef = useRef(unsavedChanges);
|
||||
const editorRef = useRef(null);
|
||||
const lastSavedBrew = useRef(_.cloneDeep(props.brew));
|
||||
|
||||
useEffect(()=>{
|
||||
fetchThemeBundle(setError, setThemeBundle, currentBrew.renderer, currentBrew.theme);
|
||||
|
||||
const handleControlKeys = (e)=>{
|
||||
if(!(e.ctrlKey || e.metaKey)) return;
|
||||
if(e.keyCode === 83) trySaveRef.current(true);
|
||||
if(e.keyCode === 80) printCurrentBrew();
|
||||
if([83, 80].includes(e.keyCode)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleControlKeys);
|
||||
window.onbeforeunload = ()=>{
|
||||
if(unsavedChangesRef.current)
|
||||
return 'You have unsaved changes!';
|
||||
};
|
||||
return ()=>{
|
||||
document.removeEventListener('keydown', handleControlKeys);
|
||||
window.onbeforeunload = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(()=>{
|
||||
unsavedChangesRef.current = unsavedChanges;
|
||||
}, [unsavedChanges]);
|
||||
|
||||
const save = ()=>{
|
||||
request.post('/api')
|
||||
.send(currentBrew)
|
||||
.end((err, res)=>{
|
||||
if(err) {
|
||||
setError(err);
|
||||
return;
|
||||
}
|
||||
const saved = res.body;
|
||||
window.location = `/edit/${saved.editId}`;
|
||||
const save = async (brew, saveToGoogle)=>{
|
||||
const res = await request
|
||||
.post(`/api${saveGoogle ? '?saveToGoogle=true' : ''}`)
|
||||
.send(brew)
|
||||
.catch((err)=>{
|
||||
console.error('Error Updating Local Brew');
|
||||
setError(err);
|
||||
});
|
||||
};
|
||||
if(!res) return;
|
||||
|
||||
useEffect(()=>{
|
||||
const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current);
|
||||
setUnsavedChanges(hasChange);
|
||||
|
||||
if(autoSaveEnabled) trySave(false, hasChange);
|
||||
}, [currentBrew]);
|
||||
|
||||
const handleSplitMove = ()=>{
|
||||
editorRef.current.update();
|
||||
};
|
||||
|
||||
const handleBrewChange = (field)=>(value, subfield)=>{ //'text', 'style', 'snippets', 'metadata'
|
||||
if(subfield == 'renderer' || subfield == 'theme')
|
||||
fetchThemeBundle(setError, setThemeBundle, value.renderer, value.theme);
|
||||
|
||||
//If there are HTML errors, run the validator on every change to give quick feedback
|
||||
if(HTMLErrors.length && (field == 'text' || field == 'snippets'))
|
||||
setHTMLErrors(Markdown.validate(value));
|
||||
|
||||
if(field == 'metadata') setCurrentBrew((prev)=>({ ...prev, ...value }));
|
||||
else setCurrentBrew((prev)=>({ ...prev, [field]: value }));
|
||||
|
||||
if(useLocalStorage) {
|
||||
if(field == 'text') localStorage.setItem(BREWKEY, value);
|
||||
if(field == 'style') localStorage.setItem(STYLEKEY, value);
|
||||
if(field == 'snippets') localStorage.setItem(SNIPKEY, value);
|
||||
if(field == 'metadata') localStorage.setItem(METAKEY, JSON.stringify({
|
||||
renderer : value.renderer,
|
||||
theme : value.theme,
|
||||
lang : value.lang
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const renderSaveButton = ()=>{
|
||||
// #1 - Currently saving, show SAVING
|
||||
if(isSaving)
|
||||
return <Nav.item className='save' icon='fas fa-spinner fa-spin'>saving...</Nav.item>;
|
||||
|
||||
// #2 - Unsaved changes exist, autosave is OFF and warning timer has expired, show AUTOSAVE WARNING
|
||||
// if(unsavedChanges && warnUnsavedChanges) {
|
||||
// resetWarnUnsavedTimer();
|
||||
// const elapsedTime = Math.round((new Date() - lastSavedTime) / 1000 / 60);
|
||||
// const text = elapsedTime === 0
|
||||
// ? 'Autosave is OFF.'
|
||||
// : `Autosave is OFF, and you haven't saved for ${elapsedTime} minutes.`;
|
||||
|
||||
// return <Nav.item className='save error' icon='fas fa-exclamation-circle'>
|
||||
// Reminder...
|
||||
// <div className='errorContainer'>{text}</div>
|
||||
// </Nav.item>;
|
||||
// }
|
||||
|
||||
// #3 - Unsaved changes exist, click to save, show SAVE NOW
|
||||
if(unsavedChanges)
|
||||
return <Nav.item className='save' onClick={save} color='blue' icon='fas fa-save'>save now</Nav.item>;
|
||||
|
||||
// #4 - No unsaved changes, autosave is ON, show AUTO-SAVED
|
||||
if(autoSaveEnabled)
|
||||
return <Nav.item className='save saved'>auto-saved</Nav.item>;
|
||||
|
||||
// #5 - No unsaved changes, and has never been saved, hide the button
|
||||
if(neverSaved)
|
||||
return <Nav.item className='save neverSaved' disabled={true}>save now</Nav.item>;
|
||||
|
||||
// DEFAULT - No unsaved changes, show SAVED
|
||||
return <Nav.item className='save saved'>saved</Nav.item>;
|
||||
};
|
||||
|
||||
const clearError = ()=>{
|
||||
setError(null);
|
||||
setIsSaving(false);
|
||||
const saved = res.body;
|
||||
window.onbeforeunload = null;
|
||||
window.location = `/edit/${saved.editId}`;
|
||||
};
|
||||
|
||||
const renderNavbar = ()=>{
|
||||
@@ -191,6 +88,33 @@ const HomePage =(props)=>{
|
||||
</Navbar>;
|
||||
};
|
||||
|
||||
const {
|
||||
handleSplitMove,
|
||||
handleBrewChange,
|
||||
clearError,
|
||||
renderSaveButton,
|
||||
unsavedChanges,
|
||||
trySave
|
||||
} = useCommonEditPageFunctions({
|
||||
saveGoogle,
|
||||
setError,
|
||||
setThemeBundle,
|
||||
HTMLErrors,
|
||||
setHTMLErrors,
|
||||
currentBrew,
|
||||
setCurrentBrew,
|
||||
useLocalStorage,
|
||||
BREWKEY,
|
||||
STYLEKEY,
|
||||
SNIPKEY,
|
||||
METAKEY,
|
||||
hbfm,
|
||||
sandbox,
|
||||
lastSavedBrew,
|
||||
editorRef,
|
||||
save,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='homePage sitePage'>
|
||||
<Meta name='google-site-verification' content='NwnAQSSJZzAT7N-p5MY6ydQ7Njm67dtbu73ZSyE5Fy4' />
|
||||
@@ -214,15 +138,14 @@ const HomePage =(props)=>{
|
||||
text={currentBrew.text}
|
||||
style={currentBrew.style}
|
||||
renderer={currentBrew.renderer}
|
||||
themeBundle={themeBundle}
|
||||
onPageChange={setCurrentBrewRendererPageNum}
|
||||
currentEditorViewPageNum={currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={currentBrewRendererPageNum}
|
||||
themeBundle={themeBundle}
|
||||
/>
|
||||
</SplitPane>
|
||||
</div>
|
||||
<div className={`floatingSaveButton${unsavedChanges ? ' show' : ''}`} onClick={save}>
|
||||
<div className={`floatingSaveButton${unsavedChanges ? ' show' : ''}`} onClick={()=>trySave(true, true, saveGoogle)}>
|
||||
Save current <i className='fas fa-save' />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,39 +2,41 @@
|
||||
import './newPage.less';
|
||||
|
||||
// Common imports
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef, useEffectEvent } from 'react';
|
||||
import request from '../../utils/request-middleware.js';
|
||||
import Markdown from '@shared/markdown.js';
|
||||
import { hbfm } from 'hbmarkedwrapper';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { DEFAULT_BREW } from '../../../../server/brewDefaults.js';
|
||||
import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '@shared/helpers.js';
|
||||
|
||||
import useCommonEditPageFunctions from '../../utils/commonEditPageFunctions.jsx'
|
||||
|
||||
import SplitPane from '@components/splitPane/splitPane.jsx';
|
||||
import Editor from '../../editor/editor.jsx';
|
||||
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
|
||||
|
||||
import Nav from '@navbar/nav.jsx';
|
||||
import Navbar from '@navbar/navbar.jsx';
|
||||
import NewBrewItem from '@navbar/newbrew.navitem.jsx';
|
||||
import AccountNavItem from '@navbar/account.navitem.jsx';
|
||||
import ErrorNavItem from '@navbar/error-navitem.jsx';
|
||||
import HelpNavItem from '@navbar/help.navitem.jsx';
|
||||
import VaultNavItem from '@navbar/vault.navitem.jsx';
|
||||
import PrintNavItem from '@navbar/print.navitem.jsx';
|
||||
import Nav from '@navbar/nav.jsx';
|
||||
import Navbar from '@navbar/navbar.jsx';
|
||||
import NewBrewItem from '@navbar/newbrew.navitem.jsx';
|
||||
import AccountNavItem from '@navbar/account.navitem.jsx';
|
||||
import ErrorNavItem from '@navbar/error-navitem.jsx';
|
||||
import HelpNavItem from '@navbar/help.navitem.jsx';
|
||||
import VaultNavItem from '@navbar/vault.navitem.jsx';
|
||||
import PrintNavItem from '@navbar/print.navitem.jsx';
|
||||
import RecentNavItems from '@navbar/recent.navitem.jsx';
|
||||
const { both: RecentNavItem } = RecentNavItems;
|
||||
|
||||
// Page specific imports
|
||||
|
||||
const BREWKEY = 'HB_newPage_content';
|
||||
const STYLEKEY = 'HB_newPage_style';
|
||||
const METAKEY = 'HB_newPage_metadata';
|
||||
const SNIPKEY = 'HB_newPage_snippets';
|
||||
const METAKEY = 'HB_newPage_meta';
|
||||
|
||||
const SAVEKEYPREFIX = 'HB_editor_defaultSave_';
|
||||
|
||||
const useLocalStorage = true;
|
||||
const neverSaved = true;
|
||||
const sandbox = true;
|
||||
|
||||
const NewPage = (props)=>{
|
||||
props = {
|
||||
@@ -43,43 +45,19 @@ const NewPage = (props)=>{
|
||||
};
|
||||
|
||||
const [currentBrew, setCurrentBrew] = useState(props.brew);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveGoogle, setSaveGoogle] = useState(global.account?.googleId ? true : false);
|
||||
const [error, setError] = useState(null);
|
||||
const [HTMLErrors, setHTMLErrors] = useState(Markdown.validate(props.brew.text));
|
||||
const [HTMLErrors, setHTMLErrors] = useState(hbfm.validate(props.brew.text));
|
||||
const [currentEditorViewPageNum, setCurrentEditorViewPageNum] = useState(1);
|
||||
const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1);
|
||||
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
|
||||
const [themeBundle, setThemeBundle] = useState({});
|
||||
const [unsavedChanges, setUnsavedChanges] = useState(false);
|
||||
const [autoSaveEnabled, setAutoSaveEnabled] = useState(false);
|
||||
|
||||
const editorRef = useRef(null);
|
||||
const lastSavedBrew = useRef(_.cloneDeep(props.brew));
|
||||
// const saveTimeout = useRef(null);
|
||||
// const warnUnsavedTimeout = useRef(null);
|
||||
const trySaveRef = useRef(null); // CTRL+S listener lives outside React and needs ref to use trySave with latest copy of brew
|
||||
const unsavedChangesRef = useRef(unsavedChanges); // Similarly, onBeforeUnload lives outside React and needs ref to unsavedChanges
|
||||
const editorRef = useRef(null);
|
||||
const lastSavedBrew = useRef(_.cloneDeep(props.brew));
|
||||
|
||||
useEffect(()=>{
|
||||
loadBrew();
|
||||
fetchThemeBundle(setError, setThemeBundle, currentBrew.renderer, currentBrew.theme);
|
||||
|
||||
const handleControlKeys = (e)=>{
|
||||
if(!(e.ctrlKey || e.metaKey)) return;
|
||||
if(e.keyCode === 83) trySaveRef.current(true);
|
||||
if(e.keyCode === 80) printCurrentBrew();
|
||||
if([83, 80].includes(e.keyCode)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleControlKeys);
|
||||
|
||||
return ()=>{
|
||||
document.removeEventListener('keydown', handleControlKeys);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadBrew = ()=>{
|
||||
@@ -111,63 +89,22 @@ const NewPage = (props)=>{
|
||||
window.history.replaceState({}, window.location.title, '/new/');
|
||||
};
|
||||
|
||||
useEffect(()=>{
|
||||
const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current);
|
||||
setUnsavedChanges(hasChange);
|
||||
|
||||
if(autoSaveEnabled) trySave(false, hasChange);
|
||||
}, [currentBrew]);
|
||||
|
||||
useEffect(()=>{
|
||||
trySaveRef.current = trySave;
|
||||
unsavedChangesRef.current = unsavedChanges;
|
||||
});
|
||||
|
||||
const handleSplitMove = ()=>{
|
||||
editorRef.current.update();
|
||||
};
|
||||
|
||||
const handleBrewChange = (field)=>(value, subfield)=>{ //'text', 'style', 'snippets', 'metadata'
|
||||
if(subfield == 'renderer' || subfield == 'theme')
|
||||
fetchThemeBundle(setError, setThemeBundle, value.renderer, value.theme);
|
||||
|
||||
//If there are HTML errors, run the validator on every change to give quick feedback
|
||||
if(HTMLErrors.length && (field == 'text' || field == 'snippets'))
|
||||
setHTMLErrors(Markdown.validate(value));
|
||||
|
||||
if(field == 'metadata') setCurrentBrew((prev)=>({ ...prev, ...value }));
|
||||
else setCurrentBrew((prev)=>({ ...prev, [field]: value }));
|
||||
|
||||
if(useLocalStorage) {
|
||||
if(field == 'text') localStorage.setItem(BREWKEY, value);
|
||||
if(field == 'style') localStorage.setItem(STYLEKEY, value);
|
||||
if(field == 'snippets') localStorage.setItem(SNIPKEY, value);
|
||||
if(field == 'metadata') localStorage.setItem(METAKEY, JSON.stringify({
|
||||
renderer : value.renderer,
|
||||
theme : value.theme,
|
||||
lang : value.lang
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const trySave = async ()=>{
|
||||
setIsSaving(true);
|
||||
|
||||
const updatedBrew = { ...currentBrew };
|
||||
splitTextStyleAndMetadata(updatedBrew);
|
||||
|
||||
const pageRegex = updatedBrew.renderer === 'legacy' ? /\\page/g : /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/gm;
|
||||
updatedBrew.pageCount = (updatedBrew.text.match(pageRegex) || []).length + 1;
|
||||
const save = async (brew, saveToGoogle)=>{
|
||||
//Prepare content to send to server
|
||||
const brewToSave = {
|
||||
...brew,
|
||||
text : brew.text.normalize('NFC'),
|
||||
pageCount : ((brew.renderer === 'legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/gm)) || []).length + 1,
|
||||
textBin : undefined
|
||||
};
|
||||
|
||||
const res = await request
|
||||
.post(`/api${saveGoogle ? '?saveToGoogle=true' : ''}`)
|
||||
.send(updatedBrew)
|
||||
.send(brewToSave)
|
||||
.catch((err)=>{
|
||||
setIsSaving(false);
|
||||
console.error('Error Updating Local Brew');
|
||||
setError(err);
|
||||
});
|
||||
|
||||
setIsSaving(false);
|
||||
if(!res) return;
|
||||
|
||||
const savedBrew = res.body;
|
||||
@@ -175,49 +112,10 @@ const NewPage = (props)=>{
|
||||
localStorage.removeItem(BREWKEY);
|
||||
localStorage.removeItem(STYLEKEY);
|
||||
localStorage.removeItem(METAKEY);
|
||||
window.onbeforeunload = null;
|
||||
window.location = `/edit/${savedBrew.editId}`;
|
||||
};
|
||||
|
||||
const renderSaveButton = ()=>{
|
||||
// #1 - Currently saving, show SAVING
|
||||
if(isSaving)
|
||||
return <Nav.item className='save' icon='fas fa-spinner fa-spin'>saving...</Nav.item>;
|
||||
|
||||
// #2 - Unsaved changes exist, autosave is OFF and warning timer has expired, show AUTOSAVE WARNING
|
||||
// if(unsavedChanges && warnUnsavedChanges) {
|
||||
// resetWarnUnsavedTimer();
|
||||
// const elapsedTime = Math.round((new Date() - lastSavedTime) / 1000 / 60);
|
||||
// const text = elapsedTime === 0
|
||||
// ? 'Autosave is OFF.'
|
||||
// : `Autosave is OFF, and you haven't saved for ${elapsedTime} minutes.`;
|
||||
|
||||
// return <Nav.item className='save error' icon='fas fa-exclamation-circle'>
|
||||
// Reminder...
|
||||
// <div className='errorContainer'>{text}</div>
|
||||
// </Nav.item>;
|
||||
// }
|
||||
|
||||
// #3 - Unsaved changes exist, click to save, show SAVE NOW
|
||||
if(unsavedChanges)
|
||||
return <Nav.item className='save' onClick={trySave} color='blue' icon='fas fa-save'>save now</Nav.item>;
|
||||
|
||||
// #4 - No unsaved changes, autosave is ON, show AUTO-SAVED
|
||||
if(autoSaveEnabled)
|
||||
return <Nav.item className='save saved'>auto-saved</Nav.item>;
|
||||
|
||||
// #5 - No unsaved changes, and has never been saved, hide the button
|
||||
if(neverSaved)
|
||||
return <Nav.item className='save neverSaved' disabled={true}>save now</Nav.item>;
|
||||
|
||||
// DEFAULT - No unsaved changes, show SAVED
|
||||
return <Nav.item className='save saved'>saved</Nav.item>;
|
||||
};
|
||||
|
||||
const clearError = ()=>{
|
||||
setError(null);
|
||||
setIsSaving(false);
|
||||
};
|
||||
|
||||
const renderNavbar = ()=>(
|
||||
<Navbar>
|
||||
<Nav.section>
|
||||
@@ -238,6 +136,31 @@ const NewPage = (props)=>{
|
||||
</Navbar>
|
||||
);
|
||||
|
||||
const {
|
||||
handleSplitMove,
|
||||
handleBrewChange,
|
||||
clearError,
|
||||
renderSaveButton
|
||||
} = useCommonEditPageFunctions({
|
||||
saveGoogle,
|
||||
setError,
|
||||
setThemeBundle,
|
||||
HTMLErrors,
|
||||
setHTMLErrors,
|
||||
currentBrew,
|
||||
setCurrentBrew,
|
||||
useLocalStorage,
|
||||
BREWKEY,
|
||||
STYLEKEY,
|
||||
SNIPKEY,
|
||||
METAKEY,
|
||||
hbfm,
|
||||
sandbox,
|
||||
lastSavedBrew,
|
||||
editorRef,
|
||||
save,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='newPage sitePage'>
|
||||
{renderNavbar()}
|
||||
@@ -260,12 +183,10 @@ const NewPage = (props)=>{
|
||||
text={currentBrew.text}
|
||||
style={currentBrew.style}
|
||||
renderer={currentBrew.renderer}
|
||||
theme={currentBrew.theme}
|
||||
themeBundle={themeBundle}
|
||||
errors={HTMLErrors}
|
||||
lang={currentBrew.lang}
|
||||
onPageChange={setCurrentBrewRendererPageNum}
|
||||
currentEditorViewPageNum={currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={currentBrewRendererPageNum}
|
||||
allowPrint={true}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import React, { useState, useEffect, useEffectEvent, useRef } from 'react';
|
||||
import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js';
|
||||
import _ from 'lodash';
|
||||
import Nav from '@navbar/nav.jsx';
|
||||
|
||||
const AUTOSAVE_KEY = 'HB_editor_autoSaveOn';
|
||||
|
||||
const SAVE_TIMEOUT = 10000; //Autosave 10 seconds after last change
|
||||
const UNSAVED_WARNING_TIMEOUT = 900000; //Warn user afer 15 minutes of unsaved changes
|
||||
const UNSAVED_WARNING_POPUP_TIMEOUT = 4000; //Show the warning for 4 seconds
|
||||
|
||||
export default function useCommonEditPageFunctions(dependencies) {
|
||||
const {
|
||||
saveGoogle,
|
||||
setError,
|
||||
setThemeBundle,
|
||||
HTMLErrors,
|
||||
setHTMLErrors,
|
||||
currentBrew,
|
||||
setCurrentBrew,
|
||||
useLocalStorage,
|
||||
BREWKEY,
|
||||
STYLEKEY,
|
||||
SNIPKEY,
|
||||
METAKEY,
|
||||
hbfm,
|
||||
sandbox,
|
||||
lastSavedBrew,
|
||||
editorRef,
|
||||
save,
|
||||
} = dependencies;
|
||||
|
||||
const [isSaving , setIsSaving] = useState(false);
|
||||
const [lastSavedTime , setLastSavedTime] = useState(new Date());
|
||||
const [autoSaveEnabled , setAutoSaveEnabled] = useState(!sandbox);
|
||||
const [warnUnsavedChanges, setWarnUnsavedChanges] = useState(true);
|
||||
const [unsavedChanges , setUnsavedChanges] = useState(false);
|
||||
|
||||
const unsavedChangesRef = useRef(unsavedChanges); // onBeforeUnload lives outside React and needs ref to unsavedChanges
|
||||
const warnUnsavedTimeout = useRef(null); // timers live outside React and need ref to consistently track time
|
||||
const saveTimeout = useRef(null);
|
||||
|
||||
//==--------- Page setup ----------==//
|
||||
useEffect(()=>{
|
||||
const autoSavePref = !sandbox && JSON.parse(localStorage.getItem(AUTOSAVE_KEY) ?? true);
|
||||
setAutoSaveEnabled(autoSavePref);
|
||||
setWarnUnsavedChanges(!autoSavePref);
|
||||
setHTMLErrors(hbfm.validate(currentBrew.text));
|
||||
fetchThemeBundle(setError, setThemeBundle, currentBrew.renderer, currentBrew.theme);
|
||||
|
||||
const handleControlKeys = (e)=>{
|
||||
if(!(e.ctrlKey || e.metaKey)) return;
|
||||
if(e.keyCode === 83) trySave(true, true, saveGoogle);
|
||||
if(e.keyCode === 80) printCurrentBrew();
|
||||
if([83, 80].includes(e.keyCode)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleControlKeys);
|
||||
window.onbeforeunload = ()=>{
|
||||
if(unsavedChangesRef.current)
|
||||
return 'You have unsaved changes!';
|
||||
};
|
||||
return ()=>{
|
||||
document.removeEventListener('keydown', handleControlKeys);
|
||||
window.onBeforeUnload = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
//======----- Check for unsaved changes and autosave if enabled -----======
|
||||
useEffect(()=>{
|
||||
const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current);
|
||||
setUnsavedChanges(hasChange);
|
||||
unsavedChangesRef.current = hasChange;
|
||||
|
||||
if(autoSaveEnabled) trySave(false, hasChange, saveGoogle);
|
||||
}, [currentBrew]);
|
||||
|
||||
const resetWarnUnsavedTimer = ()=>{
|
||||
setTimeout(()=>setWarnUnsavedChanges(false), UNSAVED_WARNING_POPUP_TIMEOUT); // Hide the warning after 4 seconds
|
||||
clearTimeout(warnUnsavedTimeout.current);
|
||||
warnUnsavedTimeout.current = setTimeout(()=>setWarnUnsavedChanges(true), UNSAVED_WARNING_TIMEOUT); // 15 minutes between unsaved work warnings
|
||||
};
|
||||
|
||||
const handleSplitMove = ()=>{
|
||||
editorRef.current.update();
|
||||
};
|
||||
|
||||
const handleBrewChange = (field)=>(value, subfield)=>{ //'text', 'style', 'snippets', 'metadata'
|
||||
if(subfield == 'renderer' || subfield == 'theme')
|
||||
fetchThemeBundle(setError, setThemeBundle, value.renderer, value.theme);
|
||||
|
||||
//If there are HTML errors, run the validator on every change to give quick feedback
|
||||
if(HTMLErrors.length && (field == 'text' || field == 'snippets'))
|
||||
setHTMLErrors(hbfm.validate(value));
|
||||
|
||||
if(field == 'metadata') setCurrentBrew((prev)=>({ ...prev, ...value }));
|
||||
else setCurrentBrew((prev)=>({ ...prev, [field]: value }));
|
||||
|
||||
if(useLocalStorage) {
|
||||
if(field == 'text') localStorage.setItem(BREWKEY, value);
|
||||
if(field == 'style') localStorage.setItem(STYLEKEY, value);
|
||||
if(field == 'snippets') localStorage.setItem(SNIPKEY, value);
|
||||
if(field == 'metadata') localStorage.setItem(METAKEY, JSON.stringify({
|
||||
renderer : value.renderer,
|
||||
theme : value.theme,
|
||||
lang : value.lang
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAutoSave = ()=>{
|
||||
clearTimeout(warnUnsavedTimeout.current);
|
||||
clearTimeout(saveTimeout.current);
|
||||
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(!autoSaveEnabled));
|
||||
setAutoSaveEnabled(!autoSaveEnabled);
|
||||
setWarnUnsavedChanges(autoSaveEnabled);
|
||||
};
|
||||
|
||||
const clearError = ()=>{
|
||||
setError(null);
|
||||
setIsSaving(false);
|
||||
};
|
||||
|
||||
const trySave = useEffectEvent((forceSave = false, hasChanges = true, saveToGoogle = false)=>{
|
||||
clearTimeout(saveTimeout.current);
|
||||
if(isSaving) return;
|
||||
if(!forceSave && !hasChanges) return;
|
||||
const newTimeout = forceSave ? 0 : SAVE_TIMEOUT;
|
||||
|
||||
saveTimeout.current = setTimeout(async ()=>{
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
await save(currentBrew, saveToGoogle)
|
||||
.catch((err)=>{
|
||||
setError(err);
|
||||
});
|
||||
setIsSaving(false);
|
||||
setLastSavedTime(new Date());
|
||||
if(!autoSaveEnabled) resetWarnUnsavedTimer();
|
||||
}, newTimeout);
|
||||
});
|
||||
|
||||
const renderSaveButton = ()=>{
|
||||
if(isSaving)
|
||||
return <Nav.item className='save' icon='fas fa-spinner fa-spin'>saving...</Nav.item>;
|
||||
|
||||
if(unsavedChanges && warnUnsavedChanges) {
|
||||
resetWarnUnsavedTimer();
|
||||
const elapsedTime = Math.round((new Date() - lastSavedTime) / 1000 / 60);
|
||||
const text = elapsedTime === 0
|
||||
? `Autosave is OFF${sandbox ? ' for this sandbox page' : ''}.`
|
||||
: `Autosave is OFF${sandbox ? ' for this sandbox page' : ''}, and you haven't saved for ${elapsedTime} minutes.`;
|
||||
|
||||
return <Nav.item className='save error' icon='fas fa-exclamation-circle'>
|
||||
Reminder...
|
||||
<div className='errorContainer'>{text}</div>
|
||||
</Nav.item>;
|
||||
}
|
||||
|
||||
if(unsavedChanges)
|
||||
return <Nav.item className='save' onClick={()=>trySave(true, true, saveGoogle)} color='blue' icon='fas fa-save'>save now</Nav.item>;
|
||||
|
||||
if(autoSaveEnabled)
|
||||
return <Nav.item className='save saved'>auto-saved</Nav.item>;
|
||||
|
||||
if(sandbox)
|
||||
return <Nav.item className='save neverSaved' disabled={true}>save now</Nav.item>;
|
||||
|
||||
return <Nav.item className='save saved'>saved</Nav.item>;
|
||||
};
|
||||
|
||||
return {
|
||||
handleSplitMove,
|
||||
handleBrewChange,
|
||||
toggleAutoSave,
|
||||
clearError,
|
||||
trySave,
|
||||
renderSaveButton,
|
||||
autoSaveEnabled,
|
||||
unsavedChanges,
|
||||
}
|
||||
}
|
||||
Generated
+3902
-2094
File diff suppressed because it is too large
Load Diff
+7
-5
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "homebrewery",
|
||||
"description": "Create authentic looking D&D homebrews using only markdown",
|
||||
"version": "3.22.0",
|
||||
"version": "3.23.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"npm": ">=10.8 <12",
|
||||
@@ -70,10 +70,10 @@
|
||||
],
|
||||
"coverageThreshold": {
|
||||
"global": {
|
||||
"statements": 50,
|
||||
"branches": 40,
|
||||
"functions": 40,
|
||||
"lines": 50
|
||||
"statements": 40,
|
||||
"branches": 25,
|
||||
"functions": 30,
|
||||
"lines": 40
|
||||
},
|
||||
"server/homebrew.api.js": {
|
||||
"statements": 60,
|
||||
@@ -123,6 +123,7 @@
|
||||
"fflate": "^0.8.3",
|
||||
"fs-extra": "^11.3.5",
|
||||
"hash-wasm": "^4.12.0",
|
||||
"hbmarkedwrapper": "^1.0.0",
|
||||
"idb-keyval": "^6.2.5",
|
||||
"js-yaml": "^5.3.0",
|
||||
"jwt-simple": "^0.5.6",
|
||||
@@ -145,6 +146,7 @@
|
||||
"nanoid": "6.0.1",
|
||||
"nconf": "^0.13.0",
|
||||
"node": "^26.7.0",
|
||||
"prettier": "^3.8.1",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-frame-component": "^5.3.2",
|
||||
|
||||
+9
-354
@@ -16,18 +16,15 @@ import path from 'path';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import api from './homebrew.api.js';
|
||||
const { homebrewApi, getBrew, getUsersBrewThemes, getCSS } = api;
|
||||
const { homebrewApi, getBrew, getCSS } = api;
|
||||
import adminApi from './admin.api.js';
|
||||
import vaultApi from './vault.api.js';
|
||||
import GoogleActions from './googleActions.js';
|
||||
import pageRoutes from './page-routes.js';
|
||||
|
||||
import serveCompressedStaticAssets from './static-assets.mv.js';
|
||||
import sanitizeFilename from 'sanitize-filename';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import { model as HomebrewModel } from './homebrew.model.js';
|
||||
|
||||
import { DEFAULT_BREW } from './brewDefaults.js';
|
||||
import { splitTextStyleAndMetadata } from '../shared/helpers.js';
|
||||
|
||||
//==== Middleware Imports ====//
|
||||
import contentNegotiation from './middleware/content-negotiation.js';
|
||||
import bodyParser from 'body-parser';
|
||||
@@ -116,12 +113,6 @@ export default async function createApp(vite) {
|
||||
app.use(adminApi(vite));
|
||||
app.use(vaultApi);
|
||||
|
||||
const welcomeText = fs.readFileSync('./client/homebrew/pages/homePage/welcome_msg.md', 'utf8');
|
||||
const welcomeTextLegacy = fs.readFileSync('./client/homebrew/pages/homePage/welcome_msg_legacy.md', 'utf8');
|
||||
const migrateText = fs.readFileSync('./client/homebrew/pages/homePage/migrate.md', 'utf8');
|
||||
const changelogText = fs.readFileSync('changelog.md', 'utf8');
|
||||
const faqText = fs.readFileSync('faq.md', 'utf8');
|
||||
|
||||
String.prototype.replaceAll = function(s, r){return this.split(s).join(r);};
|
||||
|
||||
const defaultMetaTags = {
|
||||
@@ -132,134 +123,17 @@ export default async function createApp(vite) {
|
||||
type : 'website'
|
||||
};
|
||||
|
||||
app.use(pageRoutes({
|
||||
defaultMetaTags,
|
||||
HomebrewModel,
|
||||
sanitizeBrew,
|
||||
}));
|
||||
|
||||
//Robots.txt
|
||||
app.get('/robots.txt', (req, res)=>{
|
||||
return res.sendFile(`robots.txt`, { root: process.cwd() });
|
||||
});
|
||||
|
||||
//Home page
|
||||
app.get('/', (req, res, next)=>{
|
||||
req.brew = {
|
||||
text : welcomeText,
|
||||
renderer : 'V3',
|
||||
theme : '5ePHB'
|
||||
},
|
||||
|
||||
req.ogMeta = { ...defaultMetaTags,
|
||||
title : 'Homepage',
|
||||
description : 'Homepage'
|
||||
};
|
||||
|
||||
splitTextStyleAndMetadata(req.brew);
|
||||
return next();
|
||||
});
|
||||
|
||||
//Home page Legacy
|
||||
app.get('/legacy', (req, res, next)=>{
|
||||
req.brew = {
|
||||
text : welcomeTextLegacy,
|
||||
renderer : 'legacy',
|
||||
theme : '5ePHB'
|
||||
},
|
||||
|
||||
req.ogMeta = { ...defaultMetaTags,
|
||||
title : 'Homepage (Legacy)',
|
||||
description : 'Homepage'
|
||||
};
|
||||
|
||||
splitTextStyleAndMetadata(req.brew);
|
||||
return next();
|
||||
});
|
||||
|
||||
//Legacy/Other Document -> v3 Migration Guide
|
||||
app.get('/migrate', (req, res, next)=>{
|
||||
req.brew = {
|
||||
text : migrateText,
|
||||
renderer : 'V3',
|
||||
theme : '5ePHB'
|
||||
},
|
||||
|
||||
req.ogMeta = { ...defaultMetaTags,
|
||||
title : 'v3 Migration Guide',
|
||||
description : 'A brief guide to converting Legacy documents to the v3 renderer.'
|
||||
};
|
||||
|
||||
splitTextStyleAndMetadata(req.brew);
|
||||
return next();
|
||||
});
|
||||
|
||||
//Changelog page
|
||||
app.get('/changelog', async (req, res, next)=>{
|
||||
req.brew = {
|
||||
title : 'Changelog',
|
||||
text : changelogText,
|
||||
renderer : 'V3',
|
||||
theme : '5ePHB'
|
||||
},
|
||||
|
||||
req.ogMeta = { ...defaultMetaTags,
|
||||
title : 'Changelog',
|
||||
description : 'Development changelog.'
|
||||
};
|
||||
|
||||
splitTextStyleAndMetadata(req.brew);
|
||||
return next();
|
||||
});
|
||||
|
||||
//FAQ page
|
||||
app.get('/faq', async (req, res, next)=>{
|
||||
req.brew = {
|
||||
title : 'FAQ',
|
||||
text : faqText,
|
||||
renderer : 'V3',
|
||||
theme : '5ePHB'
|
||||
},
|
||||
|
||||
req.ogMeta = { ...defaultMetaTags,
|
||||
title : 'FAQ',
|
||||
description : 'Frequently Asked Questions'
|
||||
};
|
||||
|
||||
splitTextStyleAndMetadata(req.brew);
|
||||
return next();
|
||||
});
|
||||
|
||||
//Source page
|
||||
app.get('/source/:id', asyncHandler(getBrew('share')), (req, res)=>{
|
||||
const { brew } = req;
|
||||
|
||||
const replaceStrings = { '&': '&', '<': '<', '>': '>' };
|
||||
let text = brew.text;
|
||||
for (const replaceStr in replaceStrings) {
|
||||
text = text.replaceAll(replaceStr, replaceStrings[replaceStr]);
|
||||
}
|
||||
text = `<code><pre style="white-space: pre-wrap;">${text}</pre></code>`;
|
||||
res.status(200).send(text);
|
||||
});
|
||||
|
||||
//Download brew source page
|
||||
app.get('/download/:id', asyncHandler(getBrew('share')), (req, res)=>{
|
||||
const { brew } = req;
|
||||
sanitizeBrew(brew, 'share');
|
||||
const prefix = 'HB - ';
|
||||
|
||||
const encodeRFC3986ValueChars = (str)=>{
|
||||
return (
|
||||
encodeURIComponent(str)
|
||||
.replace(/[!'()*]/g, (char)=>{`%${char.charCodeAt(0).toString(16).toUpperCase()}`;})
|
||||
);
|
||||
};
|
||||
|
||||
let fileName = sanitizeFilename(`${prefix}${brew.title}`).replaceAll(' ', '');
|
||||
if(!fileName || !fileName.length) { fileName = `${prefix}-Untitled-Brew`; };
|
||||
res.set({
|
||||
'Cache-Control' : 'no-cache',
|
||||
'Content-Type' : 'text/plain',
|
||||
'Content-Disposition' : `attachment; filename*=UTF-8''${encodeRFC3986ValueChars(fileName)}.txt`
|
||||
});
|
||||
res.status(200).send(brew.text);
|
||||
});
|
||||
|
||||
//Serve brew metadata
|
||||
app.get('/metadata/:id', asyncHandler(getBrew('share')), (req, res)=>{
|
||||
const { brew } = req;
|
||||
@@ -280,78 +154,6 @@ export default async function createApp(vite) {
|
||||
//Serve brew styling
|
||||
app.get('/css/:id', asyncHandler(getBrew('share')), (req, res)=>{getCSS(req, res);});
|
||||
|
||||
//User Page
|
||||
app.get('/user/:username', dbCheck, async (req, res, next)=>{
|
||||
const ownAccount = req.account && (req.account.username == req.params.username);
|
||||
|
||||
req.ogMeta = { ...defaultMetaTags,
|
||||
title : `${req.params.username}'s Collection`,
|
||||
description : 'View my collection of homebrew on the Homebrewery.'
|
||||
// type : could be 'profile'?
|
||||
};
|
||||
|
||||
const fields = [
|
||||
'googleId',
|
||||
'title',
|
||||
'pageCount',
|
||||
'description',
|
||||
'authors',
|
||||
'lang',
|
||||
'published',
|
||||
'views',
|
||||
'shareId',
|
||||
'editId',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'lastViewed',
|
||||
'thumbnail',
|
||||
'tags'
|
||||
];
|
||||
|
||||
let brews = await HomebrewModel.getByUser(req.params.username, ownAccount, fields)
|
||||
.catch((err)=>{
|
||||
console.log(err);
|
||||
});
|
||||
|
||||
brews.forEach((brew)=>brew.stubbed = true); //All brews from MongoDB are "stubbed"
|
||||
|
||||
if(ownAccount && req?.account?.googleId){
|
||||
const auth = await GoogleActions.authCheck(req.account, res);
|
||||
let googleBrews = await GoogleActions.listGoogleBrews(auth)
|
||||
.catch((err)=>{
|
||||
console.error(err);
|
||||
});
|
||||
|
||||
// If stub matches file from Google, use Google metadata over stub metadata
|
||||
if(googleBrews && googleBrews.length > 0) {
|
||||
for (const brew of brews.filter((brew)=>brew.googleId)) {
|
||||
const match = googleBrews.findIndex((b)=>b.editId === brew.editId);
|
||||
if(match !== -1) {
|
||||
brew.googleId = googleBrews[match].googleId;
|
||||
brew.pageCount = googleBrews[match].pageCount;
|
||||
brew.renderer = googleBrews[match].renderer;
|
||||
brew.version = googleBrews[match].version;
|
||||
brew.webViewLink = googleBrews[match].webViewLink;
|
||||
googleBrews.splice(match, 1);
|
||||
}
|
||||
}
|
||||
|
||||
//Remaining unstubbed google brews display current user as author
|
||||
googleBrews = googleBrews.map((brew)=>({ ...brew, authors: [req.account.username] }));
|
||||
brews = _.concat(brews, googleBrews);
|
||||
}
|
||||
}
|
||||
|
||||
req.brews = _.map(brews, (brew)=>{
|
||||
// Clean up brew data
|
||||
brew.title = brew.title?.trim();
|
||||
brew.description = brew.description?.trim();
|
||||
return sanitizeBrew(brew, ownAccount ? 'edit' : 'share');
|
||||
});
|
||||
|
||||
return next();
|
||||
});
|
||||
|
||||
//Change author name on brews
|
||||
app.put('/api/user/rename', dbCheck, async (req, res)=>{
|
||||
const { username, newUsername } = req.body;
|
||||
@@ -380,144 +182,6 @@ export default async function createApp(vite) {
|
||||
}
|
||||
});
|
||||
|
||||
//Edit Page
|
||||
app.get('/edit/:id', asyncHandler(getBrew('edit')), asyncHandler(async(req, res, next)=>{
|
||||
req.brew = req.brew.toObject ? req.brew.toObject() : req.brew;
|
||||
|
||||
req.userThemes = await(getUsersBrewThemes(req.account?.username));
|
||||
|
||||
req.ogMeta = { ...defaultMetaTags,
|
||||
title : req.brew.title || 'Untitled Brew',
|
||||
description : req.brew.description || 'No description.',
|
||||
image : req.brew.thumbnail || defaultMetaTags.image,
|
||||
locale : req.brew.lang,
|
||||
type : 'article'
|
||||
};
|
||||
|
||||
sanitizeBrew(req.brew, 'edit');
|
||||
splitTextStyleAndMetadata(req.brew);
|
||||
res.header('Cache-Control', 'no-cache, no-store'); //reload the latest saved brew when pressing back button, not the cached version before save.
|
||||
return next();
|
||||
}));
|
||||
|
||||
//New Page from ID
|
||||
app.get('/new/:id', asyncHandler(getBrew('share')), asyncHandler(async(req, res, next)=>{
|
||||
sanitizeBrew(req.brew, 'share');
|
||||
splitTextStyleAndMetadata(req.brew);
|
||||
const brew = {
|
||||
shareId : req.brew.shareId,
|
||||
title : `CLONE - ${req.brew.title}`,
|
||||
text : req.brew.text,
|
||||
style : req.brew.style,
|
||||
renderer : req.brew.renderer,
|
||||
theme : req.brew.theme,
|
||||
tags : req.brew.tags,
|
||||
snippets : req.brew.snippets
|
||||
};
|
||||
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', dbCheck, asyncHandler(getBrew('share')), asyncHandler(async (req, res, next)=>{
|
||||
const { brew } = req;
|
||||
req.ogMeta = { ...defaultMetaTags,
|
||||
title : `${req.brew.title || 'Untitled Brew'} - ${req.brew.authors[0] || 'No author.'}`,
|
||||
description : req.brew.description || 'No description.',
|
||||
image : req.brew.thumbnail || defaultMetaTags.image,
|
||||
type : 'article'
|
||||
};
|
||||
|
||||
// increase visitor view count, do not include visits by author(s)
|
||||
if(!brew.authors.includes(req.account?.username)){
|
||||
if(req.params.id.length > 12 && !brew._id) {
|
||||
const googleId = brew.googleId;
|
||||
const shareId = brew.shareId;
|
||||
await GoogleActions.increaseView(googleId, shareId, 'share', brew)
|
||||
.catch((err)=>{next(err);});
|
||||
} else {
|
||||
await HomebrewModel.increaseView({ shareId: brew.shareId });
|
||||
}
|
||||
};
|
||||
|
||||
brew.authors.includes(req.account?.username) ? sanitizeBrew(req.brew, 'shareAuthor') : sanitizeBrew(req.brew, 'share');
|
||||
splitTextStyleAndMetadata(req.brew);
|
||||
return next();
|
||||
}));
|
||||
|
||||
//Account Page
|
||||
app.get('/account', dbCheck, asyncHandler(async (req, res, next)=>{
|
||||
const data = {};
|
||||
data.title = 'Account Information Page';
|
||||
|
||||
if(!req.account) {
|
||||
res.set('WWW-Authenticate', 'Bearer realm="Authorization Required"');
|
||||
const error = new Error('No valid account');
|
||||
error.status = 401;
|
||||
error.HBErrorCode = '50';
|
||||
error.page = data.title;
|
||||
return next(error);
|
||||
};
|
||||
|
||||
let auth;
|
||||
let googleCount = [];
|
||||
if(req.account) {
|
||||
if(req.account.googleId) {
|
||||
auth = await GoogleActions.authCheck(req.account, res, false);
|
||||
|
||||
googleCount = await GoogleActions.listGoogleBrews(auth)
|
||||
.catch((err)=>{
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
const query = { authors: req.account.username, googleId: { $exists: false } };
|
||||
const mongoCount = await HomebrewModel.countDocuments(query)
|
||||
.catch((err)=>{
|
||||
console.log(err);
|
||||
return 0;
|
||||
});
|
||||
|
||||
data.accountDetails = {
|
||||
username : req.account.username,
|
||||
issued : req.account.issued,
|
||||
googleId : Boolean(req.account.googleId),
|
||||
authCheck : Boolean(req.account.googleId && auth?.credentials.access_token),
|
||||
mongoCount : mongoCount,
|
||||
googleCount : googleCount?.length
|
||||
};
|
||||
}
|
||||
|
||||
req.brew = data;
|
||||
|
||||
req.ogMeta = { ...defaultMetaTags,
|
||||
title : `Account Page`,
|
||||
description : null
|
||||
};
|
||||
|
||||
return next();
|
||||
}));
|
||||
|
||||
// Local only
|
||||
if(isLocalEnvironment){
|
||||
// Login
|
||||
@@ -534,15 +198,6 @@ export default async function createApp(vite) {
|
||||
app.use('/staticImages', express.static(config.get('hb_images') && fs.existsSync(config.get('hb_images')) ? config.get('hb_images') :'staticImages'));
|
||||
app.use('/staticFonts', express.static(config.get('hb_fonts') && fs.existsSync(config.get('hb_fonts')) ? config.get('hb_fonts'):'staticFonts'));
|
||||
|
||||
//Vault Page
|
||||
app.get('/vault', asyncHandler(async(req, res, next)=>{
|
||||
req.ogMeta = { ...defaultMetaTags,
|
||||
title : 'The Vault',
|
||||
description : 'Search for Brews'
|
||||
};
|
||||
return next();
|
||||
}));
|
||||
|
||||
//Send rendered page
|
||||
app.use(asyncHandler(async (req, res, next)=>{
|
||||
if(!req.route) return res.redirect('/'); // Catch-all for invalid routes
|
||||
|
||||
@@ -4,7 +4,7 @@ import { model as HomebrewModel } from './homebrew.model.js';
|
||||
import express from 'express';
|
||||
import zlib from 'zlib';
|
||||
import GoogleActions from './googleActions.js';
|
||||
import Markdown from '../shared/markdown.js';
|
||||
import { hbfm } from 'hbmarkedwrapper';
|
||||
import * as yaml from 'js-yaml';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import { nanoid } from 'nanoid';
|
||||
@@ -233,7 +233,7 @@ const api = {
|
||||
return text;
|
||||
},
|
||||
getGoodBrewTitle : (text)=>{
|
||||
const tokens = Markdown.marked.lexer(text);
|
||||
const tokens = hbfm.marked.lexer(text);
|
||||
return (tokens.find((token)=>token.type === 'heading' || token.type === 'paragraph')?.text || 'No Title')
|
||||
.slice(0, MAX_TITLE_LENGTH);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
|
||||
// page-routes.js
|
||||
|
||||
import { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
process.chdir(`${__dirname}/..`);
|
||||
|
||||
import _ from 'lodash';
|
||||
import express from 'express';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import fs from 'fs';
|
||||
|
||||
//==== Middleware Imports ====//
|
||||
import dbCheck from './middleware/dbCheck.js';
|
||||
import sanitizeFilename from 'sanitize-filename';
|
||||
import { DEFAULT_BREW } from './brewDefaults.js';
|
||||
import { splitTextStyleAndMetadata } from '../shared/helpers.js';
|
||||
import GoogleActions from './googleActions.js';
|
||||
|
||||
import api from './homebrew.api.js';
|
||||
const { getBrew, getUsersBrewThemes } = api;
|
||||
|
||||
const welcomeText = fs.readFileSync('./client/homebrew/pages/homePage/welcome_msg.md', 'utf8');
|
||||
const welcomeTextLegacy = fs.readFileSync('./client/homebrew/pages/homePage/welcome_msg_legacy.md', 'utf8');
|
||||
const migrateText = fs.readFileSync('./client/homebrew/pages/homePage/migrate.md', 'utf8');
|
||||
const changelogText = fs.readFileSync('changelog.md', 'utf8');
|
||||
const faqText = fs.readFileSync('faq.md', 'utf8');
|
||||
|
||||
export default function pageRoutes({
|
||||
defaultMetaTags,
|
||||
HomebrewModel,
|
||||
sanitizeBrew,
|
||||
}) {
|
||||
const app = express.Router();
|
||||
|
||||
//Home page
|
||||
app.get('/', (req, res, next)=>{
|
||||
req.brew = {
|
||||
text : welcomeText,
|
||||
renderer : 'V3',
|
||||
theme : '5ePHB'
|
||||
},
|
||||
|
||||
req.ogMeta = { ...defaultMetaTags,
|
||||
title : 'Homepage',
|
||||
description : 'Homepage'
|
||||
};
|
||||
|
||||
splitTextStyleAndMetadata(req.brew);
|
||||
return next();
|
||||
});
|
||||
|
||||
//Home page Legacy
|
||||
app.get('/legacy', (req, res, next)=>{
|
||||
req.brew = {
|
||||
text : welcomeTextLegacy,
|
||||
renderer : 'legacy',
|
||||
theme : '5ePHB'
|
||||
},
|
||||
|
||||
req.ogMeta = { ...defaultMetaTags,
|
||||
title : 'Homepage (Legacy)',
|
||||
description : 'Homepage'
|
||||
};
|
||||
|
||||
splitTextStyleAndMetadata(req.brew);
|
||||
return next();
|
||||
});
|
||||
|
||||
//Legacy/Other Document -> v3 Migration Guide
|
||||
app.get('/migrate', (req, res, next)=>{
|
||||
req.brew = {
|
||||
text : migrateText,
|
||||
renderer : 'V3',
|
||||
theme : '5ePHB'
|
||||
},
|
||||
|
||||
req.ogMeta = { ...defaultMetaTags,
|
||||
title : 'v3 Migration Guide',
|
||||
description : 'A brief guide to converting Legacy documents to the v3 renderer.'
|
||||
};
|
||||
|
||||
splitTextStyleAndMetadata(req.brew);
|
||||
return next();
|
||||
});
|
||||
|
||||
//Changelog page
|
||||
app.get('/changelog', async (req, res, next)=>{
|
||||
req.brew = {
|
||||
title : 'Changelog',
|
||||
text : changelogText,
|
||||
renderer : 'V3',
|
||||
theme : '5ePHB'
|
||||
},
|
||||
|
||||
req.ogMeta = { ...defaultMetaTags,
|
||||
title : 'Changelog',
|
||||
description : 'Development changelog.'
|
||||
};
|
||||
|
||||
splitTextStyleAndMetadata(req.brew);
|
||||
return next();
|
||||
});
|
||||
|
||||
//FAQ page
|
||||
app.get('/faq', async (req, res, next)=>{
|
||||
req.brew = {
|
||||
title : 'FAQ',
|
||||
text : faqText,
|
||||
renderer : 'V3',
|
||||
theme : '5ePHB'
|
||||
},
|
||||
|
||||
req.ogMeta = { ...defaultMetaTags,
|
||||
title : 'FAQ',
|
||||
description : 'Frequently Asked Questions'
|
||||
};
|
||||
|
||||
splitTextStyleAndMetadata(req.brew);
|
||||
return next();
|
||||
});
|
||||
|
||||
//Source page
|
||||
app.get('/source/:id', asyncHandler(getBrew('share')), (req, res)=>{
|
||||
const { brew } = req;
|
||||
|
||||
const replaceStrings = { '&': '&', '<': '<', '>': '>' };
|
||||
let text = brew.text;
|
||||
for (const replaceStr in replaceStrings) {
|
||||
text = text.replaceAll(replaceStr, replaceStrings[replaceStr]);
|
||||
}
|
||||
text = `<code><pre style="white-space: pre-wrap;">${text}</pre></code>`;
|
||||
res.status(200).send(text);
|
||||
});
|
||||
|
||||
//Download brew source page
|
||||
app.get('/download/:id', asyncHandler(getBrew('share')), (req, res)=>{
|
||||
const { brew } = req;
|
||||
sanitizeBrew(brew, 'share');
|
||||
const prefix = 'HB - ';
|
||||
|
||||
const encodeRFC3986ValueChars = (str)=>{
|
||||
return (
|
||||
encodeURIComponent(str)
|
||||
.replace(/[!'()*]/g, (char)=>{`%${char.charCodeAt(0).toString(16).toUpperCase()}`;})
|
||||
);
|
||||
};
|
||||
|
||||
let fileName = sanitizeFilename(`${prefix}${brew.title}`).replaceAll(' ', '');
|
||||
if(!fileName || !fileName.length) { fileName = `${prefix}-Untitled-Brew`; };
|
||||
res.set({
|
||||
'Cache-Control' : 'no-cache',
|
||||
'Content-Type' : 'text/plain',
|
||||
'Content-Disposition' : `attachment; filename*=UTF-8''${encodeRFC3986ValueChars(fileName)}.txt`
|
||||
});
|
||||
res.status(200).send(brew.text);
|
||||
});
|
||||
|
||||
//User Page
|
||||
app.get('/user/:username', dbCheck, async (req, res, next)=>{
|
||||
const ownAccount = req.account && (req.account.username == req.params.username);
|
||||
|
||||
req.ogMeta = { ...defaultMetaTags,
|
||||
title : `${req.params.username}'s Collection`,
|
||||
description : 'View my collection of homebrew on the Homebrewery.'
|
||||
// type : could be 'profile'?
|
||||
};
|
||||
|
||||
const fields = [
|
||||
'googleId',
|
||||
'title',
|
||||
'pageCount',
|
||||
'description',
|
||||
'authors',
|
||||
'lang',
|
||||
'published',
|
||||
'views',
|
||||
'shareId',
|
||||
'editId',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'lastViewed',
|
||||
'thumbnail',
|
||||
'tags'
|
||||
];
|
||||
|
||||
let brews = await HomebrewModel.getByUser(req.params.username, ownAccount, fields)
|
||||
.catch((err)=>{
|
||||
console.log(err);
|
||||
});
|
||||
|
||||
brews.forEach((brew)=>brew.stubbed = true); //All brews from MongoDB are "stubbed"
|
||||
|
||||
if(ownAccount && req?.account?.googleId){
|
||||
const auth = await GoogleActions.authCheck(req.account, res);
|
||||
let googleBrews = await GoogleActions.listGoogleBrews(auth)
|
||||
.catch((err)=>{
|
||||
console.error(err);
|
||||
});
|
||||
|
||||
// If stub matches file from Google, use Google metadata over stub metadata
|
||||
if(googleBrews && googleBrews.length > 0) {
|
||||
for (const brew of brews.filter((brew)=>brew.googleId)) {
|
||||
const match = googleBrews.findIndex((b)=>b.editId === brew.editId);
|
||||
if(match !== -1) {
|
||||
brew.googleId = googleBrews[match].googleId;
|
||||
brew.pageCount = googleBrews[match].pageCount;
|
||||
brew.renderer = googleBrews[match].renderer;
|
||||
brew.version = googleBrews[match].version;
|
||||
brew.webViewLink = googleBrews[match].webViewLink;
|
||||
googleBrews.splice(match, 1);
|
||||
}
|
||||
}
|
||||
|
||||
//Remaining unstubbed google brews display current user as author
|
||||
googleBrews = googleBrews.map((brew)=>({ ...brew, authors: [req.account.username] }));
|
||||
brews = _.concat(brews, googleBrews);
|
||||
}
|
||||
}
|
||||
|
||||
req.brews = _.map(brews, (brew)=>{
|
||||
// Clean up brew data
|
||||
brew.title = brew.title?.trim();
|
||||
brew.description = brew.description?.trim();
|
||||
return sanitizeBrew(brew, ownAccount ? 'edit' : 'share');
|
||||
});
|
||||
|
||||
return next();
|
||||
});
|
||||
|
||||
//Edit Page
|
||||
app.get('/edit/:id', asyncHandler(getBrew('edit')), asyncHandler(async(req, res, next)=>{
|
||||
req.brew = req.brew.toObject ? req.brew.toObject() : req.brew;
|
||||
|
||||
req.userThemes = await(getUsersBrewThemes(req.account?.username));
|
||||
|
||||
req.ogMeta = { ...defaultMetaTags,
|
||||
title : req.brew.title || 'Untitled Brew',
|
||||
description : req.brew.description || 'No description.',
|
||||
image : req.brew.thumbnail || defaultMetaTags.image,
|
||||
locale : req.brew.lang,
|
||||
type : 'article'
|
||||
};
|
||||
|
||||
sanitizeBrew(req.brew, 'edit');
|
||||
splitTextStyleAndMetadata(req.brew);
|
||||
res.header('Cache-Control', 'no-cache, no-store'); //reload the latest saved brew when pressing back button, not the cached version before save.
|
||||
return next();
|
||||
}));
|
||||
|
||||
//New Page from ID
|
||||
app.get('/new/:id', asyncHandler(getBrew('share')), asyncHandler(async(req, res, next)=>{
|
||||
sanitizeBrew(req.brew, 'share');
|
||||
splitTextStyleAndMetadata(req.brew);
|
||||
const brew = {
|
||||
shareId : req.brew.shareId,
|
||||
title : `CLONE - ${req.brew.title}`,
|
||||
text : req.brew.text,
|
||||
style : req.brew.style,
|
||||
renderer : req.brew.renderer,
|
||||
theme : req.brew.theme,
|
||||
tags : req.brew.tags,
|
||||
snippets : req.brew.snippets
|
||||
};
|
||||
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', dbCheck, asyncHandler(getBrew('share')), asyncHandler(async (req, res, next)=>{
|
||||
const { brew } = req;
|
||||
req.ogMeta = { ...defaultMetaTags,
|
||||
title : `${req.brew.title || 'Untitled Brew'} - ${req.brew.authors[0] || 'No author.'}`,
|
||||
description : req.brew.description || 'No description.',
|
||||
image : req.brew.thumbnail || defaultMetaTags.image,
|
||||
type : 'article'
|
||||
};
|
||||
|
||||
// increase visitor view count, do not include visits by author(s)
|
||||
if(!brew.authors.includes(req.account?.username)){
|
||||
if(req.params.id.length > 12 && !brew._id) {
|
||||
const googleId = brew.googleId;
|
||||
const shareId = brew.shareId;
|
||||
await GoogleActions.increaseView(googleId, shareId, 'share', brew)
|
||||
.catch((err)=>{next(err);});
|
||||
} else {
|
||||
await HomebrewModel.increaseView({ shareId: brew.shareId });
|
||||
}
|
||||
};
|
||||
|
||||
brew.authors.includes(req.account?.username) ? sanitizeBrew(req.brew, 'shareAuthor') : sanitizeBrew(req.brew, 'share');
|
||||
splitTextStyleAndMetadata(req.brew);
|
||||
return next();
|
||||
}));
|
||||
|
||||
//Account Page
|
||||
app.get('/account', dbCheck, asyncHandler(async (req, res, next)=>{
|
||||
const data = {};
|
||||
data.title = 'Account Information Page';
|
||||
|
||||
if(!req.account) {
|
||||
res.set('WWW-Authenticate', 'Bearer realm="Authorization Required"');
|
||||
const error = new Error('No valid account');
|
||||
error.status = 401;
|
||||
error.HBErrorCode = '50';
|
||||
error.page = data.title;
|
||||
return next(error);
|
||||
};
|
||||
|
||||
let auth;
|
||||
let googleCount = [];
|
||||
if(req.account) {
|
||||
if(req.account.googleId) {
|
||||
auth = await GoogleActions.authCheck(req.account, res, false);
|
||||
|
||||
googleCount = await GoogleActions.listGoogleBrews(auth)
|
||||
.catch((err)=>{
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
const query = { authors: req.account.username, googleId: { $exists: false } };
|
||||
const mongoCount = await HomebrewModel.countDocuments(query)
|
||||
.catch((err)=>{
|
||||
console.log(err);
|
||||
return 0;
|
||||
});
|
||||
|
||||
data.accountDetails = {
|
||||
username : req.account.username,
|
||||
issued : req.account.issued,
|
||||
googleId : Boolean(req.account.googleId),
|
||||
authCheck : Boolean(req.account.googleId && auth?.credentials.access_token),
|
||||
mongoCount : mongoCount,
|
||||
googleCount : googleCount?.length
|
||||
};
|
||||
}
|
||||
|
||||
req.brew = data;
|
||||
|
||||
req.ogMeta = { ...defaultMetaTags,
|
||||
title : `Account Page`,
|
||||
description : null
|
||||
};
|
||||
|
||||
return next();
|
||||
}));
|
||||
|
||||
//Vault Page
|
||||
app.get('/vault', asyncHandler(async(req, res, next)=>{
|
||||
req.ogMeta = { ...defaultMetaTags,
|
||||
title : 'The Vault',
|
||||
description : 'Search for Brews'
|
||||
};
|
||||
return next();
|
||||
}));
|
||||
|
||||
return app;
|
||||
}
|
||||
+1
-1
@@ -146,7 +146,7 @@ const printCurrentBrew = async ()=>{
|
||||
}
|
||||
};
|
||||
|
||||
const fetchThemeBundle = async (setError, setThemeBundle, renderer, theme)=>{
|
||||
const fetchThemeBundle = async (setError = ()=>{}, setThemeBundle = ()=>{}, renderer, theme)=>{
|
||||
if(!renderer || !theme) return;
|
||||
const res = await request
|
||||
.get(`/api/theme/${renderer}/${theme}`)
|
||||
|
||||
@@ -1,574 +0,0 @@
|
||||
|
||||
/* eslint-disable max-lines */
|
||||
import _ from 'lodash';
|
||||
import { marked as Marked } from 'marked';
|
||||
import MarkedExtendedTables from 'marked-extended-tables';
|
||||
import MarkedDefinitionLists from 'marked-definition-lists';
|
||||
import MarkedAlignedParagraphs from 'marked-alignment-paragraphs';
|
||||
import MarkedNonbreakingSpaces from 'marked-nonbreaking-spaces';
|
||||
import MarkedSubSuperText from 'marked-subsuper-text';
|
||||
import { markedVariables,
|
||||
setMarkedVariablePage,
|
||||
setMarkedVariable,
|
||||
getMarkedVariable } from 'marked-variables';
|
||||
import { markedSmartypantsLite as MarkedSmartypantsLite } from 'marked-smartypants-lite';
|
||||
import { gfmHeadingId as MarkedGFMHeadingId, resetHeadings as MarkedGFMResetHeadingIDs } from 'marked-gfm-heading-id';
|
||||
import { markedEmoji as MarkedEmojis } from 'marked-emoji';
|
||||
import MarkedDiagramsMarkdeep from 'marked-diagrams-markdeep';
|
||||
|
||||
|
||||
//Icon fonts included so they can appear in emoji autosuggest dropdown
|
||||
import diceFont from '../themes/fonts/iconFonts/diceFont.js';
|
||||
import elderberryInn from '../themes/fonts/iconFonts/elderberryInn.js';
|
||||
import gameIcons from '../themes/fonts/iconFonts/gameIcons.js';
|
||||
import fontAwesome from '../themes/fonts/iconFonts/fontAwesome.js';
|
||||
|
||||
const renderer = new Marked.Renderer();
|
||||
const tokenizer = new Marked.Tokenizer();
|
||||
|
||||
//Processes the markdown within an HTML block if it's just a class-wrapper
|
||||
renderer.html = function (token) {
|
||||
let html = token.text;
|
||||
if(_.startsWith(_.trim(html), '<div') && _.endsWith(_.trim(html), '</div>')){
|
||||
const openTag = html.substring(0, html.indexOf('>')+1);
|
||||
html = html.substring(html.indexOf('>')+1);
|
||||
html = html.substring(0, html.lastIndexOf('</div>'));
|
||||
|
||||
// Repeat the markdown processing for content inside the div, minus the preprocessing and postprocessing hooks which should only run once globally
|
||||
const opts = Marked.defaults;
|
||||
const tokens = Marked.lexer(html, opts);
|
||||
Marked.walkTokens(tokens, opts.walkTokens);
|
||||
return `${openTag} ${Marked.parser(tokens, opts)} </div>`;
|
||||
}
|
||||
return html;
|
||||
};
|
||||
|
||||
// Don't wrap {{ Spans alone on a line, or {{ Divs in <p> tags
|
||||
renderer.paragraph = function(token){
|
||||
let match;
|
||||
const text = this.parser.parseInline(token.tokens);
|
||||
if(text.startsWith('<div') || text.startsWith('</div'))
|
||||
return `${text}`;
|
||||
else if(match = text.match(/(^|^.*?\n)<span class="inline-block(.*?<\/span>)$/))
|
||||
return `${match[1].trim() ? `<p>${match[1]}</p>` : ''}<span class="inline-block${match[2]}`;
|
||||
else
|
||||
return `<p>${text}</p>\n`;
|
||||
};
|
||||
|
||||
//Fix local links in the Preview iFrame to link inside the frame
|
||||
renderer.link = function (token) {
|
||||
let { href, title, tokens } = token;
|
||||
const text = this.parser.parseInline(tokens);
|
||||
let self = false;
|
||||
if(href[0] == '#') {
|
||||
self = true;
|
||||
}
|
||||
href = cleanUrl(href);
|
||||
|
||||
if(href === null) {
|
||||
return text;
|
||||
}
|
||||
let out = `<a href="${escape(href)}"`;
|
||||
if(title) {
|
||||
out += ` title="${escape(title)}"`;
|
||||
}
|
||||
// if(self) {
|
||||
// out += ' target="_self"';
|
||||
// }
|
||||
out += `>${text}</a>`;
|
||||
return out;
|
||||
};
|
||||
|
||||
// Expose `src` attribute as `--HB_src` to make the URL accessible via CSS
|
||||
renderer.image = function (token) {
|
||||
const { href, title, text } = token;
|
||||
if(href === null)
|
||||
return text;
|
||||
|
||||
let out = `<img loading="lazy" src="${href}" alt="${text}" style="--HB_src:url(${href});"`;
|
||||
if(title)
|
||||
out += ` title="${title}"`;
|
||||
|
||||
out += '>';
|
||||
return out;
|
||||
};
|
||||
|
||||
// Disable default reflink behavior, as it steps on our variables extension
|
||||
tokenizer.def = function () {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const mustacheSpans = {
|
||||
name : 'mustacheSpans',
|
||||
level : 'inline', // Is this a block-level or inline-level tokenizer?
|
||||
start(src) { return src.match(/{{[^{]/)?.index; }, // Hint to Marked.js to stop and check for a match
|
||||
tokenizer(src, tokens) {
|
||||
const completeSpan = /^{{[^\n]*}}/; // Regex for the complete token
|
||||
const inlineRegex = /{{(?=((?:[:=](?:"['\w,\-+*/()#%=?.&:!@$^;:\[\]_= ]*"|[\w\-+*/()#%.]*)|[^"=':{}\s]*)*))\1 *|}}/g;
|
||||
const match = completeSpan.exec(src);
|
||||
if(match) {
|
||||
//Find closing delimiter
|
||||
let blockCount = 0;
|
||||
let tags = {};
|
||||
let endTags = 0;
|
||||
let endToken = 0;
|
||||
let delim;
|
||||
while (delim = inlineRegex.exec(match[0])) {
|
||||
if(_.isEmpty(tags)) {
|
||||
tags = processStyleTags(delim[0].substring(2));
|
||||
endTags = delim[0].length;
|
||||
}
|
||||
if(delim[0].startsWith('{{')) {
|
||||
blockCount++;
|
||||
} else if(delim[0] == '}}' && blockCount !== 0) {
|
||||
blockCount--;
|
||||
if(blockCount == 0) {
|
||||
endToken = inlineRegex.lastIndex;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(endToken) {
|
||||
const raw = src.slice(0, endToken);
|
||||
const text = raw.slice(endTags || -2, -2);
|
||||
|
||||
return { // Token to generate
|
||||
type : 'mustacheSpans', // Should match "name" above
|
||||
raw : raw, // Text to consume from the source
|
||||
text : text, // Additional custom properties
|
||||
tags : tags,
|
||||
tokens : this.lexer.inlineTokens(text) // inlineTokens to process **bold**, *italics*, etc.
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
renderer(token) {
|
||||
const tags = token.tags;
|
||||
tags.classes = ['inline-block', tags.classes].join(' ').trim();
|
||||
return `<span` +
|
||||
`${tags.classes ? ` class="${tags.classes}"` : ''}` +
|
||||
`${tags.id ? ` id="${tags.id}"` : ''}` +
|
||||
`${tags.styles ? ` style="${Object.entries(tags.styles).map(([key, value])=>`${key}:${value};`).join(' ')}"` : ''}` +
|
||||
`${tags.attributes ? ` ${Object.entries(tags.attributes).map(([key, value])=>`${key}="${value}"`).join(' ')}` : ''}` +
|
||||
`>${this.parser.parseInline(token.tokens)}</span>`; // parseInline to turn child tokens into HTML
|
||||
}
|
||||
};
|
||||
|
||||
const mustacheDivs = {
|
||||
name : 'mustacheDivs',
|
||||
level : 'block',
|
||||
start(src) { return src.match(/\n *{{[^{]/m)?.index; }, // Hint to Marked.js to stop and check for a match
|
||||
tokenizer(src, tokens) {
|
||||
const completeBlock = /^ *{{[^\n}]* *\n.*\n *}}/s; // Regex for the complete token
|
||||
const blockRegex = /^ *{{(?=((?:[:=](?:"['\w,\-+*/()#%=?.&:!@$^;:\[\]_= ]*"|[\w\-()#%.]*)|[^"=':{}\s]*)*))\1 *$|^ *}}$/gm;
|
||||
const match = completeBlock.exec(src);
|
||||
if(match) {
|
||||
//Find closing delimiter
|
||||
let blockCount = 0;
|
||||
let tags = {};
|
||||
let endTags = 0;
|
||||
let endToken = 0;
|
||||
let delim;
|
||||
while (delim = blockRegex.exec(match[0])?.[0].trim()) {
|
||||
if(_.isEmpty(tags)) {
|
||||
tags = processStyleTags(delim.substring(2));
|
||||
endTags = delim.length + src.indexOf(delim);
|
||||
}
|
||||
if(delim.startsWith('{{')) {
|
||||
blockCount++;
|
||||
} else if(delim == '}}' && blockCount !== 0) {
|
||||
blockCount--;
|
||||
if(blockCount == 0) {
|
||||
endToken = blockRegex.lastIndex;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(endToken) {
|
||||
const raw = src.slice(0, endToken);
|
||||
const text = raw.slice(endTags || -2, -2);
|
||||
return { // Token to generate
|
||||
type : 'mustacheDivs', // Should match "name" above
|
||||
raw : raw, // Text to consume from the source
|
||||
text : text, // Additional custom properties
|
||||
tags : tags,
|
||||
tokens : this.lexer.blockTokens(text)
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
renderer(token) {
|
||||
const tags = token.tags;
|
||||
tags.classes = ['block', tags.classes].join(' ').trim();
|
||||
return `<div` +
|
||||
`${tags.classes ? ` class="${tags.classes}"` : ''}` +
|
||||
`${tags.id ? ` id="${tags.id}"` : ''}` +
|
||||
`${tags.styles ? ` style="${Object.entries(tags.styles).map(([key, value])=>`${key}:${value};`).join(' ')}"` : ''}` +
|
||||
`${tags.attributes ? ` ${Object.entries(tags.attributes).map(([key, value])=>`${key}="${value}"`).join(' ')}` : ''}` +
|
||||
`>${this.parser.parse(token.tokens)}</div>`; // parse to turn child tokens into HTML
|
||||
}
|
||||
};
|
||||
|
||||
const mustacheInjectInline = {
|
||||
name : 'mustacheInjectInline',
|
||||
level : 'inline',
|
||||
start(src) { return src.match(/ *{[^{\n]/)?.index; }, // Hint to Marked.js to stop and check for a match
|
||||
tokenizer(src, tokens) {
|
||||
const inlineRegex = /^ *{(?=((?:[:=](?:"['\w,\-+*/()#%=?.&:!@$^;:\[\]_= ]*"|[\w\-()#%.]*)|[^"=':{}\s]*)*))\1}/g;
|
||||
const match = inlineRegex.exec(src);
|
||||
if(match) {
|
||||
const lastToken = tokens[tokens.length - 1];
|
||||
if(!lastToken || lastToken.type == 'mustacheInjectInline')
|
||||
return false;
|
||||
|
||||
const tags = processStyleTags(match[1]);
|
||||
lastToken.originalType = lastToken.type;
|
||||
lastToken.type = 'mustacheInjectInline';
|
||||
lastToken.injectedTags = tags;
|
||||
return {
|
||||
type : 'mustacheInjectInline', // Should match "name" above
|
||||
raw : match[0], // Text to consume from the source
|
||||
text : ''
|
||||
};
|
||||
}
|
||||
},
|
||||
renderer(token) {
|
||||
if(!token.originalType){
|
||||
return;
|
||||
}
|
||||
token.type = token.originalType;
|
||||
const text = this.parser.parseInline([token]);
|
||||
const originalTags = extractHTMLStyleTags(text);
|
||||
const injectedTags = token.injectedTags;
|
||||
const tags = mergeHTMLTags(originalTags, injectedTags);
|
||||
const openingTag = /(<[^\s<>]+)[^\n<>]*(>.*)/s.exec(text);
|
||||
if(openingTag) {
|
||||
return `${openingTag[1]}` +
|
||||
`${tags.classes ? ` class="${tags.classes}"` : ''}` +
|
||||
`${tags.id ? ` id="${tags.id}"` : ''}` +
|
||||
`${!_.isEmpty(tags.styles) ? ` style="${Object.entries(tags.styles).map(([key, value])=>`${key}:${value};`).join(' ')}"` : ''}` +
|
||||
`${!_.isEmpty(tags.attributes) ? ` ${Object.entries(tags.attributes).map(([key, value])=>`${key}="${value}"`).join(' ')}` : ''}` +
|
||||
`${openingTag[2]}`; // parse to turn child tokens into HTML
|
||||
}
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
const mustacheInjectBlock = {
|
||||
extensions : [{
|
||||
name : 'mustacheInjectBlock',
|
||||
level : 'block',
|
||||
start(src) { return src.match(/\n *{[^{\n]/m)?.index; }, // Hint to Marked.js to stop and check for a match
|
||||
tokenizer(src, tokens) {
|
||||
const inlineRegex = /^ *{(?=((?:[:=](?:"['\w,\-+*/()#%=?.&:!@$^;:\[\]_= ]*"|[\w\-+*/()#%.]*)|[^"=':{}\s]*)*))\1}/ym;
|
||||
const match = inlineRegex.exec(src);
|
||||
if(match) {
|
||||
const lastToken = tokens[tokens.length - 1];
|
||||
if(!lastToken || lastToken.type == 'mustacheInjectBlock')
|
||||
return false;
|
||||
|
||||
lastToken.originalType = 'mustacheInjectBlock';
|
||||
lastToken.injectedTags = processStyleTags(match[1]);
|
||||
return {
|
||||
type : 'mustacheInjectBlock', // Should match "name" above
|
||||
raw : match[0], // Text to consume from the source
|
||||
text : ''
|
||||
};
|
||||
}
|
||||
},
|
||||
renderer(token) {
|
||||
if(!token.originalType){
|
||||
return;
|
||||
}
|
||||
token.type = token.originalType;
|
||||
const text = this.parser.parse([token]);
|
||||
const originalTags = extractHTMLStyleTags(text);
|
||||
const injectedTags = token.injectedTags;
|
||||
const tags = mergeHTMLTags(originalTags, injectedTags);
|
||||
const openingTag = /(<[^\s<>]+)[^\n<>]*(>.*)/s.exec(text);
|
||||
if(openingTag) {
|
||||
return `${openingTag[1]}` +
|
||||
`${tags.classes ? ` class="${tags.classes}"` : ''}` +
|
||||
`${tags.id ? ` id="${tags.id}"` : ''}` +
|
||||
`${!_.isEmpty(tags.styles) ? ` style="${Object.entries(tags.styles).map(([key, value])=>`${key}:${value};`).join(' ')}"` : ''}` +
|
||||
`${!_.isEmpty(tags.attributes) ? ` ${Object.entries(tags.attributes).map(([key, value])=>`${key}="${value}"`).join(' ')}` : ''}` +
|
||||
`${openingTag[2]}`; // parse to turn child tokens into HTML
|
||||
}
|
||||
return text;
|
||||
}
|
||||
}],
|
||||
walkTokens(token) {
|
||||
// After token tree is finished, tag tokens to apply styles to so Renderer can find them
|
||||
// Does not work with tables since Marked.js tables generate invalid "tokens", and changing "type" ruins Marked handling that edge-case
|
||||
if(token.originalType == 'mustacheInjectBlock' && token.type !== 'table') {
|
||||
token.originalType = token.type;
|
||||
token.type = 'mustacheInjectBlock';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const forcedParagraphBreaks = {
|
||||
name : 'hardBreaks',
|
||||
level : 'block',
|
||||
start(src) { return src.match(/\n:+$/m)?.index; }, // Hint to Marked.js to stop and check for a match
|
||||
tokenizer(src, tokens) {
|
||||
const regex = /^(:+)(?:\n|$)/ym;
|
||||
const match = regex.exec(src);
|
||||
if(match?.length) {
|
||||
return {
|
||||
type : 'hardBreaks', // Should match "name" above
|
||||
raw : match[0], // Text to consume from the source
|
||||
length : match[1].length,
|
||||
text : ''
|
||||
};
|
||||
}
|
||||
},
|
||||
renderer(token) {
|
||||
return `<div class='blank'></div>\n`.repeat(token.length);
|
||||
}
|
||||
};
|
||||
|
||||
// Emoji options
|
||||
// To add more icon fonts, need to do these things
|
||||
// 1) Add the font file as .woff2 to themes/fonts/iconFonts folder
|
||||
// 2) Create a .less file mapping CSS class names to the font character
|
||||
// 3) Create a .js file mapping Autosuggest names to CSS class names
|
||||
// 4) Import the .less file into shared/naturalcrit/codeEditor/codeEditor.less
|
||||
// 5) Import the .less file into themes/V3/blank.style.less
|
||||
// 6) Import the .js file to shared/naturalcrit/codeEditor/autocompleteEmoji.js and add to `emojis` object
|
||||
// 7) Import the .js file here to markdown.js, and add to `emojis` object below
|
||||
const MarkedEmojiOptions = {
|
||||
emojis : {
|
||||
...diceFont,
|
||||
...elderberryInn,
|
||||
...fontAwesome,
|
||||
...gameIcons,
|
||||
},
|
||||
renderer : (token)=>`<i class="${token.emoji}"></i>`
|
||||
};
|
||||
|
||||
const tableTerminators = [
|
||||
`:+\\n`, // hardBreak
|
||||
` *{[^\n]+}`, // blockInjector
|
||||
` *{{[^{\n]*\n.*?\n}}` // mustacheDiv
|
||||
];
|
||||
|
||||
const markdeepOptions = { langs: ['asciiArt'] };
|
||||
|
||||
Marked.use(markedVariables());
|
||||
Marked.use(MarkedDiagramsMarkdeep(markdeepOptions));
|
||||
Marked.use(MarkedDefinitionLists());
|
||||
Marked.use({ extensions: [forcedParagraphBreaks, mustacheSpans, mustacheDivs, mustacheInjectInline] });
|
||||
Marked.use(mustacheInjectBlock);
|
||||
Marked.use(MarkedAlignedParagraphs());
|
||||
Marked.use(MarkedSubSuperText());
|
||||
Marked.use(MarkedNonbreakingSpaces());
|
||||
Marked.use({ renderer: renderer, tokenizer: tokenizer, mangle: false });
|
||||
Marked.use(MarkedExtendedTables({ interruptPatterns: tableTerminators }), MarkedGFMHeadingId({ globalSlugs: true }),
|
||||
MarkedSmartypantsLite(), MarkedEmojis(MarkedEmojiOptions));
|
||||
|
||||
function cleanUrl(href) {
|
||||
try {
|
||||
href = encodeURI(href).replace(/%25/g, '%');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return href;
|
||||
}
|
||||
|
||||
const escapeTest = /[&<>"']/;
|
||||
const escapeReplace = /[&<>"']/g;
|
||||
const escapeTestNoEncode = /[<>"']|&(?!#?\w+;)/;
|
||||
const escapeReplaceNoEncode = /[<>"']|&(?!#?\w+;)/g;
|
||||
const escapeReplacements = {
|
||||
'&' : '&',
|
||||
'<' : '<',
|
||||
'>' : '>',
|
||||
'"' : '"',
|
||||
'\'' : '''
|
||||
};
|
||||
const getEscapeReplacement = (ch)=>escapeReplacements[ch];
|
||||
const escape = function (html, encode) {
|
||||
if(encode) {
|
||||
if(escapeTest.test(html)) {
|
||||
return html.replace(escapeReplace, getEscapeReplacement);
|
||||
}
|
||||
} else {
|
||||
if(escapeTestNoEncode.test(html)) {
|
||||
return html.replace(escapeReplaceNoEncode, getEscapeReplacement);
|
||||
}
|
||||
}
|
||||
return html;
|
||||
};
|
||||
|
||||
const tagTypes = ['div', 'span', 'a'];
|
||||
const tagRegex = new RegExp(`(${
|
||||
_.map(tagTypes, (type)=>{
|
||||
return `\\<${type}\\b|\\</${type}>`;
|
||||
}).join('|')})`, 'g');
|
||||
|
||||
// Special "void" tags that can be self-closed but don't need to be.
|
||||
const voidTags = new Set([
|
||||
'area', 'base', 'br', 'col', 'command', 'hr', 'img',
|
||||
'input', 'keygen', 'link', 'meta', 'param', 'source'
|
||||
]);
|
||||
|
||||
const processStyleTags = (string)=>{
|
||||
//split tags up. quotes can only occur right after : or =.
|
||||
//TODO: can we simplify to just split on commas?
|
||||
const tags = string.match(/(?:[^, ":=]+|[:=](?:"[^"]*"|))+/g);
|
||||
|
||||
const id = _.remove(tags, (tag)=>tag.startsWith('#')).map((tag)=>tag.slice(1))[0] || null;
|
||||
const classes = _.remove(tags, (tag)=>(!tag.includes(':')) && (!tag.includes('='))).join(' ') || null;
|
||||
const attributes = _.remove(tags, (tag)=>(tag.includes('='))).map((tag)=>tag.replace(/="?([^"]*)"?/g, '="$1"'))
|
||||
?.filter((attr)=>!attr.startsWith('class="') && !attr.startsWith('style="') && !attr.startsWith('id="'))
|
||||
.reduce((obj, attr)=>{
|
||||
const index = attr.indexOf('=');
|
||||
let [key, value] = [attr.substring(0, index), attr.substring(index + 1)];
|
||||
value = value.replace(/"/g, '');
|
||||
obj[key.trim()] = value.trim();
|
||||
return obj;
|
||||
}, {}) || null;
|
||||
const styles = tags?.length ? tags.reduce((styleObj, style)=>{
|
||||
const index = style.indexOf(':');
|
||||
const [key, value] = [style.substring(0, index), style.substring(index + 1)];
|
||||
styleObj[key.trim()] = value.replace(/"?([^"]*)"?/g, '$1').trim();
|
||||
return styleObj;
|
||||
}, {}) : null;
|
||||
|
||||
return {
|
||||
id : id,
|
||||
classes : classes,
|
||||
styles : _.isEmpty(styles) ? null : styles,
|
||||
attributes : _.isEmpty(attributes) ? null : attributes
|
||||
};
|
||||
};
|
||||
|
||||
//Given a string representing an HTML element, extract all of its properties (id, class, style, and other attributes)
|
||||
const extractHTMLStyleTags = (htmlString)=>{
|
||||
const firstElementOnly = htmlString.split('>')[0];
|
||||
const id = firstElementOnly.match(/id="([^"]*)"/)?.[1] || null;
|
||||
const classes = firstElementOnly.match(/class="([^"]*)"/)?.[1] || null;
|
||||
const styles = firstElementOnly.match(/style="([^"]*)"/)?.[1]
|
||||
?.split(';').reduce((styleObj, style)=>{
|
||||
if(style.trim() === '') return styleObj;
|
||||
const index = style.indexOf(':');
|
||||
const [key, value] = [style.substring(0, index), style.substring(index + 1)];
|
||||
styleObj[key.trim()] = value.trim();
|
||||
return styleObj;
|
||||
}, {}) || null;
|
||||
const attributes = firstElementOnly.match(/[a-zA-Z]+="[^"]*"/g)
|
||||
?.filter((attr)=>!attr.startsWith('class="') && !attr.startsWith('style="') && !attr.startsWith('id="'))
|
||||
.reduce((obj, attr)=>{
|
||||
const index = attr.indexOf('=');
|
||||
const [key, value] = [attr.substring(0, index), attr.substring(index + 1)];
|
||||
obj[key.trim()] = value.replace(/"/g, '');
|
||||
return obj;
|
||||
}, {}) || null;
|
||||
|
||||
return {
|
||||
id : id,
|
||||
classes : classes,
|
||||
styles : _.isEmpty(styles) ? null : styles,
|
||||
attributes : _.isEmpty(attributes) ? null : attributes
|
||||
};
|
||||
};
|
||||
|
||||
const mergeHTMLTags = (originalTags, newTags)=>{
|
||||
return {
|
||||
id : newTags.id || originalTags.id || null,
|
||||
classes : [originalTags.classes, newTags.classes].join(' ').trim() || null,
|
||||
styles : Object.assign(originalTags.styles ?? {}, newTags.styles ?? {}),
|
||||
attributes : Object.assign(originalTags.attributes ?? {}, newTags.attributes ?? {})
|
||||
};
|
||||
};
|
||||
|
||||
const Markdown = {
|
||||
marked : Marked,
|
||||
render : (rawBrewText, pageNumber=0)=>{
|
||||
setMarkedVariablePage(pageNumber);
|
||||
|
||||
const lastPageNumber = pageNumber > 0 ? getMarkedVariable('HB_pageNumber', pageNumber - 1) : 0;
|
||||
setMarkedVariable('HB_pageNumber', //Add document variables for this page
|
||||
!isNaN(Number(lastPageNumber)) ? Number(lastPageNumber) + 1 : lastPageNumber,
|
||||
pageNumber);
|
||||
|
||||
if(pageNumber==0) MarkedGFMResetHeadingIDs();
|
||||
|
||||
rawBrewText = rawBrewText.replace(/^\\column(?:break)?$/gm, `\n<div class='columnSplit'></div>\n`);
|
||||
|
||||
const opts = Marked.defaults;
|
||||
|
||||
rawBrewText = opts.hooks.preprocess(rawBrewText);
|
||||
const tokens = Marked.lexer(rawBrewText, opts);
|
||||
|
||||
Marked.walkTokens(tokens, opts.walkTokens);
|
||||
|
||||
const html = Marked.parser(tokens, opts);
|
||||
return opts.hooks.postprocess(html);
|
||||
},
|
||||
|
||||
validate : (rawBrewText)=>{
|
||||
const errors = [];
|
||||
const leftovers = _.reduce(rawBrewText.split('\n'), (acc, line, _lineNumber)=>{
|
||||
const lineNumber = _lineNumber + 1;
|
||||
const matches = line.match(tagRegex);
|
||||
if(!matches || !matches.length) return acc;
|
||||
|
||||
_.each(matches, (match)=>{
|
||||
_.each(tagTypes, (type)=>{
|
||||
if(match == `<${type}`){
|
||||
acc.push({
|
||||
type : type,
|
||||
line : lineNumber
|
||||
});
|
||||
}
|
||||
if(match === `</${type}>`){
|
||||
// Closing tag: Check we expect it to be closed.
|
||||
// The accumulator may contain a sequence of voidable opening tags,
|
||||
// over which we skip before checking validity of the close.
|
||||
while (acc.length && voidTags.has(_.last(acc).type) && _.last(acc).type != type) {
|
||||
acc.pop();
|
||||
}
|
||||
// Now check that what remains in the accumulator is valid.
|
||||
if(!acc.length){
|
||||
errors.push({
|
||||
line : lineNumber,
|
||||
type : type,
|
||||
text : 'Unmatched closing tag',
|
||||
id : 'CLOSE'
|
||||
});
|
||||
} else if(_.last(acc).type == type){
|
||||
acc.pop();
|
||||
} else {
|
||||
errors.push({
|
||||
line : `${_.last(acc).line} to ${lineNumber}`,
|
||||
type : type,
|
||||
text : 'Type mismatch on closing tag',
|
||||
id : 'MISMATCH'
|
||||
});
|
||||
acc.pop();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
_.each(leftovers, (unmatched)=>{
|
||||
errors.push({
|
||||
line : unmatched.line,
|
||||
type : unmatched.type,
|
||||
text : 'Unmatched opening tag',
|
||||
id : 'OPEN'
|
||||
});
|
||||
});
|
||||
|
||||
return errors;
|
||||
},
|
||||
};
|
||||
|
||||
export default Markdown;
|
||||
|
||||
@@ -23,7 +23,6 @@ html,body, #reactRoot {
|
||||
text-transform : uppercase;
|
||||
text-decoration : none;
|
||||
cursor : pointer;
|
||||
outline : none;
|
||||
background-color : @backgroundColor;
|
||||
border : none;
|
||||
&:hover { background-color : darken(@backgroundColor, 5%); }
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
|
||||
|
||||
import Markdown from '../../shared/markdown.js';
|
||||
import { hbfm } from 'hbmarkedwrapper';
|
||||
|
||||
test('Processes the markdown within an HTML block if its just a class wrapper', function() {
|
||||
const source = '<div>*Bold text*</div>';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered).toBe('<div> <p><em>Bold text</em></p>\n </div>');
|
||||
});
|
||||
|
||||
@@ -12,6 +12,6 @@ test('Processes the markdown within an HTML block if its just a class wrapper',
|
||||
//
|
||||
// test('Check markdown is using the custom renderer; specifically that it adds target=_self attribute to internal links in HTML blocks', function() {
|
||||
// const source = '<div>[Has _self Attribute?](#p1)</div>';
|
||||
// const rendered = Markdown.render(source);
|
||||
// const rendered = hbfm.render(source);
|
||||
// expect(rendered).toBe('<div> <p><a href="#p1" target="_self">Has _self Attribute?</a></p>\n </div>');
|
||||
// });
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
|
||||
|
||||
import Markdown from '../../shared/markdown.js';
|
||||
import { hbfm } from 'hbmarkedwrapper';
|
||||
|
||||
describe('Inline Definition Lists', ()=>{
|
||||
test('No Term 1 Definition', function() {
|
||||
const source = ':: My First Definition\n\n';
|
||||
const rendered = Markdown.render(source).trim();
|
||||
const rendered = hbfm.render(source).trim();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt></dt><dd>My First Definition</dd>\n</dl>');
|
||||
});
|
||||
|
||||
test('Single Definition Term', function() {
|
||||
const source = 'My term :: My First Definition\n\n';
|
||||
const rendered = Markdown.render(source).trim();
|
||||
const rendered = hbfm.render(source).trim();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>My term</dt><dd>My First Definition</dd>\n</dl>');
|
||||
});
|
||||
|
||||
test('Multiple Definition Terms', function() {
|
||||
const source = 'Term 1::Definition of Term 1\nTerm 2::Definition of Term 2\n\n';
|
||||
const rendered = Markdown.render(source).trim();
|
||||
const rendered = hbfm.render(source).trim();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt><dd>Definition of Term 1</dd>\n<dt>Term 2</dt><dd>Definition of Term 2</dd>\n</dl>');
|
||||
});
|
||||
});
|
||||
@@ -25,79 +25,79 @@ describe('Inline Definition Lists', ()=>{
|
||||
describe('Multiline Definition Lists', ()=>{
|
||||
test('Single Term, Single Definition', function() {
|
||||
const source = 'Term 1\n::Definition 1\n\n';
|
||||
const rendered = Markdown.render(source).trim();
|
||||
const rendered = hbfm.render(source).trim();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt>\n<dd>Definition 1</dd></dl>');
|
||||
});
|
||||
|
||||
test('Single Term, Plural Definitions', function() {
|
||||
const source = 'Term 1\n::Definition 1\n::Definition 2\n\n';
|
||||
const rendered = Markdown.render(source).trim();
|
||||
const rendered = hbfm.render(source).trim();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt>\n<dd>Definition 1</dd>\n<dd>Definition 2</dd></dl>');
|
||||
});
|
||||
|
||||
test('Multiple Term, Single Definitions', function() {
|
||||
const source = 'Term 1\n::Definition 1\n\nTerm 2\n::Definition 1\n\n';
|
||||
const rendered = Markdown.render(source).trim();
|
||||
const rendered = hbfm.render(source).trim();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt>\n<dd>Definition 1</dd>\n<dt>Term 2</dt>\n<dd>Definition 1</dd></dl>');
|
||||
});
|
||||
|
||||
test('Multiple Term, Plural Definitions', function() {
|
||||
const source = 'Term 1\n::Definition 1\n::Definition 2\n\nTerm 2\n::Definition 1\n::Definition 2\n\n';
|
||||
const rendered = Markdown.render(source).trim();
|
||||
const rendered = hbfm.render(source).trim();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt>\n<dd>Definition 1</dd>\n<dd>Definition 2</dd>\n<dt>Term 2</dt>\n<dd>Definition 1</dd>\n<dd>Definition 2</dd></dl>');
|
||||
});
|
||||
|
||||
test('Single Term, Single multi-line definition', function() {
|
||||
const source = 'Term 1\n::Definition 1\nand more and\nmore and more\n\n';
|
||||
const rendered = Markdown.render(source).trim();
|
||||
const rendered = hbfm.render(source).trim();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt>\n<dd>Definition 1 and more and more and more</dd></dl>');
|
||||
});
|
||||
|
||||
test('Single Term, Plural multi-line definitions', function() {
|
||||
const source = 'Term 1\n::Definition 1\nand more and more\n::Definition 2\nand more\nand more\n::Definition 3\n\n';
|
||||
const rendered = Markdown.render(source).trim();
|
||||
const rendered = hbfm.render(source).trim();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt>\n<dd>Definition 1 and more and more</dd>\n<dd>Definition 2 and more and more</dd>\n<dd>Definition 3</dd></dl>');
|
||||
});
|
||||
|
||||
test('Multiple Term, Single multi-line definition', function() {
|
||||
const source = 'Term 1\n::Definition 1\nand more and more\n\nTerm 2\n::Definition 1\n::Definition 2\n\n';
|
||||
const rendered = Markdown.render(source).trim();
|
||||
const rendered = hbfm.render(source).trim();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt>\n<dd>Definition 1 and more and more</dd>\n<dt>Term 2</dt>\n<dd>Definition 1</dd>\n<dd>Definition 2</dd></dl>');
|
||||
});
|
||||
|
||||
test('Multiple Term, Single multi-line definition, followed by an inline dl', function() {
|
||||
const source = 'Term 1\n::Definition 1\nand more and more\n\nTerm 2\n::Definition 1\n::Definition 2\n\n::Inline Definition (no term)';
|
||||
const rendered = Markdown.render(source).trim();
|
||||
const rendered = hbfm.render(source).trim();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt>\n<dd>Definition 1 and more and more</dd>\n<dt>Term 2</dt>\n<dd>Definition 1</dd>\n<dd>Definition 2</dd></dl><dl><dt></dt><dd>Inline Definition (no term)</dd>\n</dl>');
|
||||
});
|
||||
|
||||
test('Multiple Term, Single multi-line definition, followed by paragraph', function() {
|
||||
const source = 'Term 1\n::Definition 1\nand more and more\n\nTerm 2\n::Definition 1\n::Definition 2\n\nParagraph';
|
||||
const rendered = Markdown.render(source).trim();
|
||||
const rendered = hbfm.render(source).trim();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt>\n<dd>Definition 1 and more and more</dd>\n<dt>Term 2</dt>\n<dd>Definition 1</dd>\n<dd>Definition 2</dd></dl><p>Paragraph</p>');
|
||||
});
|
||||
|
||||
test('Block Token cannot be the Term of a multi-line definition', function() {
|
||||
const source = '## Header\n::Definition 1 of a single-line DL\n::Definition 1 of another single-line DL';
|
||||
const rendered = Markdown.render(source).trim();
|
||||
const rendered = hbfm.render(source).trim();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<h2 id="header">Header</h2>\n<dl><dt></dt><dd>Definition 1 of a single-line DL</dd>\n<dt></dt><dd>Definition 1 of another single-line DL</dd>\n</dl>');
|
||||
});
|
||||
|
||||
test('Inline DL has priority over Multiline', function() {
|
||||
const source = 'Term 1 :: Inline definition 1\n:: Inline definition 2 (no DT)';
|
||||
const rendered = Markdown.render(source).trim();
|
||||
const rendered = hbfm.render(source).trim();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt><dd>Inline definition 1</dd>\n<dt></dt><dd>Inline definition 2 (no DT)</dd>\n</dl>');
|
||||
});
|
||||
|
||||
test('Multiline Definition Term must have at least one non-empty Definition', function() {
|
||||
const source = 'Term 1\n::';
|
||||
const rendered = Markdown.render(source).trim();
|
||||
const rendered = hbfm.render(source).trim();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>Term 1</p>\n<div class='blank'></div>\n<div class='blank'></div>`);
|
||||
});
|
||||
|
||||
test('Multiline Definition List must have at least one non-newline character after ::', function() {
|
||||
const source = 'Term 1\n::\nDefinition 1\n\n';
|
||||
const rendered = Markdown.render(source).trim();
|
||||
const rendered = hbfm.render(source).trim();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>Term 1</p>\n<div class='blank'></div>\n<div class='blank'></div>\n<p>Definition 1</p>`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Markdown from '../../shared/markdown.js';
|
||||
import { hbfm } from 'hbmarkedwrapper';
|
||||
import dedent from 'dedent';
|
||||
|
||||
// Marked.js adds line returns after closing tags on some default tokens.
|
||||
@@ -12,37 +12,37 @@ const emoji = 'df_d12_2';
|
||||
describe(`When emojis/icons are active`, ()=>{
|
||||
it('when a word is between two colons (:word:), and a matching emoji exists, it is rendered as an emoji', function() {
|
||||
const source = `:${emoji}:`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><i class="df d12-2"></i></p>`);
|
||||
});
|
||||
|
||||
it('when a word is between two colons (:word:), and no matching emoji exists, it is not parsed', function() {
|
||||
const source = `:invalid:`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>:invalid:</p>`);
|
||||
});
|
||||
|
||||
it('two valid emojis with no whitespace are prioritized over definition lists', function() {
|
||||
const source = `:${emoji}::${emoji}:`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><i class="df d12-2"></i><i class="df d12-2"></i></p>`);
|
||||
});
|
||||
|
||||
it('definition lists that are not also part of an emoji can coexist with normal emojis', function() {
|
||||
const source = `definition :: term ${emoji}::${emoji}:`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<dl><dt>definition</dt><dd>term df_d12_2:<i class="df d12-2"></i></dd></dl>`);
|
||||
});
|
||||
|
||||
it('A valid emoji is compatible with curly injectors', function() {
|
||||
const source = `:${emoji}:{color:blue,myClass}`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><i class="df d12-2 myClass" style="color:blue;"></i></p>`);
|
||||
});
|
||||
|
||||
it('Emojis are not parsed inside of curly span CSS blocks', function() {
|
||||
const source = `{{color:${emoji} text}}`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<span class="inline-block" style="color:df_d12_2;">text</span>`);
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ describe(`When emojis/icons are active`, ()=>{
|
||||
const source = dedent`{{color:${emoji}
|
||||
text
|
||||
}}`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class="block" style="color:df_d12_2;"><p>text</p></div>`);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,47 +1,47 @@
|
||||
|
||||
|
||||
import Markdown from '../../shared/markdown.js';
|
||||
import { hbfm } from 'hbmarkedwrapper';
|
||||
|
||||
describe('Hard Breaks', ()=>{
|
||||
test('Single Break', function() {
|
||||
const source = ':\n\n';
|
||||
const rendered = Markdown.render(source).trim();
|
||||
const rendered = hbfm.render(source).trim();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class='blank'></div>`);
|
||||
});
|
||||
|
||||
test('Double Break', function() {
|
||||
const source = '::\n\n';
|
||||
const rendered = Markdown.render(source).trim();
|
||||
const rendered = hbfm.render(source).trim();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class='blank'></div>\n<div class='blank'></div>`);
|
||||
});
|
||||
|
||||
test('Triple Break', function() {
|
||||
const source = ':::\n\n';
|
||||
const rendered = Markdown.render(source).trim();
|
||||
const rendered = hbfm.render(source).trim();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class='blank'></div>\n<div class='blank'></div>\n<div class='blank'></div>`);
|
||||
});
|
||||
|
||||
test('Many Break', function() {
|
||||
const source = '::::::::::\n\n';
|
||||
const rendered = Markdown.render(source).trim();
|
||||
const rendered = hbfm.render(source).trim();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class='blank'></div>\n<div class='blank'></div>\n<div class='blank'></div>\n<div class='blank'></div>\n<div class='blank'></div>\n<div class='blank'></div>\n<div class='blank'></div>\n<div class='blank'></div>\n<div class='blank'></div>\n<div class='blank'></div>`);
|
||||
});
|
||||
|
||||
test('Multiple sets of Breaks', function() {
|
||||
const source = ':::\n:::\n:::';
|
||||
const rendered = Markdown.render(source).trim();
|
||||
const rendered = hbfm.render(source).trim();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class='blank'></div>\n<div class='blank'></div>\n<div class='blank'></div>\n<div class='blank'></div>\n<div class='blank'></div>\n<div class='blank'></div>\n<div class='blank'></div>\n<div class='blank'></div>\n<div class='blank'></div>`);
|
||||
});
|
||||
|
||||
test('Break directly between two paragraphs', function() {
|
||||
const source = 'Line 1\n::\nLine 2';
|
||||
const rendered = Markdown.render(source).trim();
|
||||
const rendered = hbfm.render(source).trim();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>Line 1</p>\n<div class='blank'></div>\n<div class='blank'></div>\n<p>Line 2</p>`);
|
||||
});
|
||||
|
||||
test('Ignored inside a code block', function() {
|
||||
const source = '```\n\n:\n\n```\n';
|
||||
const rendered = Markdown.render(source).trim();
|
||||
const rendered = hbfm.render(source).trim();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<pre><code>\n:\n</code></pre>`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable max-lines */
|
||||
|
||||
import dedent from 'dedent';
|
||||
import Markdown from '../../shared/markdown.js';
|
||||
import { hbfm } from 'hbmarkedwrapper';
|
||||
|
||||
// Marked.js adds line returns after closing tags on some default tokens.
|
||||
// This removes those line returns for comparison sake.
|
||||
@@ -15,112 +15,112 @@ String.prototype.trimReturns = function(){
|
||||
describe('Inline: When using the Inline syntax {{ }}', ()=>{
|
||||
it('Renders a mustache span with text only', function() {
|
||||
const source = '{{ text}}';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block">text</span>');
|
||||
});
|
||||
|
||||
it('Renders a mustache span with text only, but with spaces', function() {
|
||||
const source = '{{ this is a text}}';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block">this is a text</span>');
|
||||
});
|
||||
|
||||
it('Renders an empty mustache span', function() {
|
||||
const source = '{{}}';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block"></span>');
|
||||
});
|
||||
|
||||
it('Renders a mustache span with just a space', function() {
|
||||
const source = '{{ }}';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block"></span>');
|
||||
});
|
||||
|
||||
it('Renders a mustache span with a few spaces only', function() {
|
||||
const source = '{{ }}';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block"></span>');
|
||||
});
|
||||
|
||||
it('Renders a mustache span with text and class', function() {
|
||||
const source = '{{my-class text}}';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block my-class">text</span>');
|
||||
});
|
||||
|
||||
it('Renders a mustache span with text and two classes', function() {
|
||||
const source = '{{my-class,my-class2 text}}';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block my-class my-class2">text</span>');
|
||||
});
|
||||
|
||||
it('Renders a mustache span with text with spaces and class', function() {
|
||||
const source = '{{my-class this is a text}}';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block my-class">this is a text</span>');
|
||||
});
|
||||
|
||||
it('Renders a mustache span with text and id', function() {
|
||||
const source = '{{#my-span text}}';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block" id="my-span">text</span>');
|
||||
});
|
||||
|
||||
it('Renders a mustache span with text and two ids', function() {
|
||||
const source = '{{#my-span,#my-favorite-span text}}';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block" id="my-span">text</span>');
|
||||
});
|
||||
|
||||
it('Renders a mustache span with text and css property', function() {
|
||||
const source = '{{color:red text}}';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block" style="color:red;">text</span>');
|
||||
});
|
||||
|
||||
it('Renders a mustache span with text and two css properties', function() {
|
||||
const source = '{{color:red,padding:5px text}}';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block" style="color:red; padding:5px;">text</span>');
|
||||
});
|
||||
|
||||
it('Renders a mustache span with text and css property which contains quotes', function() {
|
||||
const source = '{{font-family:"trebuchet ms" text}}';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block" style="font-family:trebuchet ms;">text</span>');
|
||||
});
|
||||
|
||||
it('Renders a mustache span with text and two css properties which contains quotes', function() {
|
||||
const source = '{{font-family:"trebuchet ms",padding:"5px 10px" text}}';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block" style="font-family:trebuchet ms; padding:5px 10px;">text</span>');
|
||||
});
|
||||
|
||||
|
||||
it('Renders a mustache span with text with quotes and css property which contains double quotes', function() {
|
||||
const source = '{{font-family:"trebuchet ms" text "with quotes"}}';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block" style="font-family:trebuchet ms;">text “with quotes”</span>');
|
||||
});
|
||||
|
||||
|
||||
it('Renders a mustache span with text with quotes and css property which contains double and simple quotes', function() {
|
||||
const source = `{{--stringVariable:"'string'" text "with quotes"}}`;
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<span class="inline-block" style="--stringVariable:'string';">text “with quotes”</span>`);
|
||||
});
|
||||
|
||||
|
||||
it('Renders a mustache span with text, id, class and a couple of css properties', function() {
|
||||
const source = '{{pen,#author,color:orange,font-family:"trebuchet ms" text}}';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block pen" id="author" style="color:orange; font-family:trebuchet ms;">text</span>');
|
||||
});
|
||||
|
||||
it('Renders a span with added attributes', function() {
|
||||
const source = 'Text and {{pen,#author,color:orange,font-family:"trebuchet ms",a="b and c",d=e, text}} and more text!';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>Text and <span class="inline-block pen" id="author" style="color:orange; font-family:trebuchet ms;" a="b and c" d="e">text</span> and more text!</p>\n');
|
||||
});
|
||||
});
|
||||
@@ -132,7 +132,7 @@ describe(`Block: When using the Block syntax {{tags\\ntext\\n}}`, ()=>{
|
||||
const source = dedent`{{
|
||||
text
|
||||
}}`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class="block"><p>text</p></div>`);
|
||||
});
|
||||
|
||||
@@ -140,14 +140,14 @@ describe(`Block: When using the Block syntax {{tags\\ntext\\n}}`, ()=>{
|
||||
const source = dedent`{{
|
||||
|
||||
}}`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class="block"></div>`);
|
||||
});
|
||||
|
||||
it('Renders a single paragraph with opening and closing brackets', function() {
|
||||
const source = dedent`{{
|
||||
}}`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>{{}}</p>`);
|
||||
});
|
||||
|
||||
@@ -155,7 +155,7 @@ describe(`Block: When using the Block syntax {{tags\\ntext\\n}}`, ()=>{
|
||||
const source = dedent`{{cat
|
||||
|
||||
}}`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class="block cat"></div>`);
|
||||
});
|
||||
|
||||
@@ -163,7 +163,7 @@ describe(`Block: When using the Block syntax {{tags\\ntext\\n}}`, ()=>{
|
||||
const source = dedent`{{cat
|
||||
Sample text.
|
||||
}}`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class="block cat"><p>Sample text.</p></div>`);
|
||||
});
|
||||
|
||||
@@ -171,7 +171,7 @@ describe(`Block: When using the Block syntax {{tags\\ntext\\n}}`, ()=>{
|
||||
const source = dedent`{{cat,dog
|
||||
Sample text.
|
||||
}}`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class="block cat dog"><p>Sample text.</p></div>`);
|
||||
});
|
||||
|
||||
@@ -179,7 +179,7 @@ describe(`Block: When using the Block syntax {{tags\\ntext\\n}}`, ()=>{
|
||||
const source = dedent`{{color:red
|
||||
Sample text.
|
||||
}}`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class="block" style="color:red;"><p>Sample text.</p></div>`);
|
||||
});
|
||||
|
||||
@@ -187,7 +187,7 @@ describe(`Block: When using the Block syntax {{tags\\ntext\\n}}`, ()=>{
|
||||
const source = dedent`{{--stringVariable:"'string'"
|
||||
Sample text.
|
||||
}}`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class="block" style="--stringVariable:'string';"><p>Sample text.</p></div>`);
|
||||
});
|
||||
|
||||
@@ -195,7 +195,7 @@ describe(`Block: When using the Block syntax {{tags\\ntext\\n}}`, ()=>{
|
||||
const source = dedent`{{--stringVariable:"'string'"
|
||||
Sample text.
|
||||
}}`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class="block" style="--stringVariable:'string';"><p>Sample text.</p></div>`);
|
||||
});
|
||||
|
||||
@@ -203,7 +203,7 @@ describe(`Block: When using the Block syntax {{tags\\ntext\\n}}`, ()=>{
|
||||
const source = dedent`{{cat,color:red
|
||||
Sample text.
|
||||
}}`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class="block cat" style="color:red;"><p>Sample text.</p></div>`);
|
||||
});
|
||||
|
||||
@@ -211,7 +211,7 @@ describe(`Block: When using the Block syntax {{tags\\ntext\\n}}`, ()=>{
|
||||
const source = dedent`{{color:red,cat,#dog
|
||||
Sample text.
|
||||
}}`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class="block cat" id="dog" style="color:red;"><p>Sample text.</p></div>`);
|
||||
});
|
||||
|
||||
@@ -219,7 +219,7 @@ describe(`Block: When using the Block syntax {{tags\\ntext\\n}}`, ()=>{
|
||||
const source = dedent`{{#cat,#dog
|
||||
Sample text.
|
||||
}}`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class="block" id="cat"><p>Sample text.</p></div>`);
|
||||
});
|
||||
|
||||
@@ -227,13 +227,13 @@ describe(`Block: When using the Block syntax {{tags\\ntext\\n}}`, ()=>{
|
||||
const source = dedent`{{color:red,cat,#dog,a="b and c",d="e"
|
||||
Sample text.
|
||||
}}`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class=\"block cat\" id=\"dog\" style=\"color:red;\" a=\"b and c\" d=\"e\"><p>Sample text.</p></div>`);
|
||||
});
|
||||
|
||||
it('Renders a div with added attributes', function() {
|
||||
const source = '{{pen,#author,color:orange,font-family:"trebuchet ms",a="b and c",d=e\nText and text and more text!\n}}\n';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<div class="block pen" id="author" style="color:orange; font-family:trebuchet ms;" a="b and c" d="e"><p>Text and text and more text!</p>\n</div>');
|
||||
});
|
||||
});
|
||||
@@ -245,116 +245,116 @@ describe('Injection: When an injection tag follows an element', ()=>{
|
||||
describe('and that element is an inline-block', ()=>{
|
||||
it('Renders a span "text" with no injection', function() {
|
||||
const source = '{{ text}}{}';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block">text</span>');
|
||||
});
|
||||
|
||||
it('Renders a span "text" with injected Class name', function() {
|
||||
const source = '{{ text}}{ClassName}';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block ClassName">text</span>');
|
||||
});
|
||||
|
||||
it('Renders a span "text" with injected attribute', function() {
|
||||
const source = '{{ text}}{a="b and c"}';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block" a="b and c">text</span>');
|
||||
});
|
||||
|
||||
it('Renders a span "text" with injected style', function() {
|
||||
const source = '{{ text}}{color:red}';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block" style="color:red;">text</span>');
|
||||
});
|
||||
|
||||
it('Renders a span "text" with injected style using a string variable', function() {
|
||||
const source = `{{ text}}{--stringVariable:"'string'"}`;
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<span class="inline-block" style="--stringVariable:'string';">text</span>`);
|
||||
});
|
||||
|
||||
it('Renders a span "text" with two injected styles', function() {
|
||||
const source = '{{ text}}{color:red,background:blue}';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block" style="color:red; background:blue;">text</span>');
|
||||
});
|
||||
|
||||
it('Renders a span "text" with its own ID, overwritten with an injected ID', function() {
|
||||
const source = '{{#oldId text}}{#newId}';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block" id="newId">text</span>');
|
||||
});
|
||||
|
||||
it('Renders a span "text" with its own attributes, overwritten with an injected attribute, plus a new one', function() {
|
||||
const source = '{{attrA="old",attrB="old" text}}{attrA="new",attrC="new"}';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block" attrA="new" attrB="old" attrC="new">text</span>');
|
||||
});
|
||||
|
||||
it('Renders a span "text" with its own attributes, overwritten with an injected attribute, ignoring "class", "style", and "id"', function() {
|
||||
const source = '{{attrA="old",attrB="old" text}}{attrA="new",attrC="new",class="new",style="new",id="new"}';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block" attrA="new" attrB="old" attrC="new">text</span>');
|
||||
});
|
||||
|
||||
it('Renders a span "text" with its own styles, appended with injected styles', function() {
|
||||
const source = '{{color:blue,height:10px text}}{width:10px,color:red}';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block" style="color:red; height:10px; width:10px;">text</span>');
|
||||
});
|
||||
|
||||
it('Renders a span "text" with its own classes, appended with injected classes', function() {
|
||||
const source = '{{classA,classB text}}{classA,classC}';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block classA classB classA classC">text</span>');
|
||||
});
|
||||
|
||||
it('Renders an emphasis element with injected Class name', function() {
|
||||
const source = '*emphasis*{big}';
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p><em class="big">emphasis</em></p>');
|
||||
});
|
||||
|
||||
it('Renders a code element with injected style', function() {
|
||||
const source = '`code`{background:gray}';
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p><code style="background:gray;">code</code></p>');
|
||||
});
|
||||
|
||||
it('Renders an image element with injected style', function() {
|
||||
const source = '{position:absolute}';
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p><img style="--HB_src:url(https://i.imgur.com/hMna6G0.png); position:absolute;" loading="lazy" src="https://i.imgur.com/hMna6G0.png" alt="alt text"></p>');
|
||||
});
|
||||
|
||||
it('Renders an element modified by only the first of two consecutive injections', function() {
|
||||
const source = '{{ text}}{color:red}{background:blue}';
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
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();
|
||||
const rendered = hbfm.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() {
|
||||
const source = ` {position:absolute,bottom:20px,left:130px,width:220px,a="b and c",d=e}`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><img style="--HB_src:url(https://i.imgur.com/hMna6G0.png); position:absolute; bottom:20px; left:130px; width:220px;" loading="lazy" 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 = ` {position:absolute,bottom:20px,left:130px,width:220px,a="b and c",d=e}`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><img style="--HB_src:url(https://i.imgur.com/hMna6G0.png?auth=12345&height=1024); position:absolute; bottom:20px; left:130px; width:220px;" loading="lazy" 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 = ` {position:absolute,bottom:20px,left:130px,width:220px,a="b and c",d=e,otherUrl="url?auth=12345"}`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><img style="--HB_src:url(https://i.imgur.com/hMna6G0.png); position:absolute; bottom:20px; left:130px; width:220px;" loading="lazy" src="https://i.imgur.com/hMna6G0.png" alt="homebrew mug" a="b and c" d="e" otherUrl="url?auth=12345"></p>`);
|
||||
});
|
||||
});
|
||||
@@ -362,19 +362,19 @@ describe('Injection: When an injection tag follows an element', ()=>{
|
||||
describe('and that element is a block', ()=>{
|
||||
it('renders a div "text" with no injection', function() {
|
||||
const source = '{{\ntext\n}}\n{}';
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<div class="block"><p>text</p></div>');
|
||||
});
|
||||
|
||||
it('renders a div "text" with injected Class name', function() {
|
||||
const source = '{{\ntext\n}}\n{ClassName}';
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<div class="block ClassName"><p>text</p></div>');
|
||||
});
|
||||
|
||||
it('renders a div "text" with injected style', function() {
|
||||
const source = '{{\ntext\n}}\n{color:red}';
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<div class="block" style="color:red;"><p>text</p></div>');
|
||||
});
|
||||
|
||||
@@ -383,7 +383,7 @@ describe('Injection: When an injection tag follows an element', ()=>{
|
||||
text
|
||||
}}
|
||||
{color:red,background:blue}`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class="block" style="color:red; background:blue;"><p>text</p></div>`);
|
||||
});
|
||||
|
||||
@@ -392,7 +392,7 @@ describe('Injection: When an injection tag follows an element', ()=>{
|
||||
text
|
||||
}}
|
||||
{--stringVariable:"'string'"}`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class="block" style="--stringVariable:'string';"><p>text</p></div>`);
|
||||
});
|
||||
|
||||
@@ -401,7 +401,7 @@ describe('Injection: When an injection tag follows an element', ()=>{
|
||||
text
|
||||
}}
|
||||
{#newId}`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<div class="block" id="newId"><p>text</p></div>');
|
||||
});
|
||||
|
||||
@@ -410,7 +410,7 @@ describe('Injection: When an injection tag follows an element', ()=>{
|
||||
text
|
||||
}}
|
||||
{attrA="new",attrC="new"}`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<div class="block" attrA="new" attrB="old" attrC="new"><p>text</p></div>');
|
||||
});
|
||||
|
||||
@@ -419,7 +419,7 @@ describe('Injection: When an injection tag follows an element', ()=>{
|
||||
text
|
||||
}}
|
||||
{attrA="new",attrC="new",class="new",style="new",id="new"}`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<div class="block" attrA="new" attrB="old" attrC="new"><p>text</p></div>');
|
||||
});
|
||||
|
||||
@@ -428,7 +428,7 @@ describe('Injection: When an injection tag follows an element', ()=>{
|
||||
text
|
||||
}}
|
||||
{width:10px,color:red}`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<div class="block" style="color:red; height:10px; width:10px;"><p>text</p></div>');
|
||||
});
|
||||
|
||||
@@ -437,14 +437,14 @@ describe('Injection: When an injection tag follows an element', ()=>{
|
||||
text
|
||||
}}
|
||||
{classA,classC}`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<div class="block classA classB classA classC"><p>text</p></div>');
|
||||
});
|
||||
|
||||
it('renders an h2 header "text" with injected class name', function() {
|
||||
const source = dedent`## text
|
||||
{ClassName}`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<h2 class="ClassName" id="text">text</h2>');
|
||||
});
|
||||
|
||||
@@ -455,7 +455,7 @@ describe('Injection: When an injection tag follows an element', ()=>{
|
||||
| 300 | 2 |
|
||||
|
||||
{ClassName}`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<table class="ClassName"><thead><tr><th align=left>Experience Points</th><th align=center>Level</th></tr></thead><tbody><tr><td align=left>0</td><td align=center>1</td></tr><tr><td align=left>300</td><td align=center>2</td></tr></tbody></table>`);
|
||||
});
|
||||
|
||||
@@ -466,7 +466,7 @@ describe('Injection: When an injection tag follows an element', ()=>{
|
||||
// - Dark Chant of the Dentists
|
||||
// - Divine Spell of Crossdressing
|
||||
// {color:red}`;
|
||||
// const rendered = Markdown.render(source).trimReturns();
|
||||
// const rendered = hbfm.render(source).trimReturns();
|
||||
// expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`...`); // FIXME: expect this to be injected into <ul>? Currently injects into last <li>
|
||||
// });
|
||||
|
||||
@@ -474,7 +474,7 @@ describe('Injection: When an injection tag follows an element', ()=>{
|
||||
const source = dedent`## text
|
||||
{ClassName}
|
||||
{secondInjection}`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<h2 class="ClassName" id="text">text</h2><p>{secondInjection}</p>');
|
||||
});
|
||||
|
||||
@@ -487,7 +487,7 @@ describe('Injection: When an injection tag follows an element', ()=>{
|
||||
{innerDiv}
|
||||
}}
|
||||
{outerDiv}`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<div class="block outerDiv"><p>outer text</p><div class="block innerDiv"><p>inner text</p></div></div>');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
|
||||
|
||||
import Markdown from '../../shared/markdown.js';
|
||||
import {hbfm} from 'hbmarkedwrapper';
|
||||
|
||||
describe('Non-Breaking Spaces Interactions', ()=>{
|
||||
test('I am actually a single-line definition list!', function() {
|
||||
const source = 'Term ::> Definition 1\n';
|
||||
const rendered = Markdown.render(source).trim();
|
||||
const rendered = hbfm.render(source).trim();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<dl><dt>Term</dt><dd>> Definition 1</dd>\n</dl>`);
|
||||
});
|
||||
|
||||
test('I am actually a definition list!', function() {
|
||||
const source = 'Term\n::> Definition 1\n';
|
||||
const rendered = Markdown.render(source).trim();
|
||||
const rendered = hbfm.render(source).trim();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<dl><dt>Term</dt>\n<dd>> Definition 1</dd></dl>`);
|
||||
});
|
||||
|
||||
test('I am actually a two-term definition list!', function() {
|
||||
const source = 'Term\n::> Definition 1\n::>> Definition 2';
|
||||
const rendered = Markdown.render(source).trim();
|
||||
const rendered = hbfm.render(source).trim();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<dl><dt>Term</dt>\n<dd>> Definition 1</dd>\n<dd>>> Definition 2</dd></dl>`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
|
||||
|
||||
import Markdown from '../../shared/markdown.js';
|
||||
import { hbfm } from 'hbmarkedwrapper';
|
||||
|
||||
describe('Justification', ()=>{
|
||||
test('Left Justify', function() {
|
||||
const source = ':- Hello';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p align=\"Left\">Hello</p>`);
|
||||
});
|
||||
test('Right Justify', function() {
|
||||
const source = '-: Hello';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p align=\"Right\">Hello</p>`);
|
||||
});
|
||||
test('Center Justify', function() {
|
||||
const source = ':-: Hello';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p align=\"Center\">Hello</p>`);
|
||||
});
|
||||
|
||||
test('Ignored inside a code block', function() {
|
||||
const source = '```\n\n:- Hello\n\n```\n';
|
||||
const rendered = Markdown.render(source);
|
||||
const rendered = hbfm.render(source);
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<pre><code>\n:- Hello\n</code></pre>\n`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable max-lines */
|
||||
|
||||
import dedent from 'dedent';
|
||||
import Markdown from '../../shared/markdown.js';
|
||||
import { hbfm } from 'hbmarkedwrapper';
|
||||
|
||||
// Marked.js adds line returns after closing tags on some default tokens.
|
||||
// This removes those line returns for comparison sake.
|
||||
@@ -12,7 +12,7 @@ String.prototype.trimReturns = function(){
|
||||
const renderAllPages = function(pages){
|
||||
const outputs = [];
|
||||
pages.forEach((page, index)=>{
|
||||
const output = Markdown.render(page, index);
|
||||
const output = hbfm.render(page, index);
|
||||
outputs.push(output);
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ describe('Block-level variables', ()=>{
|
||||
|
||||
$[var]
|
||||
`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>string</p>');
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('Block-level variables', ()=>{
|
||||
lines
|
||||
|
||||
$[var]`;
|
||||
const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
|
||||
const rendered = hbfm.render(source).replace(/\s/g, ' ').trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>string across multiple lines</p>');
|
||||
});
|
||||
|
||||
@@ -54,7 +54,7 @@ describe('Block-level variables', ()=>{
|
||||
| C | D |
|
||||
|
||||
$[var]`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
|
||||
<h5 id="title">Title</h5>
|
||||
<table><thead><tr><th align=left>H1</th>
|
||||
@@ -71,7 +71,7 @@ describe('Block-level variables', ()=>{
|
||||
$[var]
|
||||
|
||||
[var]: string`;
|
||||
const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
|
||||
const rendered = hbfm.render(source).replace(/\s/g, ' ').trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>string</p>');
|
||||
});
|
||||
|
||||
@@ -82,7 +82,7 @@ describe('Block-level variables', ()=>{
|
||||
[var]: string
|
||||
|
||||
[var]: new string`;
|
||||
const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
|
||||
const rendered = hbfm.render(source).replace(/\s/g, ' ').trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>new string</p>');
|
||||
});
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('Block-level variables', ()=>{
|
||||
|
||||
[lastName]: $[lastName]son
|
||||
`;
|
||||
const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
|
||||
const rendered = hbfm.render(source).replace(/\s/g, ' ').trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>Welcome, Mr. Bob Jacobson!</p>');
|
||||
});
|
||||
|
||||
@@ -116,7 +116,7 @@ describe('Block-level variables', ()=>{
|
||||
|
||||
$[var]
|
||||
`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>one</p><p>two</p>'.trimReturns());
|
||||
});
|
||||
|
||||
@@ -132,7 +132,7 @@ describe('Block-level variables', ()=>{
|
||||
|
||||
$[var]
|
||||
`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>two</p><p>one</p><p>two</p>'.trimReturns());
|
||||
});
|
||||
|
||||
@@ -142,7 +142,7 @@ describe('Block-level variables', ()=>{
|
||||
|
||||
$[last]: Jones
|
||||
`;
|
||||
const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
|
||||
const rendered = hbfm.render(source).replace(/\s/g, ' ').trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>My name is $[first] Jones</p>`.trimReturns());
|
||||
});
|
||||
});
|
||||
@@ -154,7 +154,7 @@ describe('Inline-level variables', ()=>{
|
||||
|
||||
$[var]
|
||||
`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>string</p><p>string</p>');
|
||||
});
|
||||
|
||||
@@ -163,7 +163,7 @@ describe('Inline-level variables', ()=>{
|
||||
$[var](My name is $[name] Jones)
|
||||
|
||||
[name]: Bob`;
|
||||
const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
|
||||
const rendered = hbfm.render(source).replace(/\s/g, ' ').trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>My name is Bob Jones</p>');
|
||||
});
|
||||
|
||||
@@ -174,7 +174,7 @@ describe('Inline-level variables', ()=>{
|
||||
$[name](Bob)
|
||||
|
||||
[name]: Bill`;
|
||||
const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
|
||||
const rendered = hbfm.render(source).replace(/\s/g, ' ').trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>My name is Bill Jones</p> <p>Bob</p>`.trimReturns());
|
||||
});
|
||||
|
||||
@@ -187,7 +187,7 @@ describe('Inline-level variables', ()=>{
|
||||
$[var2](A variable ) with unbalanced parens)
|
||||
|
||||
$[var2]`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
|
||||
<p>A variable (with nested parens) inside</p>
|
||||
<p>A variable (with nested parens) inside</p>
|
||||
@@ -202,35 +202,35 @@ describe('Math', ()=>{
|
||||
const source = dedent`
|
||||
$[1 + 3 * 5 - (1 / 4)]
|
||||
`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>15.75</p>');
|
||||
});
|
||||
|
||||
it('Handles round function', function() {
|
||||
const source = dedent`
|
||||
$[round(1/4)]`;
|
||||
const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
|
||||
const rendered = hbfm.render(source).replace(/\s/g, ' ').trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>0</p>');
|
||||
});
|
||||
|
||||
it('Handles floor function', function() {
|
||||
const source = dedent`
|
||||
$[floor(0.6)]`;
|
||||
const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
|
||||
const rendered = hbfm.render(source).replace(/\s/g, ' ').trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>0</p>');
|
||||
});
|
||||
|
||||
it('Handles ceil function', function() {
|
||||
const source = dedent`
|
||||
$[ceil(0.2)]`;
|
||||
const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
|
||||
const rendered = hbfm.render(source).replace(/\s/g, ' ').trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>1</p>');
|
||||
});
|
||||
|
||||
it('Handles nested functions', function() {
|
||||
const source = dedent`
|
||||
$[ceil(floor(round(0.6)))]`;
|
||||
const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
|
||||
const rendered = hbfm.render(source).replace(/\s/g, ' ').trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>1</p>');
|
||||
});
|
||||
|
||||
@@ -242,7 +242,7 @@ describe('Math', ()=>{
|
||||
|
||||
Answer is $[answer]($[1 + 3 * num1 - (1 / num2)]).
|
||||
`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>Answer is 15.75.</p>');
|
||||
});
|
||||
|
||||
@@ -252,7 +252,7 @@ describe('Math', ()=>{
|
||||
|
||||
Increment num1 to get $[num1]($[num1 + 1]) and again to $[num1]($[num1 + 1]).
|
||||
`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>Increment num1 to get 6 and again to 7.</p>');
|
||||
});
|
||||
});
|
||||
@@ -268,7 +268,7 @@ describe('Code blocks', ()=>{
|
||||
$[var](new string)
|
||||
\`\`\`
|
||||
`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
|
||||
<pre><code>
|
||||
[var]: string
|
||||
@@ -289,7 +289,7 @@ describe('Code blocks', ()=>{
|
||||
|
||||
$[var](new string)
|
||||
`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
|
||||
<p>test</p>
|
||||
|
||||
@@ -304,7 +304,7 @@ describe('Code blocks', ()=>{
|
||||
|
||||
it('Ignores all variables in inline code blocks', function() {
|
||||
const source = '[var](Hello) `[link](url)`. This `[var] does not work`';
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
|
||||
<p><a href="Hello">var</a> <code>[link](url)</code>. This <code>[var] does not work</code></p>`.trimReturns());
|
||||
});
|
||||
@@ -313,35 +313,35 @@ describe('Code blocks', ()=>{
|
||||
describe('Normal Links and Images', ()=>{
|
||||
it('Renders normal images', function() {
|
||||
const source = ``;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
|
||||
<p><img loading="lazy" src="url" alt="alt text" style="--HB_src:url(url);"></p>`.trimReturns());
|
||||
});
|
||||
|
||||
it('Renders normal images with a title', function() {
|
||||
const source = 'An image !';
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
|
||||
<p>An image <img loading="lazy" src="url" alt="alt text" style="--HB_src:url(url);" title="and title">!</p>`.trimReturns());
|
||||
});
|
||||
|
||||
it('Applies curly injectors to images', function() {
|
||||
const source = `{width:100px}`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
|
||||
<p><img style="--HB_src:url(url); width:100px;" loading="lazy" src="url" alt="alt text"></p>`.trimReturns());
|
||||
});
|
||||
|
||||
it('Renders normal links', function() {
|
||||
const source = 'A Link to my [website](url)!';
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
|
||||
<p>A Link to my <a href="url">website</a>!</p>`.trimReturns());
|
||||
});
|
||||
|
||||
it('Renders normal links with a title', function() {
|
||||
const source = 'A Link to my [website](url "and title")!';
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
|
||||
<p>A Link to my <a href="url" title="and title">website</a>!</p>`.trimReturns());
|
||||
});
|
||||
@@ -399,17 +399,17 @@ describe('Cross-page variables', ()=>{
|
||||
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();
|
||||
const rendered = hbfm.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();
|
||||
const rendered = hbfm.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();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>4</p>`);
|
||||
});
|
||||
});
|
||||
@@ -417,13 +417,13 @@ describe('Math function parameter handling', ()=>{
|
||||
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();
|
||||
const rendered = hbfm.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();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered).toBe('<p>14</p>');
|
||||
});
|
||||
});
|
||||
@@ -431,31 +431,31 @@ describe('Variable names that are subsets of other names', ()=>{
|
||||
describe('Regression Tests', ()=>{
|
||||
it('Don\'t Eat all the parentheticals!', function() {
|
||||
const source='\n| title 1 | title 2 | title 3 | title 4|\n|-----------|---------|---------|--------|\n|[foo](bar) | Ipsum | ) | ) |\n';
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered).toBe('<table><thead><tr><th>title 1</th><th>title 2</th><th>title 3</th><th>title 4</th></tr></thead><tbody><tr><td><a href=\"bar\">foo</a></td><td>Ipsum</td><td>)</td><td>)</td></tr></tbody></table>');
|
||||
});
|
||||
|
||||
it('Handle Extra spaces in image alt-text 1', function(){
|
||||
const source='';
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered).toBe('<p><img loading="lazy" src=\"http://i.imgur.com/hMna6G0.png\" alt=\"where is my image??\" style=\"--HB_src:url(http://i.imgur.com/hMna6G0.png);\"></p>');
|
||||
});
|
||||
|
||||
it('Handle Extra spaces in image alt-text 2', function(){
|
||||
const source='';
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered).toBe('<p><img loading="lazy" src=\"http://i.imgur.com/hMna6G0.png\" alt=\"where is my image??\" style=\"--HB_src:url(http://i.imgur.com/hMna6G0.png);\"></p>');
|
||||
});
|
||||
|
||||
it('Handle Extra spaces in image alt-text 3', function(){
|
||||
const source='';
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered).toBe('<p><img loading="lazy" src=\"http://i.imgur.com/hMna6G0.png\" alt=\"where is my image??\" style=\"--HB_src:url(http://i.imgur.com/hMna6G0.png);\"></p>');
|
||||
});
|
||||
|
||||
it('Handle Extra spaces in image alt-text 4', function(){
|
||||
const source='{height=20%,width=20%}';
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered).toBe('<p><img style=\"--HB_src:url(http://i.imgur.com/hMna6G0.png);\" loading="lazy" src=\"http://i.imgur.com/hMna6G0.png\" alt=\"where is my image??\" height=\"20%\" width=\"20%\"></p>');
|
||||
});
|
||||
});
|
||||
@@ -463,73 +463,73 @@ describe('Regression Tests', ()=>{
|
||||
describe('Custom Math Function Tests', ()=>{
|
||||
it('Sign Test', function() {
|
||||
const source = `[a]: 13\n\n[b]: -11\n\nPositive: $[sign(a)]\n\nNegative: $[sign(b)]`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered).toBe('<p>Positive: +</p><p>Negative: -</p>');
|
||||
});
|
||||
|
||||
it('Signed Test', function() {
|
||||
const source = `[a]: 13\n\n[b]: -11\n\nPositive: $[signed(a)]\n\nNegative: $[signed(b)]`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered).toBe('<p>Positive: +13</p><p>Negative: -11</p>');
|
||||
});
|
||||
|
||||
it('Roman Numerals Test', function() {
|
||||
const source = `[a]: 18\n\nRoman Numeral: $[toRomans(a)]`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered).toBe('<p>Roman Numeral: XVIII</p>');
|
||||
});
|
||||
|
||||
it('Roman Numerals Test - Uppercase', function() {
|
||||
const source = `[a]: 18\n\nRoman Numeral: $[toRomansUpper(a)]`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered).toBe('<p>Roman Numeral: XVIII</p>');
|
||||
});
|
||||
|
||||
it('Roman Numerals Test - Lowercase', function() {
|
||||
const source = `[a]: 18\n\nRoman Numeral: $[toRomansLower(a)]`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered).toBe('<p>Roman Numeral: xviii</p>');
|
||||
});
|
||||
|
||||
it('Number to Characters Test', function() {
|
||||
const source = `[a]: 18\n\n[b]: 39\n\nCharacters: $[toChar(a)] $[toChar(b)]`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered).toBe('<p>Characters: R AM</p>');
|
||||
});
|
||||
|
||||
it('Number to Characters Test - Uppercase', function() {
|
||||
const source = `[a]: 18\n\n[b]: 39\n\nCharacters: $[toCharUpper(a)] $[toCharUpper(b)]`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered).toBe('<p>Characters: R AM</p>');
|
||||
});
|
||||
|
||||
it('Number to Characters Test - Lowercase', function() {
|
||||
const source = `[a]: 18\n\n[b]: 39\n\nCharacters: $[toCharLower(a)] $[toCharLower(b)]`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered).toBe('<p>Characters: r am</p>');
|
||||
});
|
||||
|
||||
it('Number to Words Test', function() {
|
||||
const source = `[a]: 80085\n\nWords: $[toWords(a)]`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered).toBe('<p>Words: eighty thousand and eighty-five</p>');
|
||||
});
|
||||
|
||||
it('Number to Words Test - Uppercase', function() {
|
||||
const source = `[a]: 80085\n\nWords: $[toWordsUpper(a)]`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered).toBe('<p>Words: EIGHTY THOUSAND AND EIGHTY-FIVE</p>');
|
||||
});
|
||||
|
||||
it('Number to Words Test - Lowercase', function() {
|
||||
const source = `[a]: 80085\n\nWords: $[toWordsLower(a)]`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered).toBe('<p>Words: eighty thousand and eighty-five</p>');
|
||||
});
|
||||
|
||||
it('Number to Words Test - Capitalized', function() {
|
||||
const source = `[a]: 80085\n\nWords: $[toWordsCaps(a)]`;
|
||||
const rendered = Markdown.render(source).trimReturns();
|
||||
const rendered = hbfm.render(source).trimReturns();
|
||||
expect(rendered).toBe('<p>Words: Eighty Thousand And Eighty-Five</p>');
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@
|
||||
.note table tbody tr:nth-child(odd) { background : #FFFFFF; }
|
||||
|
||||
/* DROP CAP */
|
||||
.first-letter, .drop-cap
|
||||
h1 + p::first-letter {
|
||||
color : black;
|
||||
background-image : unset;
|
||||
|
||||
@@ -30,6 +30,7 @@ export default [
|
||||
name : 'Tweak Drop Cap',
|
||||
icon : 'fas fa-sliders-h',
|
||||
gen : dedent`/* Drop Cap settings */
|
||||
.page .first-letter, .page .drop-cap,
|
||||
.page h1 + p::first-letter {
|
||||
font-family: SolberaImitationRemake;
|
||||
font-size: 3.5cm;
|
||||
|
||||
+16
-15
@@ -85,23 +85,24 @@
|
||||
line-height : 1em;
|
||||
-webkit-column-span : all;
|
||||
-moz-column-span : all;
|
||||
& + p::first-letter {
|
||||
float : left;
|
||||
padding-bottom : 2px;
|
||||
padding-left : 40px; //Allow background color to extend into margins
|
||||
margin-top : -0.3cm;
|
||||
margin-bottom : -20px;
|
||||
margin-left : -40px;
|
||||
font-family : 'SolberaImitationRemake';
|
||||
font-size : 3.5cm;
|
||||
line-height : 1em;
|
||||
color : rgba(0, 0, 0, 0);
|
||||
background-image : linear-gradient(-45deg, #322814, #998250, #322814);
|
||||
-webkit-background-clip : text;
|
||||
background-clip : text;
|
||||
}
|
||||
& + p::first-line { font-variant : small-caps; }
|
||||
}
|
||||
.first-letter, .drop-cap,
|
||||
h1 + p::first-letter {
|
||||
float : left;
|
||||
padding-bottom : 2px;
|
||||
padding-left : 40px; //Allow background color to extend into margins
|
||||
margin-top : -0.3cm;
|
||||
margin-bottom : -20px;
|
||||
margin-left : -40px;
|
||||
font-family : 'SolberaImitationRemake';
|
||||
font-size : 3.5cm;
|
||||
line-height : 1em;
|
||||
color : rgba(0, 0, 0, 0);
|
||||
background-image : linear-gradient(-45deg, #322814, #998250, #322814);
|
||||
-webkit-background-clip : text;
|
||||
background-clip : text;
|
||||
}
|
||||
h2 {
|
||||
//margin-top : 0px; //Font is misaligned. Shift up slightly
|
||||
//margin-bottom : 0.05cm;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Markdown from '@shared/markdown.js';
|
||||
import hbfm from 'hbmarkedwrapper';
|
||||
|
||||
export default {
|
||||
createFooterFunc : function(headerSize=1){
|
||||
|
||||
@@ -80,21 +80,22 @@
|
||||
font-size : 0.89cm;
|
||||
font-variant : small-caps;
|
||||
line-height : 1em;
|
||||
& + p::first-letter {
|
||||
float : left;
|
||||
padding-top : 0.3em;
|
||||
padding-bottom : 2px;
|
||||
padding-left : 40px; //Allow background color to extend into margins
|
||||
margin-top : -0.3cm;
|
||||
margin-right : 0.1em;
|
||||
margin-bottom : -20px;
|
||||
margin-left : -40px;
|
||||
font-family : 'FrederickaTheGreat';
|
||||
font-size : 1.9em;
|
||||
line-height : 1em;
|
||||
}
|
||||
& + p::first-line { font-variant : small-caps; }
|
||||
}
|
||||
.first-letter, .drop-cap,
|
||||
h1 + p::first-letter {
|
||||
float : left;
|
||||
padding-top : 0.3em;
|
||||
padding-bottom : 2px;
|
||||
padding-left : 40px; //Allow background color to extend into margins
|
||||
margin-top : -0.3cm;
|
||||
margin-right : 0.1em;
|
||||
margin-bottom : -20px;
|
||||
margin-left : -40px;
|
||||
font-family : 'FrederickaTheGreat';
|
||||
font-size : 1.9em;
|
||||
line-height : 1em;
|
||||
}
|
||||
h2 {
|
||||
font-size : 0.62cm;
|
||||
line-height : 0.988em; //Font is misaligned. Shift up slightly
|
||||
|
||||
Reference in New Issue
Block a user