Merge branches 'cleanup' and 'master' of https://github.com/naturalcrit/homebrewery into cleanup

This commit is contained in:
Víctor Losada Hernández
2026-09-30 17:41:14 +02:00
7 changed files with 218 additions and 175 deletions
+52 -2
View File
@@ -93,6 +93,56 @@ pre {
## changelog
For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery).
### Tuesday 9/29/2026 - v3.24.1
{{taskList
##### calculuschild
* [x] Small hotfix for broken images thrashing the server
}}
### Tuesday 9/29/2026 - v3.24.0
{{taskList
##### calculuschild
* [x] Add a periodic warning to /new page if work has never been saved
* [x] Small fix to variable hoisting logic
Fixes issue [#31](https://github.com/naturalcrit/marked-variables/issues/31)
##### Ericscheid
* [x] Enable "fancy drop caps" on any paragraph, by placing `{drop-cap}` on the line below.
Fixes issue [#5024](https://github.com/naturalcrit/homebrewery/issues/5024)
* [x] Prevent crash if `<script>` tag in brew text
Fixes issue [#546](https://github.com/naturalcrit/homebrewery/issues/546)
##### G-Ambatte
* [x] Add compatibility with Markdeep Ascii art diagrams. See [example brew](https://homebrewery.naturalcrit.com/share/TsZ5Iak3bapb)
* [x] Fix stray hyphens in PDF output titles
Fixes issue [#5069](https://github.com/naturalcrit/homebrewery/issues/5069)
##### 5e-Cleric
* [x] Fix Google Drive files that can't be deleted
Fixes issue [#5020](https://github.com/naturalcrit/homebrewery/issues/5020)
* [x] Prevent co-authors moving brews between Google and Homebrewery storage.
Fixes issue [#4862](https://github.com/naturalcrit/homebrewery/issues/4862)
* [x] Fix cursor position for hotkeys, and other tweaks
Fixes issues [#4758](https://github.com/naturalcrit/homebrewery/issues/4758), [#4790](https://github.com/naturalcrit/homebrewery/issues/4790), [#4809](https://github.com/naturalcrit/homebrewery/issues/4809)
##### 5e-Cleric, G-Ambatte
* [x] Share pages update live when file contents change
##### Abquintic, Calculuschild, G-Ambatte, Gazook89, 5e-Cleric, Ericscheid
* [x] Multiple background fixes and code cleanups
}}
### Monday 9/07/2026 - v3.23.0
{{taskList
##### firstmatekidd (new contributor!)
@@ -140,6 +190,8 @@ Fixes issue [#4904](https://github.com/naturalcrit/homebrewery/issues/4904)
* [x] Multiple background fixes and code cleanups
}}
\page
### Wednesday 5/13/2026 - v3.22.1
{{taskList
##### Frederlk (new Contributor!)
@@ -157,8 +209,6 @@ Fixes issue [#4904](https://github.com/naturalcrit/homebrewery/issues/4904)
Fixes issues [#4771](https://github.com/naturalcrit/homebrewery/issues/4771), [#4783](https://github.com/naturalcrit/homebrewery/issues/4783)
}}
\page
### Saturday 4/04/2026 - v3.21.0
{{taskList
@@ -355,7 +355,7 @@ class ImageWidget extends WidgetType {
img.src = this.url;
img.onerror = ()=>{
img.src = 'client/icons/broken-image.jpg';
img.src = '/icons/broken-image.jpg';
};
return img;
@@ -1,7 +1,7 @@
/* eslint max-lines: ["error", { "max": 300 }] */
import { keymap } from '@codemirror/view';
import { undo, redo, indentMore, indentLess, deleteLine } from '@codemirror/commands';
import { EditorSelection } from '@codemirror/state';
import { EditorSelection, ChangeSet } from '@codemirror/state';
import { Prec } from '@codemirror/state';
import * as prettier from 'prettier/standalone';
import * as postcssPlugin from 'prettier/plugins/postcss';
@@ -35,11 +35,9 @@ export async function formatCSS(view) {
);
if(formatted === code) return true;
const dom = view.dom;
dom.classList.add('cm-flash');
view.dom.classList.add('cm-flash');
setTimeout(()=>{
dom.classList.remove('cm-flash');
view.dom.classList.remove('cm-flash');
view.dispatch({
changes : {
@@ -53,186 +51,172 @@ export async function formatCSS(view) {
} catch (err) {
console.error('Error formatting css: ', err);
}
return true;
}
const forEachSelection = (view, fn)=>{
view.dispatch(view.state.changeByRange((range)=>fn(view.state, range.from, range.to, view.state.doc.sliceString(range.from, range.to))));
return true;
};
const insertTab = (view)=>{
// If any selection spans multiple lines, delegates to CodeMirror's indentMore
// Otherwise inserts two spaces at each cursor/selection
// Otherwise inserts two spaces at each cursor/selection
const shouldIndent = view.state.selection.ranges.some((range)=>view.state.doc.lineAt(range.from).number !==
view.state.doc.lineAt(range.to).number
);
if(shouldIndent) return indentMore(view);
const changes = [];
for (const range of view.state.selection.ranges) {
changes.push({
from : range.from,
to : range.to,
insert : ' ' // Insert two spaces, not a tab char!
});
}
// Create a transaction so we can map old positions to
// their new positions after the edits are applied
const mappedChanges = view.state.update({ changes });
view.dispatch({
changes,
selection : EditorSelection.create(
view.state.selection.ranges.map((range)=>EditorSelection.cursor(
mappedChanges.changes.mapPos(range.from, -1) + 2
)
)
)
return forEachSelection(view, (state, from, to, selected)=>{
return {
changes : { from, to, insert: ' ' }, // Insert two spaces, not a tab char!
range : EditorSelection.cursor(from + 2)
};
});
return true;
};
const wrapSelection = (prefix, suffix)=>(view)=>{
const changes = [];
const wrapSelection = (prefix, suffix)=>(view)=>forEachSelection(view, (state, from, to, selected)=>{
const noSelection = from === to;
if(noSelection) {
const insert = prefix + suffix;
for (const range of view.state.selection.ranges) {
const { from, to } = range;
const selected = view.state.doc.sliceString(from, to);
let text;
if(from === to) { text = prefix + suffix; } else if(selected.startsWith(prefix) && selected.endsWith(suffix)) {
text = selected.slice(prefix.length, -suffix.length);
} else {text = `${prefix}${selected}${suffix}`;}
changes.push({ from, to, insert: text });
return {
changes : { from, to, insert },
range : EditorSelection.cursor(from + prefix.length)
};
}
view.dispatch({
changes
});
const hasWrapper = selected.startsWith(prefix) && selected.endsWith(suffix);
if(hasWrapper) {
return {
changes : [
{ from: to - suffix.length, to, insert: '' },
{ from, to: from + prefix.length, insert: '' }
],
range : EditorSelection.range(from, to - prefix.length - suffix.length)
};
}
return true;
};
const makeNbsp = (view)=>{
const { from } = view.state.selection.main;
const prev2 = from >= 2
? view.state.doc.sliceString(from - 2, from)
: '';
return {
changes : { from, to, insert: prefix + selected + suffix },
range : EditorSelection.range(from, to + prefix.length + suffix.length)
};
});
const makeNbsp = (view)=>forEachSelection(view, (state, from, to, selected)=>{
const prev2 = from >= 2 ? state.doc.sliceString(from - 2, from) : '';
const insert = (prev2 === ':>' || prev2 === '>>') ? '>' : ':>';
view.dispatch({
changes : { from, to: from, insert },
selection : { anchor: from + insert.length },
});
return {
changes : { from, to, insert },
range : EditorSelection.cursor(from + insert.length)
};
});
return true;
};
const makeSpace = (view)=>{
const { from, to } = view.state.selection.main;
const selected = view.state.doc.sliceString(from, to);
const makeSpace = (view)=>forEachSelection(view, (state, from, to, selected)=>{
let insert = '{{width:10% }}';
const match = selected.match(/^{{width:(\d+)% }}$/);
let newText = '{{width:10% }}';
if(match) {
const percent = Math.min(parseInt(match[1], 10) + 10, 100);
newText = `{{width:${percent}% }}`;
insert = `{{width:${percent}% }}`;
}
view.dispatch({ changes: { from, to, insert: newText } });
return true;
};
return {
changes : { from, to, insert },
range : EditorSelection.range(from, from + insert.length)
};
});
const removeSpace = (view)=>{
const { from, to } = view.state.selection.main;
const selected = view.state.doc.sliceString(from, to);
const removeSpace = (view)=>forEachSelection(view, (state, from, to, selected)=>{
const match = selected.match(/^{{width:(\d+)% }}$/);
if(match) {
const percent = parseInt(match[1], 10) - 10;
const newText = percent > 0 ? `{{width:${percent}% }}` : '';
view.dispatch({ changes: { from, to, insert: newText } });
const insert = percent > 0 ? `{{width:${percent}% }}` : '';
return { changes: { from, to, insert: insert }, range: EditorSelection.range(from, from + insert.length) };
}
return true;
};
});
const makeSpan = (view)=>{
const { from, to } = view.state.selection.main;
const selected = view.state.doc.sliceString(from, to);
const text = selected.startsWith('{{') && selected.endsWith('}}')
? selected.slice(2, -2)
: `{{${selected}}}`;
view.dispatch({ changes: { from, to, insert: text } });
return true;
};
const makeDiv = (view)=>{
const { from, to } = view.state.selection.main;
const selected = view.state.doc.sliceString(from, to);
const text = selected.startsWith('{{') && selected.endsWith('}}')
? selected.slice(2, -2)
: `{{\n${selected}\n}}`;
view.dispatch({ changes: { from, to, insert: text } });
return true;
};
const makeComment = (view)=>{
const { from, to } = view.state.selection.main;
const selected = view.state.doc.sliceString(from, to);
const isHtmlComment = selected.startsWith('<!--') && selected.endsWith('-->');
const text = isHtmlComment
? selected.slice(4, -3)
: `<!-- ${selected} -->`;
view.dispatch({ changes: { from, to, insert: text } });
return true;
};
const makeLink = (view)=>{
const { from, to } = view.state.selection.main;
const selected = view.state.doc.sliceString(from, to).trim();
const isLink = /^\[(.*)\]\((.*)\)$/.exec(selected);
const text = isLink ? `${isLink[1]} ${isLink[2]}` : `[${selected || 'alt text'}](url)`;
view.dispatch({ changes: { from, to, insert: text } });
return true;
};
const makeList = (type)=>(view)=>{
const { from, to } = view.state.selection.main;
const lines = [];
for (let l = from; l <= to; l++) {
const lineText = view.state.doc.line(l + 1).text;
lines.push(lineText);
const makeLink = (view)=>forEachSelection(view, (state, from, to, selected)=>{
// If the selection is already a Markdown link, unwrap it
const existingLink = /^\[(.*)\]\((.*)\)$/.exec(selected);
if(existingLink) {
const [, text, url] = existingLink;
const insert = `${text} ${url}`;
return {
changes : { from, to, insert },
range : EditorSelection.range(
from,
from + insert.length
),
};
}
const joined = lines.join('\n');
let newText;
if(type === 'UL') newText = joined.replace(/^/gm, '- ');
else newText = joined.replace(/^/gm, (m, i)=>`${i + 1}. `);
view.dispatch({ changes: { from, to, insert: newText } });
return true;
};
const makeHeader = (level)=>(view)=>{
const { from, to } = view.state.selection.main;
const selected = view.state.doc.sliceString(from, to);
const text = `${'#'.repeat(level)} ${selected}`;
view.dispatch({ changes: { from, to, insert: text } });
return true;
};
// If the selection is a URL, use its domain as the link text.
const isUrl =
/^(https?:\/\/|www\.)\S+$/i.test(selected) ||
/^[a-z0-9-]+(\.[a-z0-9-]+)+([/?#]\S*)?$/i.test(selected);
if(isUrl) {
const domain = selected
.replace(/^https?:\/\//i, '')
.replace(/^www\./i, '')
.split(/[/?#]/)[0];
const newColumn = (view)=>{
const { from, to } = view.state.selection.main;
view.dispatch({ changes: { from, to, insert: '\n\\column\n\n' } });
return true;
};
const name = domain.split('.')[0];
const insert = `[${name}](${selected})`;
return {
changes : { from, to, insert },
range : EditorSelection.range(from, from + insert.length),
};
}
const newPage = (view)=>{
const { from, to } = view.state.selection.main;
view.dispatch({ changes: { from, to, insert: '\n\\page\n\n' } });
return true;
};
// If the selection is just text, use it as alt text and set url as placeholder
const insert = `[${selected || 'alt text'}](url)`;
return {
changes : { from, to, insert },
range : EditorSelection.range(from, from + insert.length),
};
});
const addPrefixAtLineStart = (prefix)=>(view)=>forEachSelection(view, (state, from, to, selected)=>{
const startLine = state.doc.lineAt(from);
const endLine = state.doc.lineAt(to);
const selectedLines = [];
for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber++) { selectedLines.push(state.doc.line(lineNumber)); }
const allLinesHavePrefix = selectedLines.every((line)=>line.text.startsWith(prefix));
const changes = selectedLines.map((line)=>{
const shouldRemovePrefix = allLinesHavePrefix && line.text.startsWith(prefix);
return {
from : line.from,
to : shouldRemovePrefix
? line.from + prefix.length
: line.from,
insert : shouldRemovePrefix ? '' : prefix,
};
});
const changeSet = ChangeSet.of(changes, state.doc.length);
return {
changes,
range : EditorSelection.range(
changeSet.mapPos(from, -1),
changeSet.mapPos(to, 1)
),
};
});
const newBreak = (type)=>(view)=>forEachSelection(view, (state, from, to, selected)=>{
const insert = `\n\\${type}\n\n`;
return {
changes : { from, to, insert },
range : EditorSelection.cursor(from + insert.length)
};
});
export const generalKeymap = Prec.high(keymap.of([
{ key: 'Tab', run: insertTab }, //runs indentMore if multiple lines selected in a single selection
{ key: 'Tab', run: insertTab },
{ key: 'Shift-Tab', run: indentLess },
{ key: 'Mod-z', run: undo }, //it may be unnecessary
{ key: 'Mod-Shift-z', run: redo },
@@ -246,7 +230,6 @@ export const cssKeymap = Prec.highest(keymap.of([
]));
export const markdownKeymap = Prec.highest(keymap.of([
{ key: 'Mod-b', run: wrapSelection('**', '**') }, // makeBold
{ key: 'Mod-i', run: wrapSelection('*', '*') }, // makeItalic
{ key: 'Mod-u', run: wrapSelection('<u>', '</u>') }, // makeUnderline
@@ -255,18 +238,18 @@ export const markdownKeymap = Prec.highest(keymap.of([
{ key: 'Mod-.', run: makeNbsp },
{ key: 'Shift-Mod-.', run: makeSpace },
{ key: 'Shift-Mod-,', run: removeSpace },
{ key: 'Mod-m', run: makeSpan },
{ key: 'Shift-Mod-m', run: makeDiv },
{ key: 'Mod-/', run: makeComment },
{ key: 'Mod-k', run: makeLink },
{ key: 'Mod-l', run: makeList('UL') },
{ key: 'Shift-Mod-l', run: makeList('OL') },
{ key: 'Shift-Mod-1', run: makeHeader(1) },
{ key: 'Shift-Mod-2', run: makeHeader(2) },
{ key: 'Shift-Mod-3', run: makeHeader(3) },
{ key: 'Shift-Mod-4', run: makeHeader(4) },
{ key: 'Shift-Mod-5', run: makeHeader(5) },
{ key: 'Shift-Mod-6', run: makeHeader(6) },
{ key: 'Mod-Enter', run: newPage },
{ key: 'Shift-Mod-Enter', run: newColumn },
{ key: 'Mod-m', run: wrapSelection('{{', '}}') },
{ key: 'Shift-Mod-m', run: wrapSelection('{{\n', '\n}}') },
{ key: 'Mod-/', run: wrapSelection('<!-- ', ' -->') },
{ key: 'Mod-Shift-k', run: makeLink },
{ key: 'Mod-Shift-u', run: addPrefixAtLineStart('- ') },
{ key: 'Mod-Shift-o', run: addPrefixAtLineStart('1. ') },
{ key: 'Shift-Mod-1', run: addPrefixAtLineStart('# ') },
{ key: 'Shift-Mod-2', run: addPrefixAtLineStart('## ') },
{ key: 'Shift-Mod-3', run: addPrefixAtLineStart('### ') },
{ key: 'Shift-Mod-4', run: addPrefixAtLineStart('#### ') },
{ key: 'Shift-Mod-5', run: addPrefixAtLineStart('##### ') },
{ key: 'Shift-Mod-6', run: addPrefixAtLineStart('###### ') },
{ key: 'Mod-Enter', run: newBreak('page') },
{ key: 'Shift-Mod-Enter', run: newBreak('column') },
]));
+14 -4
View File
@@ -21,6 +21,15 @@
let prefix = '';
// Default page title
let result ='The Homebrewery';
// Add brew title, if it exists
if (title) {
result = `${title} - ${result}`;
}
// Add prefix for Stage/PR Deployment/Local install
if (url && url?.includes('://homebrewery-stage.')) {
prefix = `Stage `;
} else if (url?.includes('://homebrewery-pr-')) {
@@ -30,12 +39,13 @@
prefix = 'Local ';
}
if (title) {
document.title = `${prefix} - ${title} - The Homebrewery`;
} else if (prefix) {
document.title = `${prefix} - The Homebrewery`;
if (prefix) {
result = `${prefix} - ${result}`;
}
// Set page title
document.title = result;
if (window.location.pathname.startsWith('/admin')) {
import('/client/admin/main.jsx');
} else {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "homebrewery",
"version": "3.23.0",
"version": "3.24.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "homebrewery",
"version": "3.23.0",
"version": "3.24.1",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "homebrewery",
"description": "Create authentic looking D&D homebrews using only markdown",
"version": "3.23.0",
"version": "3.24.1",
"type": "module",
"engines": {
"npm": ">=10.8 <12",
+1 -1
View File
@@ -204,7 +204,7 @@ export default async function createApp(vite) {
});
Stream.on('sendUpdate', (event, data)=>{
console.log('Event:', event, '\nData:', data);
if(isLocalEnvironment) { console.log('Event:', event, '\nData:', data); }
res.write(`data: ${JSON.stringify({ ...data, eventType: event })}\n\n`);
});
});