implement macros

This commit is contained in:
evilchili committed 2026-10-17 13:28:47 -07:00
1 parent a306820a78
commit 241f67fb4d
27 files changed
+597 -257

No files matched your search

+1
View File
@@ -56,4 +56,5 @@ exports.defaultTags = new tags.TagCollection([
new tags.OrderedList(),
new tags.UnorderedList(),
new tags.Table(),
new tags.Macro(),
]);
+2 -1
View File
@@ -1,4 +1,4 @@
import type { Token, TagCollectionType } from './types';
import type { Token, TagType, TagCollectionType } from './types';
export declare function escapeHtml(source: string): string;
/**
* A configurable markdown <=> HTML converter.
@@ -24,6 +24,7 @@ export declare function escapeHtml(source: string): string;
*/
export declare class HopDown {
tags: TagCollectionType;
macros: TagType | null;
private tokenizer;
constructor(tags?: TagCollectionType | null);
/**
+5
View File
@@ -39,10 +39,15 @@ function escapeHtml(source) {
*/
class HopDown {
tags;
macros = null;
tokenizer;
constructor(tags) {
this.tags = tags || defaults_1.defaultTags;
this.tokenizer = new tokenizer_1.Tokenizer(this.tags);
var macroTags = this.tags.map.get(':::');
if (macroTags) {
this.macros = macroTags[0];
}
}
/**
* Convert a markdown string to tokens.
+14 -2
View File
@@ -1,4 +1,15 @@
import type { Token, TokenRole, TagType, TagCollectionType } from '../types';
interface TagSettings {
element?: string[];
opener?: string[];
closer?: string;
aliases?: string[];
pattern?: RegExp | null;
precedence?: number;
isBlock?: boolean;
showDelimitersInEditorNodes?: boolean;
template?: string;
}
export declare class Tag implements TagType {
element: string[];
opener: string[];
@@ -9,6 +20,7 @@ export declare class Tag implements TagType {
isBlock: boolean;
showDelimitersInEditorNodes: boolean;
template: string;
constructor(settings?: TagSettings);
childNodesToMarkdown(element: HTMLElement, callback: Function): string;
tokenize(role: TokenRole, sequence: string, source: string): Token | null;
toHTML(token: Token, callback: Function): string;
@@ -17,9 +29,9 @@ export declare class Tag implements TagType {
toEditorNode(token: Token, callback: Function): Node;
}
export declare class TagCollection implements TagCollectionType {
tags: Record<string, TagType>;
ordered: TagType[];
tags: TagType[];
map: Map<string, TagType[]>;
constructor(tags: TagType[]);
getByElementName(element: string): TagType | undefined;
}
export {};
+31 -9
View File
@@ -11,6 +11,28 @@ class Tag {
isBlock = false;
showDelimitersInEditorNodes = true;
template = '';
constructor(settings) {
if (settings?.element)
this.element = settings.element;
if (settings?.opener)
this.opener = settings.opener;
if (settings?.closer)
this.closer = settings.closer;
if (settings?.aliases)
this.aliases = settings.aliases;
if (settings?.pattern)
this.pattern = settings.pattern;
if (settings?.precedence)
this.precedence = settings.precedence;
if (settings?.isBlock !== undefined)
this.isBlock = settings.isBlock;
if (settings?.showDelimitersInEditorNodes !== undefined) {
this.showDelimitersInEditorNodes = settings.showDelimitersInEditorNodes;
}
if (settings?.template) {
this.template = settings.template;
}
}
childNodesToMarkdown(element, callback) {
return Array.from(element.childNodes)
.map(child => callback(child))
@@ -19,13 +41,13 @@ class Tag {
tokenize(role, sequence, source) {
if (this.pattern) {
const match = this.pattern.exec(source);
if (!match?.groups) {
if (!match) {
return null;
}
return {
'role': 'block',
'sequence': sequence,
'value': match.groups.captured,
'value': match.groups?.captured || match[0],
'groups': match.groups,
'tag': this
};
@@ -96,23 +118,23 @@ class Tag {
}
exports.Tag = Tag;
class TagCollection {
tags = {};
ordered;
tags;
map;
constructor(tags) {
var seen = [];
for (const tag of tags) {
if (tag.constructor.name in this.tags) {
if (tag.constructor.name in seen) {
throw new Error(`Duplicate tag instances found for class ${tag.constructor.name}! Do you need to subclass it?`);
}
this.tags[tag.constructor.name] = tag;
seen.push(tag.constructor.name);
}
this.ordered = tags.sort((a, b) => {
this.tags = tags.sort((a, b) => {
const p1 = a.precedence - (a.isBlock ? 100 : 0);
const p2 = b.precedence - (b.isBlock ? 100 : 0);
return p1 < p2 ? -1 : 1;
});
this.map = new Map();
for (const tag of this.ordered) {
for (const tag of this.tags) {
for (const key of tag.opener) {
if (!this.map.has(key)) {
this.map.set(key, []);
@@ -123,7 +145,7 @@ class TagCollection {
}
getByElementName(element) {
const el = element.toUpperCase();
return this.ordered.filter(t => {
return this.tags.filter(t => {
return [...t.element, ...t.aliases ?? []].includes(el);
})[0] || undefined;
}
+11
View File
@@ -0,0 +1,11 @@
import { Tag } from "./base";
import type { Token } from "../types";
export declare class Image extends Tag {
opener: string[];
element: string[];
pattern: RegExp;
autolink_pattern: RegExp;
showDelimitersInEditorNodes: boolean;
toHTML(token: Token, callback: Function): string;
toMarkdown(element: HTMLElement, callback: Function): string;
}
+25
View File
@@ -0,0 +1,25 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Image = void 0;
const base_1 = require("./base");
const hopdown_1 = require("../hopdown");
class Image extends base_1.Tag {
opener = ['!'];
element = ['IMG'];
pattern = /(?<captured>\!\[(?<text>[^\[\]]+)\]\((?<url>[^\s)]+)(?:\s+"(?<title>[^"]*)")?\))/;
autolink_pattern = /^(?<captured>(?<url>[A-Za-z]+:\/\/[^\s>]+))/;
showDelimitersInEditorNodes = false;
toHTML(token, callback) {
const title = (0, hopdown_1.escapeHtml)(token.groups.title || '');
const url = (0, hopdown_1.escapeHtml)(token.groups.url || '');
const text = (0, hopdown_1.escapeHtml)(token.groups.text || '') || url;
return `<IMG SRC="${url}" ALT="${text}" TITLE="${title}">`;
}
toMarkdown(element, callback) {
const src = element.getAttribute('src') || '';
const title = element.getAttribute('title') || '';
const text = element.getAttribute('alt') || title || src;
return `![${text}](${src} "${title}")`;
}
}
exports.Image = Image;
+1
View File
@@ -9,3 +9,4 @@ export * from "./blockquote";
export * from "./inline";
export * from "./list";
export * from "./table";
export * from "./macro";
+1
View File
@@ -25,3 +25,4 @@ __exportStar(require("./blockquote"), exports);
__exportStar(require("./inline"), exports);
__exportStar(require("./list"), exports);
__exportStar(require("./table"), exports);
__exportStar(require("./macro"), exports);
+22
View File
@@ -0,0 +1,22 @@
import { Tag } from "./base";
import type { Token, TokenRole } from '../types';
export declare class Macro extends Tag {
element: string[];
opener: string[];
closer: string;
isBlock: boolean;
showDelimitersInEditorNodes: boolean;
name: string;
template: string;
pattern: RegExp;
paramLinePattern: RegExp;
private registry;
private parseBlock;
private renderer;
register: Function;
deregister: Function;
toHTML(token: Token, callback: Function): string;
toMarkdown(element: HTMLElement, callback: Function): string;
toEditorNode(token: Token, callback: Function): Node;
tokenize(role: TokenRole, sequence: string, source: string): Token | null;
}
+116
View File
@@ -0,0 +1,116 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Macro = void 0;
const base_1 = require("./base");
class Macro extends base_1.Tag {
element = ['RIBBIT-MACRO'];
opener = [':::'];
closer = '';
isBlock = true;
showDelimitersInEditorNodes = false;
name = '';
template = '';
pattern = new RegExp('^'
+ '(?<fence>' + this.opener[0] + ')'
+ '\\s*(?<name>\\w+)\\s*\\n'
+ '(?<source>(.*\n)+)'
+ '\\k<fence>', 'm');
paramLinePattern = /^:\s*(?<param>\w+)(?:\s*\=\s*(?<value>.*))/;
registry = {};
parseBlock(token) {
var lines = token.groups.source.split("\n");
var params = {};
for (const line of lines) {
const match = this.paramLinePattern.exec(line);
if (!match) {
break;
}
params[match.groups.param] = match.groups.value || null;
}
lines = lines.slice(Object.keys(params).length);
return {
params: params,
data: lines.join("\n")
};
}
renderer = (config) => {
return config.data.trim();
};
register = (definition) => {
this.registry[definition.name] = definition;
};
deregister = (name) => {
return delete this.registry[name];
};
toHTML(token, callback) {
const handler = this.registry[token.meta.name];
if (handler.toHTML) {
return handler.toHTML(token, callback);
}
const parsed = this.parseBlock(token);
var attrs = ` name="${handler.name}"`;
for (const [key, val] of Object.entries(parsed.params)) {
attrs += ` data-${key}="${val}"`;
}
attrs += ` data-macro-source="${btoa(JSON.stringify(parsed))}"`;
var rendered = (handler.renderer || this.renderer)(parsed);
var el = this.element[0].toLowerCase();
return `<${el}${attrs}>\n${rendered}\n</${el}>`;
}
toMarkdown(element, callback) {
const name = element.getAttribute('name');
if (!name) {
throw new Error("Cannot convert macro HTML to markdown without a name!");
}
const handler = this.registry[name];
if (handler.toMarkdown) {
return handler.toMarkdown(element, callback);
}
const source = JSON.parse(atob(element.dataset.macroSource || ''));
const params = Object.entries(source.params).map(([k, v]) => `: ${k}=${v}`).join("\n");
return `::: ${name}\n${params}\n\n${source.data.trim()}\n:::\n`;
}
toEditorNode(token, callback) {
if (['open', 'close', 'block'].indexOf(token.role) == -1) {
return document.createTextNode(token.value);
}
const handler = this.registry[token.meta.name];
if (handler.toEditorNode) {
return handler.toEditorNode(token, callback);
}
const [opener, closer] = this.delimNodes();
const div = document.createElement('div');
div.innerHTML = this.toHTML(token, callback);
const el = div.firstChild;
if (token.tag.showDelimitersInEditorNodes) {
if (this.opener || this.closer) {
el.insertBefore(opener, el.firstChild);
el.appendChild(closer);
}
}
return el;
}
;
tokenize(role, sequence, source) {
const match = this.pattern.exec(source);
if (!match) {
return null;
}
const name = match.groups?.name || '';
if (!(name in this.registry)) {
console.error(`Unregistered macro name: '${name}'`);
return null;
}
return {
'role': 'block',
'sequence': sequence,
'value': match.groups?.captured || match[0],
'groups': match.groups,
'tag': this,
'meta': {
'name': name
}
};
}
}
exports.Macro = Macro;
-23
View File
@@ -30,29 +30,6 @@ export declare class Tokenizer {
* Returns the resolved character and the length consumed, or null.
*/
private resolveEntity;
/**
* Match a code span starting at the given position.
* Handles single backtick delimiters only (not multi-backtick).
*/
private matchCodeSpan;
/**
* Match a markdown link [text](url) or [text](url "title")
* starting at the given position. Disallows [ in link text
* to prevent nested link ambiguity.
*/
private matchLink;
/**
* Match an angle-bracket autolink <url> at the start of the string.
*/
private matchAutolink;
/**
* Match a bare URL (https://...) at the start of the string.
*/
private matchBareUrl;
/**
* Match an HTML tag at the start of the string.
*/
private matchHtmlTag;
/**
* Try to match a delimiter at the given position. For runs of the
* same character (e.g. *** = 3 asterisks), the run is split into
+9 -94
View File
@@ -80,16 +80,6 @@ class Tokenizer {
continue;
}
}
// Hard line break: two+ trailing spaces before newline
if (source[position] === ' ') {
const spaceMatch = remaining.match(/^(?<spaces> {2,})\n/);
if (spaceMatch?.groups) {
flushText();
tokens.push({ role: 'block', value: '<br>' });
position += spaceMatch[0].length;
continue;
}
}
// HTML entity resolution: &name; or &#digits; or &#xhex;
if (source[position] === '&') {
const resolved = this.resolveEntity(remaining);
@@ -106,13 +96,16 @@ class Tokenizer {
position += match.value?.length;
continue;
}
// check for autolinks
const anchor = this.tags.getByElementName('A');
match = anchor.tokenize('block', remaining[0], remaining);
if (match && match.value?.length) {
flushText();
tokens.push(match);
position += match.value?.length;
continue;
if (anchor) {
match = anchor.tokenize('block', remaining[0], remaining);
if (match && match.value?.length) {
flushText();
tokens.push(match);
position += match.value?.length;
continue;
}
}
// Plain character
textBuffer += source[position];
@@ -155,84 +148,6 @@ class Tokenizer {
}
return null;
}
/**
* Match a code span starting at the given position.
* Handles single backtick delimiters only (not multi-backtick).
*/
matchCodeSpan(source, position) {
if (source[position] !== '`') {
return null;
}
const closeIndex = source.indexOf('`', position + 1);
if (closeIndex === -1) {
return null;
}
const content = source.slice(position + 1, closeIndex);
return {
content,
raw: source.slice(position, closeIndex + 1),
};
}
/**
* Match a markdown link [text](url) or [text](url "title")
* starting at the given position. Disallows [ in link text
* to prevent nested link ambiguity.
*/
matchLink(source, position) {
const linkPattern = /^\[(?<text>[^\[\]]+)\]\((?<href>[^\s)]+)(?:\s+"(?<title>[^"]*)")?\)/;
const match = source.slice(position).match(linkPattern);
if (!match?.groups) {
return null;
}
return {
text: match.groups.text,
href: match.groups.href,
title: match.groups.title,
length: match[0].length,
};
}
/**
* Match an angle-bracket autolink <url> at the start of the string.
*/
matchAutolink(text) {
const pattern = /^<(?<url>https?:\/\/[^\s>]+)>/;
const match = text.match(pattern);
if (!match?.groups) {
return null;
}
return {
url: match.groups.url,
length: match[0].length,
};
}
/**
* Match a bare URL (https://...) at the start of the string.
*/
matchBareUrl(text) {
const pattern = /^[\w]+?:\/\/[^\s<>\x00]+/;
const match = text.match(pattern);
if (!match) {
return null;
}
return {
url: match[0],
length: match[0].length,
};
}
/**
* Match an HTML tag at the start of the string.
*/
matchHtmlTag(text) {
const pattern = /^<\/?[a-zA-Z][a-zA-Z0-9]*(?:\s+[^>]*)?\s*\/?>/;
const match = text.match(pattern);
if (!match) {
return null;
}
return {
tag: match[0],
length: match[0].length,
};
}
/**
* Try to match a delimiter at the given position. For runs of the
* same character (e.g. *** = 3 asterisks), the run is split into
+1 -2
View File
@@ -25,8 +25,7 @@ export interface TagType {
toEditorNode: (token: Token, callback: Function) => Node | null;
}
export interface TagCollectionType {
tags: Record<string, TagType>;
ordered: TagType[];
tags: TagType[];
map: Map<string, TagType[]>;
getByElementName(element: string): TagType | undefined;
}