Merge branch 'master' into v4Metadata

This commit is contained in:
David Bolack
2026-07-04 15:13:39 -05:00
29 changed files with 2036 additions and 1503 deletions
+4
View File
@@ -111,6 +111,10 @@ body {
vertical-align : middle;
text-align : center;
border-right : 1px solid;
max-width:50ch;
overflow:hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:last-child { border-right : none; }
}
@@ -1,71 +1,176 @@
import React from 'react';
import createReactClass from 'create-react-class';
import React, { useState } from 'react';
import request from 'superagent';
import Moment from 'moment';
const BrewCleanup = createReactClass({
displayName : 'BrewCleanup',
getDefaultProps(){
return {};
},
getInitialState() {
return {
count : 0,
const BrewCleanup = ({})=>{
const [junkBrewCollection, setJunkBrewCollection] = useState([]);
const [lostBrewCollection, setLostBrewCollection] = useState([]);
const [pendingJunk, setPendingJunk] = useState(false);
const [pendingLost, setPendingLost] = useState(false);
const [error, setError] = useState(null);
pending : false,
primed : false,
err : null
};
},
prime(){
this.setState({ pending: true });
const find = async (type)=>{
request.get('/admin/cleanup')
.then((res)=>this.setState({ count: res.body.count, primed: true }))
.catch((err)=>this.setState({ error: err }))
.finally(()=>this.setState({ pending: false }));
},
cleanup(){
this.setState({ pending: true });
if(type === 'junk') try {
setPendingJunk(true);
const res = await request.get('/admin/cleanupJunk');
request.post('/admin/cleanup')
.then((res)=>this.setState({ count: res.body.count }))
.catch((err)=>this.setState({ error: err }))
.finally(()=>this.setState({ pending: false, primed: false }));
},
renderPrimed(){
if(!this.state.primed) return;
if(!this.state.count){
return <div className='result noBrews'>No Matching Brews found.</div>;
setJunkBrewCollection(res.body.brewCollection);
} catch (err) {
setError(err);
} finally {
setPendingJunk(false);
}
if(type === 'lost') try {
setPendingLost(true);
const res = await request.get('/admin/cleanupLost');
setLostBrewCollection(res.body.brewCollection);
} catch (err) {
setError(err);
} finally {
setPendingLost(false);
}
};
const cleanup = async (type)=>{
if(type === 'junk') try {
setPendingJunk(true);
console.log('deleting junk');
const res = await request.post('/admin/cleanupJunk');
} catch (err) {
setError(err);
} finally {
setPendingJunk(false);
setJunkBrewCollection([]);
}
if(type === 'lost') try {
setPendingLost(true);
const res = await request.post('/admin/cleanupLost');
} catch (err) {
setError(err);
} finally {
setPendingLost(false);
setLostBrewCollection([]);
}
};
const renderBrewList = (type)=>{
const brewList = type === 'lost' ? lostBrewCollection : junkBrewCollection;
if(!brewList || brewList.length === 0) {
return <>
<h3>{`Results - No brews found` }</h3>
<table className='resultsTable'>
<thead>
<tr>
<th>Title</th>
<th>Last Update</th>
<th>last viewed</th>
<th>Storage</th>
</tr>
</thead>
<tbody>
<tr>
<td colSpan={4}><strong>"No brews found"</strong></td>
</tr>
</tbody>
</table>
</>;
}
console.log(type);
console.log(brewList);
return <>
<h3>{`Results - ${brewList.length} brews` }</h3>
<table className='resultsTable'>
<thead>
<tr>
<th>Title</th>
<th>Last Update</th>
<th>last viewed</th>
<th>Storage</th>
</tr>
</thead>
<tbody>
{brewList
.sort((a, b)=>{ // Sort brews from most recently updated
if(a.lastViewed > b.lastViewed) return -1;
return 1;
})
.map((brew, idx)=>{
return <tr key={idx}>
<td><strong>{brew.title || 'No Title'}</strong></td>
<td style={{ width: '200px' }}>{Moment(brew.updatedAt).fromNow()}</td>
<td>{brew.lastViewed ? Moment(brew.lastViewed).fromNow() : 'No last viewed date'}</td>
<td>{brew.googleId ? 'Google' : 'Homebrewery'}</td>
</tr>
})}
</tbody>
</table>
</>;
};
const renderFound = (type)=>{
const deleteButton = !(type === 'junk' && junkBrewCollection.length === 0 || type === 'lost' && lostBrewCollection.length === 0);
return <div className='result'>
<button onClick={this.cleanup} className='remove'>
{this.state.pending
{deleteButton && <button onClick={()=>cleanup(type)} className='remove'>
{pendingLost && type === "lost" || pendingJunk && type === "junk"
? <i className='fas fa-spin fa-spinner' />
: <span><i className='fas fa-times' /> Remove</span>
}
</button>
<span>Found {this.state.count} Brews that could be removed. </span>
}
{renderBrewList(type)}
</div>;
},
render(){
return <div className='brewUtil brewCleanup'>
<h2> Brew Cleanup </h2>
<p>Removes very short brews to tidy up the database</p>
};
const renderJunkBrewCleanup = ()=>{
return <div className='junk'>
<h3> Junk brews</h3>
<p>Queries unauthored brews that have not been viewed or <br/>updated in 30 days and are shorter than 140 bytes (up to 300)</p>
<button onClick={this.prime} className='query'>
{this.state.pending
<button onClick={()=>find('junk')} className='query'>
{pendingJunk
? <i className='fas fa-spin fa-spinner' />
: 'Query Brews'
}
</button>
{this.renderPrimed()}
{renderFound('junk')}
{this.state.error
&& <div className='error noBrews'>{this.state.error.toString()}</div>
}
{error && <div className='error noBrews'>{error.toString()}</div>}
</div>;
}
});
};
const renderLostBrewCleanup = ()=>{
return <div className='lost'>
<h3> Lost brews</h3>
<p>Queries unauthored brews that have not been <br/>updated or viewed for 2 years (up to 500)</p>
<button onClick={()=>find('lost')} className='query'>
{pendingLost
? <i className='fas fa-spin fa-spinner' />
: 'Query Brews'
}
</button>
{renderFound('lost')}
{error && <div className='error noBrews'>{error.toString()}</div>}
</div>;
};
return <div className='brewUtil brewCleanup'>
<h2> Brew Cleanup </h2>
{renderJunkBrewCleanup()}
<br/>
<br/>
{renderLostBrewCleanup()}
</div>;
};
export default BrewCleanup;
+17 -72
View File
@@ -1,4 +1,4 @@
/* eslint max-lines: ["error", { "max": 400 }] */
/* eslint max-lines: ["error", { "max": 405 }] */
import './codeEditor.less';
import React, { useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
@@ -10,20 +10,26 @@ import {
highlightActiveLine,
scrollPastEnd,
Decoration,
ViewPlugin,
drawSelection,
dropCursor,
rectangularSelection,
crosshairCursor,
} from '@codemirror/view';
import { EditorState, Compartment, StateEffect, StateField } from '@codemirror/state';
import { foldAll as foldAllCmd, unfoldAll as unfoldAllCmd, foldGutter, foldKeymap, foldEffect, foldState, syntaxHighlighting } from '@codemirror/language';
import {
unfoldAll as unfoldAllCmd,
foldGutter,
foldKeymap,
foldEffect,
foldState,
syntaxHighlighting,
} from '@codemirror/language';
import { defaultKeymap, history, undo, redo, undoDepth, redoDepth } from '@codemirror/commands';
import { languages } from '@codemirror/language-data';
import { css } from '@codemirror/lang-css';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { html } from '@codemirror/lang-html';
import { autocompleteEmoji } from './autocompleteEmoji.js';
import { autocompleteEmoji } from './extensions/autocompleteEmoji.js';
import { searchKeymap, search } from '@codemirror/search';
import { closeBrackets } from '@codemirror/autocomplete';
@@ -37,69 +43,13 @@ const themes = { default: defaultCM5Theme, ...cm5Themes, darkbrewery };
const themeCompartment = new Compartment();
const highlightCompartment = new Compartment();
import { generalKeymap, markdownKeymap } from './customKeyMaps.js';
import foldOnPages from './customFolding.js';
import { customHighlightStyle, tokenizeCustomMarkdown, tokenizeCustomCSS } from './customHighlight.js';
import { legacyCustomHighlightStyle, legacyTokenizeCustomMarkdown } from './legacyCustomHighlight.js';
import { generalKeymap, markdownKeymap } from './extensions/customKeyMaps.js';
import foldOnPages from './extensions/customFolding.js';
import { customHighlightPlugin, customHighlightStyle } from './extensions/customHighlight.js';
import { legacyCustomHighlightStyle } from './extensions/legacyCustomHighlight.js';
const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
const createHighlightPlugin = (renderer, tab)=>{
//this function takes the custom tokens created in the tokenize function in customhighlight files
//takes the tokens defined by that function and assigns classes to them
//it also creates page number and snippet number widgets
let tokenize;
if(tab === 'brewStyles') {
tokenize = tokenizeCustomCSS;
} else {
tokenize = renderer === 'V3' ? tokenizeCustomMarkdown : legacyTokenizeCustomMarkdown;
}
return ViewPlugin.fromClass(
class {
constructor(view) {
this.decorations = this.buildDecorations(view);
}
update(update) {
if(update.docChanged) {
this.decorations = this.buildDecorations(update.view);
}
}
buildDecorations(view) {
const decos = [];
const tokens = tokenize(view.state.doc.toString());
let pageCount = 1;
let snippetCount = 0;
tokens.forEach((tok)=>{
const line = view.state.doc.line(tok.line + 1);
if(tok.from != null && tok.to != null && tok.from < tok.to) {
decos.push(Decoration.mark({ class: `cm-${tok.type}` }).range(line.from + tok.from, line.from + tok.to));
} else {
decos.push(Decoration.line({ class: `cm-${tok.type}` }).range(line.from));
if(tok.type === 'pageLine' && tab === 'brewText') {
pageCount++;
line.from === 0 && pageCount--;
decos.push(Decoration.line({ attributes: { 'data-page-number': pageCount } }).range(line.from));
}
if(tok.type === 'snippetLine' && tab === 'brewSnippets') {
snippetCount++;
decos.push(Decoration.line({ attributes: { 'data-page-number': snippetCount } }).range(line.from));
}
}
});
decos.sort((a, b)=>a.from - b.from || a.to - b.to);
return Decoration.set(decos);
}
},
{ decorations: (v)=>v.decorations }
);
};
const setProgrammaticCursorLine = StateEffect.define();
const programmaticCursorLineField = StateField.define({
@@ -206,8 +156,6 @@ const CodeEditor = forwardRef(
? syntaxHighlighting(customHighlightStyle)
: syntaxHighlighting(legacyCustomHighlightStyle);
const customHighlightPlugin = createHighlightPlugin(renderer, tab);
const languageExtension = language === 'css' ? css() : [markdown({ base: markdownLanguage, codeLanguages: languages }), html({ autoCloseTags: true })];
const themeExtension = Array.isArray(themes[editorTheme]) ? themes[editorTheme] : themes[editorTheme] || themes['default'];
@@ -230,7 +178,7 @@ const CodeEditor = forwardRef(
}),
//highlights
highlightCompartment.of([customHighlightPlugin, highlightExtension]),
highlightCompartment.of([customHighlightPlugin(renderer, tab), highlightExtension]),
themeCompartment.of(themeExtension),
highlightActiveLine(),
highlightActiveLineGutter(),
@@ -371,10 +319,8 @@ const CodeEditor = forwardRef(
? syntaxHighlighting(customHighlightStyle)
: syntaxHighlighting(legacyCustomHighlightStyle);
const customHighlightPlugin = createHighlightPlugin(renderer, tab);
view.dispatch({
effects : highlightCompartment.reconfigure([customHighlightPlugin, highlightExtension]),
effects : highlightCompartment.reconfigure([customHighlightPlugin(renderer, tab), highlightExtension]),
});
}, [renderer, tab]);
@@ -383,7 +329,6 @@ const CodeEditor = forwardRef(
injectText : (text)=>{
const view = viewRef.current;
view.dispatch(
view.state.replaceSelection(text)
);
@@ -454,4 +399,4 @@ const CodeEditor = forwardRef(
},
);
export default CodeEditor;
export default CodeEditor;
+29 -1
View File
@@ -63,7 +63,8 @@
&.term { color : rgb(96, 117, 143); }
&.definition { color : rgb(97, 57, 178); }
}
.cm-block:not(.cm-comment) {
.cm-block:not(.cm-comment),
.cm-block:not(.cm-comment) * {
font-weight : bold;
color : purple;
}
@@ -161,6 +162,33 @@
outline : 1px inset #00000055 !important;
}
.cm-image {
position:relative;
.cm-preview {
object-fit: contain;
position:absolute;
bottom:0;
left:0;
height:200px;
width:200px;
height:200px;
padding:5px;
background-color: #fff;
border-radius:10px;
border:3px solid grey;
pointer-events: none;
opacity:0;
transition:0.2s opacity 0.5s;
translate:0 100%;
z-index:1000;
}
&:hover .cm-preview {
opacity:1;
}
}
/* Tab character visualization (optional) */
//.cm-tab {
// background: url(...) no-repeat right;
@@ -1,5 +1,15 @@
/* eslint max-lines: ["error", { "max": 500 }] */
import { HighlightStyle } from '@codemirror/language';
import { tags } from '@lezer/highlight';
import { legacyTokenizeCustomMarkdown } from './legacyCustomHighlight';
import {
Decoration,
ViewPlugin,
} from '@codemirror/view';
import {
syntaxTree,
ensureSyntaxTree
} from '@codemirror/language';
// Making the tokens
const customTags = {
@@ -23,7 +33,7 @@ const customTags = {
variable : 'variable',
};
export function tokenizeCustomMarkdown(text) {
function tokenizeCustomMarkdown(text) {
const tokens = [];
const lines = text.split('\n');
@@ -149,7 +159,6 @@ export function tokenizeCustomMarkdown(text) {
if(!/^::/.test(lines[lineNumber]) && lineNumber + 1 < lines.length && /^::/.test(lines[lineNumber + 1])) {
const startLine = lineNumber;
const defs = [];
let endLine = startLine;
// collect all following :: definitions
for (let i = lineNumber + 1; i < lines.length; i++) {
@@ -158,7 +167,6 @@ export function tokenizeCustomMarkdown(text) {
const defMatch = /^(::)(.+)$/.exec(nextLine);
if(!onlyColonsMatch && defMatch) {
defs.push({ colons: defMatch[1], desc: defMatch[2], line: i });
endLine = i;
} else break;
}
@@ -202,6 +210,7 @@ export function tokenizeCustomMarkdown(text) {
if(lineText.includes('{') && lineText.includes('}')) {
const injectionRegex = /(?:^|[^{\n])({(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\2})/gmd;
let match;
while ((match = injectionRegex.exec(lineText)) !== null) {
tokens.push({
line : lineNumber,
@@ -241,14 +250,21 @@ export function tokenizeCustomMarkdown(text) {
/^ *{{(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\1 *$|^ *}}$/,
);
if(match) endCh = match.index + match[0].length;
tokens.push({ line: lineNumber, type: customTags.block });
const closingMatch = lineText.match(/ *(}})/d);
if(closingMatch) {
tokens.push({ line: lineNumber, from: closingMatch.indices[1][0], to: closingMatch.indices[1][1], type: customTags.block });
} else {
tokens.push({ line: lineNumber, type: customTags.block });
}
}
});
return tokens;
}
export function tokenizeCustomCSS(text) {
function tokenizeCustomCSS(text) {
const tokens = [];
const lines = text.split('\n');
@@ -307,5 +323,150 @@ export const customHighlightStyle = HighlightStyle.define([
]);
function getUrl(node, doc) {
let url = null;
const cursor = node.node.cursor();
if(cursor.firstChild()) {
do {
if(cursor.name === 'URL') {
url = doc.sliceString(cursor.from, cursor.to);
break;
}
} while (cursor.nextSibling());
}
return url;
}
import { WidgetType } from '@codemirror/view';
class ImageWidget extends WidgetType {
constructor(url) {
super();
this.url = url;
}
toDOM() {
const img = document.createElement('img');
img.loading = "lazy";
img.className = 'cm-preview';
img.src = this.url;
img.onerror = ()=>{
img.src = 'client/icons/broken-image.jpg';
};
return img;
}
eq(other) {
return other.url === this.url;
}
}
export function customHighlightPlugin(renderer, tab) {
//this function takes the custom tokens created in the tokenize function in customhighlight files
//takes the tokens defined by that function and assigns classes to them
//it also creates page number and snippet number widgets
let tokenize;
if(tab === 'brewStyles') {
tokenize = tokenizeCustomCSS;
} else {
tokenize = renderer === 'V3' ? tokenizeCustomMarkdown : legacyTokenizeCustomMarkdown;
}
return ViewPlugin.fromClass(
class {
constructor(view) {
this.decorations = this.buildDecorations(view);
}
update(update) {
if(update.docChanged) {
this.decorations = this.buildDecorations(update.view);
}
}
buildDecorations(view) {
const decos = [];
const tokens = tokenize(view.state.doc.toString());
let pageCount = 1;
let snippetCount = 0;
const tree = ensureSyntaxTree(view.state, view.state.doc.length, 50) || syntaxTree(view.state);
tree.iterate({
enter : (node)=>{
if(node.name === 'Image') {
const url = getUrl(node, view.state.doc);
const widgetPosition = node.node.lastChild.from;
//this is not exactly standard, but should hold,
//and is the shortest way i could find of positioning
//the image inside the cm-image node
if(!url) return;
decos.push(
Decoration.mark({
class : 'cm-image'
}).range(node.from, node.to)
);
decos.push(
Decoration.widget({
widget : new ImageWidget(url),
side : 1
}).range(widgetPosition)
);
}
}
});
tokens.forEach((token)=>{
const line = view.state.doc.line(token.line + 1);
if(token.from != null && token.to != null && token.from < token.to) {
const from = line.from + token.from;
const to = line.from + token.to;
const attrs = {};
if(token.type === 'Image' && token.url) {
attrs['data-url'] = token.url;
}
decos.push(
Decoration.mark({
class : `cm-${token.type}`,
...(Object.keys(attrs).length
? { attributes: attrs }
: {})
}).range(from, to)
);
} else {
decos.push(
Decoration.line({
class : `cm-${token.type}`
}).range(line.from)
);
if(token.type === 'pageLine' && tab === 'brewText') {
pageCount++;
if(line.from === 0) pageCount--;
decos.push(Decoration.line({ attributes: { 'data-page-number': pageCount } }).range(line.from));
}
if(token.type === 'snippetLine' && tab === 'brewSnippets') {
snippetCount++;
decos.push(Decoration.line({ attributes: { 'data-page-number': snippetCount } }).range(line.from));
}
}
});
decos.sort((a, b)=>a.from - b.from || a.to - b.to);
return Decoration.set(decos);
}
},
{ decorations: (v)=>v.decorations }
);
};
+5 -3
View File
@@ -18,8 +18,7 @@ const SplitPane = (props)=>{
const [liveScroll, setLiveScroll] = useState(false);
useEffect(()=>{
const savedPos = window.localStorage.getItem(PANE_WIDTH_KEY);
setDividerPos(savedPos ? limitPosition(savedPos, 0.1 * (window.innerWidth - 13), 0.9 * (window.innerWidth - 13)) : window.innerWidth / 2);
handleResize();
setLiveScroll(window.localStorage.getItem(LIVE_SCROLL_KEY) === 'true');
window.addEventListener('resize', handleResize);
@@ -29,7 +28,10 @@ const SplitPane = (props)=>{
const limitPosition = (x, min = 1, max = window.innerWidth - 13)=>Math.round(Math.min(max, Math.max(min, x)));
//when resizing, the divider should grow smaller if less space is given, then grow back if the space is restored, to the original position
const handleResize = ()=>setDividerPos(limitPosition(window.localStorage.getItem(PANE_WIDTH_KEY), 0.1 * (window.innerWidth - 13), 0.9 * (window.innerWidth - 13)));
const handleResize = ()=>{
const savedPos = window.localStorage.getItem(PANE_WIDTH_KEY);
setDividerPos(savedPos ? limitPosition(savedPos, 0.1 * (window.innerWidth - 13), 0.9 * (window.innerWidth - 13)) : window.innerWidth / 2);
};
const handleUp =(e)=>{
e.preventDefault();
+29 -5
View File
@@ -29,7 +29,6 @@ const TOOLBAR_STATE_KEY = 'HB_renderer_toolbarState';
const INITIAL_CONTENT = dedent`
<!DOCTYPE html><html><head>
<link href="//fonts.googleapis.com/css?family=Open+Sans:400,300,600,700" rel="stylesheet" type="text/css" />
<link href='/homebrew/bundle.css' type="text/css" rel='stylesheet' />
<link href="${brewRendererStylesUrl}" rel="stylesheet" />
<link href="${headerNavStylesUrl}" rel="stylesheet" />
@@ -42,6 +41,7 @@ const BrewPage = (props)=>{
props = {
contents : '',
index : 0,
hoisted : false,
...props
};
const pageRef = useRef(null);
@@ -91,6 +91,7 @@ const BrewPage = (props)=>{
//v=====--------------------< Brew Renderer Component >-------------------=====v//
let renderedPages = [];
let pageTemplates = [];
let rawPages = [];
const BrewRenderer = (props)=>{
@@ -208,6 +209,20 @@ const BrewRenderer = (props)=>{
styles = _.mapKeys(styles, (v, k)=>k.startsWith('--') ? k : _.camelCase(k)); // Convert CSS to camelCase for React
classes = [classes, injectedTags.classes].join(' ').trim();
attributes = injectedTags.attributes;
if(global.enablev4) {
if (attributes && Object.hasOwn(attributes, 'hbtemplate')) {
pageTemplates[index] = attributes['hbtemplate'];
}
}
}
if(global.enablev4) {
// If we don't have a template for this page, look backwards until one is found or the first page.
if(!pageTemplates[index]) {
for (let i=index;i>=0; i--) {
// If one is found, add the template attribute
if (pageTemplates[i]) attributes['hbtemplate'] = pageTemplates[i];
}
}
}
pageText = pageText.includes('\n') ? pageText.substring(pageText.indexOf('\n') + 1) : ''; // Remove the \page line
}
@@ -221,22 +236,31 @@ const BrewRenderer = (props)=>{
}
};
const renderPages = ()=>{
const renderPages = (checkHoists = false)=>{
if(props.errors && props.errors.length)
return renderedPages;
if(rawPages.length != renderedPages.length) // Re-render all pages when page count changes
if(rawPages.length != renderedPages.length) { // Re-render all pages when page count changes
renderedPages.length = 0;
pageTemplates.length = 0;
}
// Render currently-edited page first so cross-page effects (variables, links) can propagate out first
if(rawPages.length > props.currentEditorCursorPageNum -1)
renderedPages[props.currentEditorCursorPageNum - 1] = renderPage(rawPages[props.currentEditorCursorPageNum - 1], props.currentEditorCursorPageNum - 1);
_.forEach(rawPages, (page, index)=>{
if((isInView(index) || !renderedPages[index]) && typeof window !== 'undefined'){
const varsOnPageRegex = /([!$]?)\[((?!\s*\])(?:\\.|[^\[\]\\])+)\]/g; // Find out if there are any vars on the page.
const forceRender = checkHoists &&
!props.hoisted &&
(page.match(varsOnPageRegex)); // forceRender forces pages outside of the PPR range to render if true.
// This is necessary on the first load to fully populate the variable table.
if((isInView(index) || !renderedPages[index] || forceRender) && typeof window !== 'undefined'){
renderedPages[index] = renderPage(page, index); // Render any page not yet rendered, but only re-render those in PPR range
}
});
if(!props.hoisted) { props.hoisted = true; } // Only fully hoist once.
return renderedPages;
};
@@ -276,7 +300,7 @@ const BrewRenderer = (props)=>{
window.addEventListener('hashchange', ()=>scrollToHash(window.location.hash));
setTimeout(()=>{ //We still see a flicker where the style isn't applied yet, so wait 100ms before showing iFrame
renderPages(); //Make sure page is renderable before showing
renderPages(true); //Make sure page is renderable before showing
setState((prevState)=>({
...prevState,
isMounted : true,
+7 -1
View File
@@ -37,9 +37,15 @@ const Homebrew = (props)=>{
lang : ''
},
userThemes,
brews
brews,
enablev4
} = props;
global.account = account;
global.version = version;
global.config = config;
global.enablev4 = enablev4;
const backgroundObject = ()=>{
if(config?.deployment || (config?.local && config?.development)) {
const bgText = config?.deployment || 'Local';
+18 -2
View File
@@ -1,9 +1,25 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import Nav from './nav.jsx';
import { printCurrentBrew } from '@shared/helpers.js';
export default function(){
const [printing, setPrinting] = useState(false);
// listen for print cycle events to display "loading" message since it can take some time.
useEffect(()=>{
document.addEventListener('print:startprep', handlePrintStartPrep);
document.addEventListener('print:finishedprep', handlePrintPrepFinished);
return ()=>{
document.removeEventListener('print:startprep', handlePrintStartPrep);
document.removeEventListener('print:finishedprep', handlePrintPrepFinished);
}
}, []);
const handlePrintStartPrep = ()=>{ setPrinting(true); };
const handlePrintPrepFinished = ()=>{ setPrinting(false); };
return <Nav.item onClick={printCurrentBrew} color='purple' icon='far fa-file-pdf'>
get PDF
{printing ? 'loading' : 'get PDF'}
</Nav.item>;
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+2 -1
View File
@@ -7,5 +7,6 @@
"local_environments" : ["docker", "local"],
"publicUrl" : "https://homebrewery.naturalcrit.com",
"hb_images" : null,
"hb_fonts" : null
"hb_fonts" : null,
"enablev4" : true
}
-4
View File
@@ -5,10 +5,6 @@
<meta
name="viewport"
content="width=device-width, initial-scale=1, height=device-height, interactive-widget=resizes-visual" />
<link
href="//fonts.googleapis.com/css?family=Open+Sans:400,300,600,700"
rel="stylesheet"
type="text/css" />
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon" />
<meta name="twitter:card" content="summary" />
+1461 -1305
View File
File diff suppressed because it is too large Load Diff
+16 -16
View File
@@ -86,12 +86,12 @@
]
},
"dependencies": {
"@babel/core": "^7.29.0",
"@babel/plugin-transform-runtime": "^7.29.0",
"@babel/core": "^7.29.7",
"@babel/plugin-transform-runtime": "^7.29.7",
"@babel/preset-env": "^7.29.5",
"@babel/preset-react": "^7.28.5",
"@babel/preset-react": "^7.29.7",
"@babel/runtime": "^7.29.2",
"@codemirror/autocomplete": "^6.20.2",
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.3",
"@codemirror/highlight": "^0.19.8",
"@codemirror/lang-css": "^6.3.1",
@@ -101,9 +101,9 @@
"@codemirror/language-data": "^6.5.2",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.0",
"@codemirror/view": "^6.43.1",
"@dmsnell/diff-match-patch": "^1.1.0",
"@googleapis/drive": "^20.1.0",
"@googleapis/drive": "^20.2.0",
"@lezer/highlight": "^1.2.3",
"@sanity/diff-match-patch": "^3.2.0",
"@vitejs/plugin-react": "^5.1.2",
@@ -114,38 +114,38 @@
"core-js": "^3.49.0",
"cors": "^2.8.5",
"create-react-class": "^15.7.0",
"dedent": "^1.7.1",
"dedent": "^1.7.2",
"express": "^5.1.0",
"express-async-handler": "^1.2.0",
"express-static-gzip": "3.0.1",
"fflate": "^0.8.2",
"fflate": "^0.8.3",
"fs-extra": "^11.3.5",
"hash-wasm": "^4.12.0",
"idb-keyval": "^6.2.2",
"js-yaml": "^4.1.1",
"idb-keyval": "^6.2.5",
"js-yaml": "^4.2.0",
"jwt-simple": "^0.5.6",
"less": "^4.6.4",
"lodash": "^4.18.1",
"marked": "15.0.12",
"marked-alignment-paragraphs": "^1.0.0",
"marked-definition-lists": "^1.0.1",
"marked-emoji": "^2.0.2",
"marked-emoji": "^2.0.3",
"marked-extended-tables": "^2.0.1",
"marked-gfm-heading-id": "^4.1.3",
"marked-gfm-heading-id": "^4.1.4",
"marked-nonbreaking-spaces": "^1.0.1",
"marked-smartypants-lite": "^1.0.3",
"marked-subsuper-text": "^1.0.4",
"marked-variables": "^1.0.5",
"markedLegacy": "npm:marked@^0.3.19",
"moment": "^2.30.1",
"mongoose": "^9.6.2",
"mongoose": "^9.7.0",
"nanoid": "5.1.11",
"nconf": "^0.13.0",
"node": "^25.9.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-frame-component": "^5.3.2",
"react-router": "^7.15.1",
"react-router": "^7.17.0",
"sanitize-filename": "1.6.4",
"superagent": "^10.2.1"
},
+48 -11
View File
@@ -38,15 +38,30 @@ export default function createAdminApi(vite) {
throw { HBErrorCode: '52', code: 401, message: 'Access denied' };
}
};
const junkBrewPipeline = [
// Search for up to 300 brews that have not been viewed or updated in 30 days and are shorter than 140 bytes
const junkBrewsPipeline = [
{ $match : {
updatedAt : { $lt: Moment().subtract(30, 'days').toDate() },
lastViewed : { $lt: Moment().subtract(30, 'days').toDate() }
} },
{ $project: { textBinSize: { $binarySize: '$textBin' } } },
{ $project: { _id: 1, textBinSize: { $binarySize: '$textBin' }, updatedAt: 1, lastViewed: 1} },
{ $match: { textBinSize: { $lt: 140 } } },
{ $limit: 100 }
{ $limit: 300 }
];
// Search for up to 500 unauthored brews that have not been viewed or updated in two years
const lostBrewsPipeline = [
{
$match: {
authors: [],
updatedAt: { $lt: Moment().subtract(2, 'years').toDate() },
lastViewed: { $lt: Moment().subtract(2, 'years').toDate() }
}
},
{
$limit: 500
}
];
/* Search for brews that aren't compressed (missing the compressed text field) */
@@ -54,19 +69,41 @@ export default function createAdminApi(vite) {
'text' : { '$exists': true }
}).lean().limit(10000).select('_id');
// Search for up to 100 brews that have not been viewed or updated in 30 days and are shorter than 140 bytes
router.get('/admin/cleanup', mw.adminOnly, (req, res)=>{
HomebrewModel.aggregate(junkBrewPipeline).option({ maxTimeMS: 60000 })
.then((objs)=>res.json({ count: objs.length }))
router.get('/admin/cleanupJunk', mw.adminOnly, (req, res)=>{
HomebrewModel.aggregate(junkBrewsPipeline).option({ maxTimeMS: 60000 })
.then((objs)=>res.json({ count: objs.length, brewCollection : objs }))
.catch((error)=>{
console.error(error);
res.status(500).json({ error: 'Internal Server Error' });
});
});
// Delete up to 100 brews that have not been viewed or updated in 30 days and are shorter than 140 bytes
router.post('/admin/cleanup', mw.adminOnly, (req, res)=>{
HomebrewModel.aggregate(junkBrewPipeline).option({ maxTimeMS: 60000 })
// Delete result of junkBrewsPipeline
router.post('/admin/cleanupJunk', mw.adminOnly, (req, res)=>{
HomebrewModel.aggregate(junkBrewsPipeline).option({ maxTimeMS: 60000 })
.then((docs)=>{
const ids = docs.map((doc)=>doc._id);
return HomebrewModel.deleteMany({ _id: { $in: ids } });
}).then((result)=>{
res.json({ count: result.deletedCount });
}).catch((error)=>{
console.error(error);
res.status(500).json({ error: 'Internal Server Error' });
});
});
router.get('/admin/cleanupLost', mw.adminOnly, (req, res)=>{
HomebrewModel.aggregate(lostBrewsPipeline).option({ maxTimeMS: 60000 })
.then((objs)=>res.json({ count: objs.length, brewCollection : objs }))
.catch((error)=>{
console.error(error);
res.status(500).json({ error: 'Internal Server Error' });
});
});
// Delete result of lostBrewsPipeline
router.post('/admin/cleanupLost', mw.adminOnly, (req, res)=>{
HomebrewModel.aggregate(lostBrewsPipeline).option({ maxTimeMS: 60000 })
.then((docs)=>{
const ids = docs.map((doc)=>doc._id);
return HomebrewModel.deleteMany({ _id: { $in: ids } });
+28 -7
View File
@@ -139,14 +139,35 @@ const splitTextStyleAndMetadata = (brew)=>{
if(typeof brew.tags === 'string') brew.tags = brew.tags ? [brew.tags] : [];
};
const printCurrentBrew = ()=>{
const printCurrentBrew = async ()=>{
if(window.typeof !== 'undefined') {
window.frames['BrewRenderer'].contentWindow.print();
//Force DOM reflow; Print dialog causes a repaint, and @media print CSS somehow makes out-of-view pages disappear
const node = window.frames['BrewRenderer'].contentDocument.getElementsByClassName('brewRenderer').item(0);
node.style.display='none';
node.offsetHeight; // accessing this is enough to trigger a reflow
node.style.display='';
// fire a custom event for the print cycle
document.dispatchEvent(new CustomEvent('print:startprep'));
try {
const iframeDoc = window.frames['BrewRenderer'].contentDocument;
// get all img elements with lazy loading (currently only elements generated through MarkedJS)
const lazyImages = [...iframeDoc.querySelectorAll('img[loading="lazy"]')];
lazyImages.forEach((img)=>{ img.loading = 'eager'; });
// waits for images to load before resolving promise and opening print dialog
await Promise.all(
lazyImages
.filter((img)=>!img.complete)
.map((img)=>new Promise((resolve)=>{ img.onload = resolve; img.onerror = resolve; }))
);
window.frames['BrewRenderer'].contentWindow.print();
//Force DOM reflow; Print dialog causes a repaint, and @media print CSS somehow makes out-of-view pages disappear
const node = iframeDoc.getElementsByClassName('brewRenderer').item(0);
node.style.display='none';
node.offsetHeight; // accessing this is enough to trigger a reflow
node.style.display='';
} finally {
// when lazy load images have all been loaded, and the doc re-rendered for print preview, emit 'finished' event.
document.dispatchEvent(new CustomEvent('print:finishedprep'));
}
}
};
+1 -1
View File
@@ -83,7 +83,7 @@ renderer.image = function (token) {
if(href === null)
return text;
let out = `<img src="${href}" alt="${text}" style="--HB_src:url(${href});"`;
let out = `<img loading="lazy" src="${href}" alt="${text}" style="--HB_src:url(${href});"`;
if(title)
out += ` title="${title}"`;
+1 -8
View File
@@ -4,14 +4,7 @@
@import './animations.less';
@import './colors.less';
@import './tooltip.less';
@font-face {
font-family : 'CodeLight';
src : url('./CODE Light.otf') format('opentype');
}
@font-face {
font-family : 'CodeBold';
src : url('./CODE Bold.otf') format('opentype');
}
@import './fonts/fonts.css';
html,body, #reactRoot {
height : 100vh;
min-height : 100vh;
+38
View File
@@ -0,0 +1,38 @@
/* open-sans-latin-wght-normal */
@font-face {
font-family : 'Open Sans';
font-style : normal;
font-weight : normal;
src : url('open-sans-latin-400-normal.woff2') format('woff2');
font-display : swap;
unicode-range : U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/*
* Nowhere is font-weight: 700 actually used with Open Sans, everything is set to 800.
* But, 800 *is* too bold. And since we don't have an 800 font file, it's just using the
* 700 font file and it looks fine. Not sure it's worth changing everything to 700?
*/
@font-face {
font-family : 'Open Sans';
font-style : normal;
font-weight : bold;
src : url('open-sans-latin-700-normal.woff2') format('woff2');
font-display : swap;
unicode-range : U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family : 'CodeLight';
font-style : normal;
src : url('./CODE Light.otf') format('opentype');
font-display : block;
}
@font-face {
font-family : 'CodeBold';
font-style : normal;
src : url('./CODE Bold.otf') format('opentype');
font-display : block;
}
+4 -4
View File
@@ -324,7 +324,7 @@ describe('Injection: When an injection tag follows an element', ()=>{
it('Renders an image element with injected style', function() {
const source = '![alt text](https://i.imgur.com/hMna6G0.png){position:absolute}';
const rendered = Markdown.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;" src="https://i.imgur.com/hMna6G0.png" alt="alt text"></p>');
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() {
@@ -343,19 +343,19 @@ describe('Injection: When an injection tag follows an element', ()=>{
it('Renders an image with added attributes', function() {
const source = `![homebrew mug](https://i.imgur.com/hMna6G0.png) {position:absolute,bottom:20px,left:130px,width:220px,a="b and c",d=e}`;
const rendered = Markdown.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;" src="https://i.imgur.com/hMna6G0.png" alt="homebrew mug" a="b and c" d="e"></p>`);
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><img style="--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 = `![homebrew mug](https://i.imgur.com/hMna6G0.png?auth=12345&height=1024) {position:absolute,bottom:20px,left:130px,width:220px,a="b and c",d=e}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><img style="--HB_src:url(https://i.imgur.com/hMna6G0.png?auth=12345&height=1024); position:absolute; bottom:20px; left:130px; width:220px;" src="https://i.imgur.com/hMna6G0.png?auth=12345&height=1024" alt="homebrew mug" a="b and c" d="e"></p>`);
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 = `![homebrew mug](https://i.imgur.com/hMna6G0.png) {position:absolute,bottom:20px,left:130px,width:220px,a="b and c",d=e,otherUrl="url?auth=12345"}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><img style="--HB_src:url(https://i.imgur.com/hMna6G0.png); position:absolute; bottom:20px; left:130px; width:220px;" src="https://i.imgur.com/hMna6G0.png" alt="homebrew mug" a="b and c" d="e" otherUrl="url?auth=12345"></p>`);
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>`);
});
});
+7 -7
View File
@@ -315,21 +315,21 @@ describe('Normal Links and Images', ()=>{
const source = `![alt text](url)`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
<p><img src="url" alt="alt text" style="--HB_src:url(url);"></p>`.trimReturns());
<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 ![alt text](url "and title")!';
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
<p>An image <img src="url" alt="alt text" style="--HB_src:url(url);" title="and title">!</p>`.trimReturns());
<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 = `![alt text](url){width:100px}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
<p><img style="--HB_src:url(url); width:100px;" src="url" alt="alt text"></p>`.trimReturns());
<p><img style="--HB_src:url(url); width:100px;" loading="lazy" src="url" alt="alt text"></p>`.trimReturns());
});
it('Renders normal links', function() {
@@ -438,25 +438,25 @@ describe('Regression Tests', ()=>{
it('Handle Extra spaces in image alt-text 1', function(){
const source='![ where is my image??](http://i.imgur.com/hMna6G0.png)';
const rendered = Markdown.render(source).trimReturns();
expect(rendered).toBe('<p><img src=\"http://i.imgur.com/hMna6G0.png\" alt=\"where is my image??\" style=\"--HB_src:url(http://i.imgur.com/hMna6G0.png);\"></p>');
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='![where is my image??](http://i.imgur.com/hMna6G0.png)';
const rendered = Markdown.render(source).trimReturns();
expect(rendered).toBe('<p><img src=\"http://i.imgur.com/hMna6G0.png\" alt=\"where is my image??\" style=\"--HB_src:url(http://i.imgur.com/hMna6G0.png);\"></p>');
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='![where is my image?? ](http://i.imgur.com/hMna6G0.png)';
const rendered = Markdown.render(source).trimReturns();
expect(rendered).toBe('<p><img src=\"http://i.imgur.com/hMna6G0.png\" alt=\"where is my image??\" style=\"--HB_src:url(http://i.imgur.com/hMna6G0.png);\"></p>');
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='![where is my image??](http://i.imgur.com/hMna6G0.png){height=20%,width=20%}';
const rendered = Markdown.render(source).trimReturns();
expect(rendered).toBe('<p><img style=\"--HB_src:url(http://i.imgur.com/hMna6G0.png);\" src=\"http://i.imgur.com/hMna6G0.png\" alt=\"where is my image??\" height=\"20%\" width=\"20%\"></p>');
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>');
});
});