implement macros
This commit is contained in:
1 parent
a306820a78
commit
241f67fb4d
27 files changed
+597
-257
No files matched your search
Vendored
+1
@@ -56,4 +56,5 @@ exports.defaultTags = new tags.TagCollection([
|
||||
new tags.OrderedList(),
|
||||
new tags.UnorderedList(),
|
||||
new tags.Table(),
|
||||
new tags.Macro(),
|
||||
]);
|
||||
Vendored
+2
-1
@@ -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);
|
||||
/**
|
||||
|
||||
Vendored
+5
@@ -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.
|
||||
|
||||
Vendored
+14
-2
@@ -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 {};
|
||||
Vendored
+31
-9
@@ -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;
|
||||
}
|
||||
|
||||
Vendored
+11
@@ -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;
|
||||
}
|
||||
Vendored
+25
@@ -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 ``;
|
||||
}
|
||||
}
|
||||
exports.Image = Image;
|
||||
Vendored
+1
@@ -9,3 +9,4 @@ export * from "./blockquote";
|
||||
export * from "./inline";
|
||||
export * from "./list";
|
||||
export * from "./table";
|
||||
export * from "./macro";
|
||||
Vendored
+1
@@ -25,3 +25,4 @@ __exportStar(require("./blockquote"), exports);
|
||||
__exportStar(require("./inline"), exports);
|
||||
__exportStar(require("./list"), exports);
|
||||
__exportStar(require("./table"), exports);
|
||||
__exportStar(require("./macro"), exports);
|
||||
Vendored
+22
@@ -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;
|
||||
}
|
||||
Vendored
+116
@@ -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;
|
||||
Vendored
-23
@@ -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
|
||||
|
||||
Vendored
+9
-94
@@ -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
|
||||
|
||||
Vendored
+1
-2
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user