mirror of
https://github.com/naturalcrit/homebrewery.git
synced 2026-06-22 15:48:38 +00:00
472 lines
12 KiB
JavaScript
472 lines
12 KiB
JavaScript
/* 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 = {
|
|
pageLine : 'pageLine', // .cm-pageLine
|
|
snippetLine : 'snippetLine', // .cm-snippetLine
|
|
columnSplit : 'columnSplit', // .cm-columnSplit
|
|
block : 'block', // .cm-block
|
|
inlineBlock : 'inline-block', // .cm-inline-block
|
|
injection : 'injection', // .cm-injection
|
|
emoji : 'emoji', // .cm-emoji
|
|
superscript : 'superscript', // .cm-superscript
|
|
subscript : 'subscript', // .cm-subscript
|
|
definitionList : 'definitionList', // .cm-definitionList
|
|
definitionTerm : 'definitionTerm', // .cm-definitionTerm
|
|
definitionDesc : 'definitionDesc', // .cm-definitionDesc
|
|
definitionColon : 'definitionColon', // .cm-definitionColon
|
|
strikethrough : 'strikethrough', // .cm-strikethrough
|
|
|
|
//CSS
|
|
|
|
variable : 'variable',
|
|
};
|
|
|
|
function tokenizeCustomMarkdown(text) {
|
|
const tokens = [];
|
|
const lines = text.split('\n');
|
|
|
|
//tokens without a `from` or `to` are interpreted by the custom plugin as line tokens
|
|
|
|
lines.forEach((lineText, lineNumber)=>{
|
|
// --- Page / snippet lines ---
|
|
if(/^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m.test(lineText)) tokens.push({ line: lineNumber, type: customTags.pageLine });
|
|
if(/^\\snippet\ .*$/.test(lineText)) tokens.push({ line: lineNumber, type: customTags.snippetLine });
|
|
if(/^\\column(?:break)?$/.test(lineText)) tokens.push({ line: lineNumber, type: customTags.columnSplit });
|
|
|
|
// --- Emoji ---
|
|
if(/:.\w+?:/.test(lineText)) {
|
|
const emojiRegex = /(:\w+?:)/g;
|
|
let match;
|
|
while ((match = emojiRegex.exec(lineText)) !== null) {
|
|
tokens.push({
|
|
line : lineNumber,
|
|
type : customTags.emoji,
|
|
from : match.index,
|
|
to : match.index + match[0].length,
|
|
});
|
|
}
|
|
}
|
|
|
|
// --- Superscript / Subscript ---
|
|
if(/\^/.test(lineText)) {
|
|
let startIndex = lineText.indexOf('^');
|
|
const superRegex = /\^(?!\s)(?=([^\n\^]*[^\s\^]))\1\^/gy;
|
|
const subRegex = /\^\^(?!\s)(?=([^\n\^]*[^\s\^]))\1\^\^/gy;
|
|
|
|
while (startIndex >= 0) {
|
|
superRegex.lastIndex = subRegex.lastIndex = startIndex;
|
|
|
|
let match = subRegex.exec(lineText);
|
|
let type = customTags.subscript;
|
|
|
|
if(!match) {
|
|
match = superRegex.exec(lineText);
|
|
type = customTags.superscript;
|
|
}
|
|
|
|
if(match) {
|
|
tokens.push({
|
|
line : lineNumber,
|
|
type,
|
|
from : match.index,
|
|
to : match.index + match[0].length,
|
|
});
|
|
}
|
|
|
|
startIndex = lineText.indexOf(
|
|
'^',
|
|
Math.max(startIndex + 1, superRegex.lastIndex || 0, subRegex.lastIndex || 0),
|
|
);
|
|
}
|
|
}
|
|
|
|
// --- Strikethrough ---
|
|
if(/\~/.test(lineText)) {
|
|
const strikethroughRegex = /~(?!\s)(.+?)(?<!\s)~/g;
|
|
|
|
const match = strikethroughRegex.exec(lineText);
|
|
const type = customTags.strikethrough;
|
|
|
|
if(match) {
|
|
tokens.push({
|
|
line : lineNumber,
|
|
type,
|
|
from : match.index,
|
|
to : match.index + match[0].length,
|
|
});
|
|
}
|
|
}
|
|
|
|
// --- single line def list ---
|
|
const singleLineRegex = /^(?=.*[^:])(.+?)(\s*)(::)([^\n]*)$/dmy;
|
|
const match = singleLineRegex.exec(lineText);
|
|
|
|
if(match) {
|
|
const [full, term, spaces, colons, desc] = match;
|
|
|
|
let offset = 0;
|
|
|
|
tokens.push({
|
|
line : lineNumber,
|
|
type : customTags.definitionList,
|
|
});
|
|
|
|
// Term
|
|
tokens.push({
|
|
line : lineNumber,
|
|
type : customTags.definitionTerm,
|
|
from : offset,
|
|
to : offset + term.length,
|
|
});
|
|
offset += term.length;
|
|
|
|
// Spaces before ::
|
|
if(spaces) {
|
|
offset += spaces.length;
|
|
}
|
|
|
|
// :: colons
|
|
tokens.push({
|
|
line : lineNumber,
|
|
type : customTags.definitionColon,
|
|
from : offset,
|
|
to : offset + colons.length,
|
|
});
|
|
offset += colons.length;
|
|
|
|
// Definition
|
|
tokens.push({
|
|
line : lineNumber,
|
|
type : customTags.definitionDesc,
|
|
from : offset,
|
|
to : offset + desc.length,
|
|
});
|
|
}
|
|
|
|
// --- multiline def list ---
|
|
if(!/^::/.test(lines[lineNumber]) && lineNumber + 1 < lines.length && /^::/.test(lines[lineNumber + 1])) {
|
|
const startLine = lineNumber;
|
|
const defs = [];
|
|
|
|
// collect all following :: definitions
|
|
for (let i = lineNumber + 1; i < lines.length; i++) {
|
|
const nextLine = lines[i];
|
|
const onlyColonsMatch = /^:*$/.test(nextLine);
|
|
const defMatch = /^(::)(.+)$/.exec(nextLine);
|
|
if(!onlyColonsMatch && defMatch) {
|
|
defs.push({ colons: defMatch[1], desc: defMatch[2], line: i });
|
|
} else break;
|
|
}
|
|
|
|
if(defs.length > 0 && lineText.trim().length > 0) {
|
|
tokens.push({
|
|
line : startLine,
|
|
type : customTags.definitionList,
|
|
});
|
|
|
|
// term
|
|
tokens.push({
|
|
line : startLine,
|
|
type : customTags.definitionTerm,
|
|
from : 0,
|
|
to : lineText.length,
|
|
});
|
|
|
|
// definitions
|
|
defs.forEach((d)=>{
|
|
tokens.push({
|
|
line : d.line,
|
|
type : customTags.definitionList,
|
|
});
|
|
|
|
tokens.push({
|
|
line : d.line,
|
|
type : customTags.definitionColon,
|
|
from : 0,
|
|
to : d.colons.length,
|
|
});
|
|
tokens.push({
|
|
line : d.line,
|
|
type : customTags.definitionDesc,
|
|
from : d.colons.length,
|
|
to : d.colons.length + d.desc?.length,
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
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,
|
|
from : match.indices[1][0],
|
|
to : match.indices[1][1],
|
|
type : customTags.injection,
|
|
});
|
|
}
|
|
}
|
|
if(lineText.includes('{{') && lineText.includes('}}')) {
|
|
// Inline blocks: single-line {{…}}
|
|
const spanRegex = /{{(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\1 *|}}/g;
|
|
let match;
|
|
let blockCount = 0;
|
|
while ((match = spanRegex.exec(lineText)) !== null) {
|
|
if(match[0].startsWith('{{')) {
|
|
blockCount += 1;
|
|
} else {
|
|
blockCount -= 1;
|
|
}
|
|
if(blockCount < 0) {
|
|
blockCount = 0;
|
|
continue;
|
|
}
|
|
tokens.push({
|
|
line : lineNumber,
|
|
from : match.index,
|
|
to : match.index + match[0].length,
|
|
type : customTags.inlineBlock,
|
|
});
|
|
}
|
|
} else if(lineText.trimLeft().startsWith('{{') || lineText.trimLeft().startsWith('}}')) {
|
|
// Highlight block divs {{\n Content \n}}
|
|
let endCh = lineText.length + 1;
|
|
|
|
const match = lineText.match(
|
|
/^ *{{(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\1 *$|^ *}}$/,
|
|
);
|
|
if(match) endCh = match.index + match[0].length;
|
|
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;
|
|
}
|
|
|
|
function tokenizeCustomCSS(text) {
|
|
const tokens = [];
|
|
const lines = text.split('\n');
|
|
|
|
lines.forEach((lineText, lineNumber)=>{
|
|
|
|
if(/--[a-zA-Z0-9-_]+/gm.test(lineText)) {
|
|
const varRegex =/--[a-zA-Z0-9-_]+/gm;
|
|
let match;
|
|
while ((match = varRegex.exec(lineText)) !== null) {
|
|
tokens.push({
|
|
line : lineNumber,
|
|
from : match.index +1,
|
|
to : match.index + match.length[1] +1,
|
|
type : customTags.varProperty,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
return tokens;
|
|
}
|
|
|
|
//assign classes to tags provided by lezer, not unlike the function above
|
|
export const customHighlightStyle = HighlightStyle.define([
|
|
{ tag: tags.heading, class: 'cm-header' },
|
|
{ tag: tags.heading1, class: 'cm-header cm-header-1' },
|
|
{ tag: tags.heading2, class: 'cm-header cm-header-2' },
|
|
{ tag: tags.heading3, class: 'cm-header cm-header-3' },
|
|
{ tag: tags.heading4, class: 'cm-header cm-header-4' },
|
|
{ tag: tags.heading5, class: 'cm-header cm-header-5' },
|
|
{ tag: tags.heading6, class: 'cm-header cm-header-6' },
|
|
{ tag: tags.link, class: 'cm-link' },
|
|
{ tag: tags.string, class: 'cm-string' },
|
|
{ tag: tags.url, class: 'cm-string cm-url' },
|
|
{ tag: tags.list, class: 'cm-list' },
|
|
{ tag: tags.strong, class: 'cm-strong' },
|
|
{ tag: tags.emphasis, class: 'cm-em' },
|
|
{ tag: tags.quote, class: 'cm-quote' },
|
|
{ tag: tags.comment, class: 'cm-comment' },
|
|
{ tag: tags.monospace, class: 'cm-comment' },
|
|
|
|
//css tags
|
|
|
|
{ tag: tags.tagName, class: 'cm-tag' },
|
|
{ tag: tags.className, class: 'cm-class' },
|
|
{ tag: tags.propertyName, class: 'cm-property' },
|
|
{ tag: tags.attributeValue, class: 'cm-value' },
|
|
{ tag: tags.keyword, class: 'cm-keyword' },
|
|
{ tag: tags.atom, class: 'cm-atom' },
|
|
{ tag: tags.integer, class: 'cm-integer' },
|
|
{ tag: tags.unit, class: 'cm-unit' },
|
|
{ tag: tags.color, class: 'cm-color' },
|
|
{ tag: tags.paren, class: 'cm-paren' },
|
|
{ tag: tags.variableName, class: 'cm-variable' },
|
|
{ tag: tags.invalid, class: 'cm-error' },
|
|
|
|
]);
|
|
|
|
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 }
|
|
);
|
|
}; |