implement macros
This commit is contained in:
1 parent
a306820a78
commit
241f67fb4d
27 files changed
+585
-245
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
+3
-88
@@ -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,7 +96,9 @@ class Tokenizer {
|
||||
position += match.value?.length;
|
||||
continue;
|
||||
}
|
||||
// check for autolinks
|
||||
const anchor = this.tags.getByElementName('A');
|
||||
if (anchor) {
|
||||
match = anchor.tokenize('block', remaining[0], remaining);
|
||||
if (match && match.value?.length) {
|
||||
flushText();
|
||||
@@ -114,6 +106,7 @@ class Tokenizer {
|
||||
position += match.value?.length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Plain character
|
||||
textBuffer += source[position];
|
||||
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;
|
||||
}
|
||||
Generated
+17
@@ -8,6 +8,9 @@
|
||||
"name": "hopdown",
|
||||
"version": "1.0.0",
|
||||
"license": "CC0-1.0",
|
||||
"dependencies": {
|
||||
"dedent": "^1.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/browser-playwright": "^4.1.10",
|
||||
"happy-dom": "^20.11.2",
|
||||
@@ -910,6 +913,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dedent": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
||||
"integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"babel-plugin-macros": "^3.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"babel-plugin-macros": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
|
||||
+3
-3
@@ -2,14 +2,11 @@
|
||||
"name": "hopdown",
|
||||
"version": "1.0.0",
|
||||
"description": "Configurable HTML <=> Markdown converter for WYSIWYG editors.",
|
||||
|
||||
"main": "dist/index.js",
|
||||
"types": "src/ts/index.d.ts",
|
||||
|
||||
"files": [
|
||||
"dist/*"
|
||||
],
|
||||
|
||||
"scripts": {
|
||||
"tsc": "tsc -p tsconfig.json",
|
||||
"build": "npm run tsc",
|
||||
@@ -24,5 +21,8 @@
|
||||
"playwright": "^1.62.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"dedent": "^1.7.2"
|
||||
}
|
||||
}
|
||||
@@ -22,4 +22,5 @@ export const defaultTags = new tags.TagCollection([
|
||||
new tags.OrderedList(),
|
||||
new tags.UnorderedList(),
|
||||
new tags.Table(),
|
||||
new tags.Macro(),
|
||||
]);
|
||||
+7
-1
@@ -40,11 +40,17 @@ export function escapeHtml(source: string): string {
|
||||
*/
|
||||
export class HopDown {
|
||||
public tags: TagCollectionType;
|
||||
public macros: TagType | null = null;
|
||||
private tokenizer: Tokenizer;
|
||||
|
||||
constructor(tags?: TagCollectionType | null) {
|
||||
this.tags = tags || defaultTags;
|
||||
this.tokenizer = new Tokenizer(this.tags);
|
||||
|
||||
var macroTags = this.tags.map.get(':::');
|
||||
if (macroTags) {
|
||||
this.macros = macroTags[0];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,7 +93,7 @@ export class HopDown {
|
||||
*/
|
||||
public inlineToHTML = (markdown: string): string => {
|
||||
const tokens = this.tokenize(markdown);
|
||||
return this.tokensToHTML(tokens)
|
||||
return this.tokensToHTML(tokens);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+38
-12
@@ -1,5 +1,17 @@
|
||||
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 class Tag implements TagType {
|
||||
element: string[] = [''];
|
||||
opener: string[] = [''];
|
||||
@@ -12,6 +24,22 @@ export class Tag implements TagType {
|
||||
|
||||
template = '';
|
||||
|
||||
constructor(settings?: TagSettings) {
|
||||
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: HTMLElement, callback: Function): string {
|
||||
return Array.from(element.childNodes)
|
||||
.map(child => callback(child))
|
||||
@@ -20,14 +48,14 @@ export class Tag implements TagType {
|
||||
|
||||
tokenize(role: TokenRole, sequence: string, source: string): Token | null {
|
||||
if (this.pattern) {
|
||||
const match = this.pattern!.exec(source);
|
||||
if (!match?.groups) {
|
||||
const match = this.pattern.exec(source);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
'role': 'block',
|
||||
'sequence': sequence,
|
||||
'value': match.groups.captured,
|
||||
'value': match.groups?.captured || match[0],
|
||||
'groups': match.groups,
|
||||
'tag': this
|
||||
}
|
||||
@@ -101,25 +129,25 @@ export class Tag implements TagType {
|
||||
|
||||
export class TagCollection implements TagCollectionType {
|
||||
|
||||
tags: Record<string, TagType> = {};
|
||||
ordered: TagType[];
|
||||
tags: TagType[];
|
||||
map: Map<string, TagType[]>;
|
||||
|
||||
constructor(tags: TagType[]) {
|
||||
var seen: string[] = [];
|
||||
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, []);
|
||||
@@ -131,10 +159,8 @@ export class TagCollection implements TagCollectionType {
|
||||
|
||||
getByElementName(element: string): TagType | undefined {
|
||||
const el = element.toUpperCase();
|
||||
return this.ordered.filter(t => {
|
||||
return this.tags.filter(t => {
|
||||
return [...t.element, ...t.aliases ?? []].includes(el);
|
||||
})[0] || undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Tag } from "./base";
|
||||
import { escapeHtml } from "../hopdown";
|
||||
import type { Token, TokenRole } from "../types";
|
||||
|
||||
export class Image extends Tag {
|
||||
opener = ['!'];
|
||||
element = ['IMG'];
|
||||
pattern = /(?<captured>\!\[(?<text>[^\[\]]+)\]\((?<url>[^\s)]+)(?:\s+"(?<title>[^"]*)")?\))/;
|
||||
autolink_pattern = /^(?<captured>(?<url>[A-Za-z]+:\/\/[^\s>]+))/;
|
||||
showDelimitersInEditorNodes = false;
|
||||
|
||||
toHTML(token: Token, callback: Function) {
|
||||
const title = escapeHtml(token.groups!.title || '');
|
||||
const url = escapeHtml(token.groups!.url || '');
|
||||
const text = escapeHtml(token.groups!.text || '') || url;
|
||||
return `<IMG SRC="${url}" ALT="${text}" TITLE="${title}">`;
|
||||
}
|
||||
|
||||
toMarkdown(element: HTMLElement, callback: Function) {
|
||||
const src = element.getAttribute('src') || '';
|
||||
const title = element.getAttribute('title') || '';
|
||||
const text = element.getAttribute('alt') || title || src;
|
||||
return ``;
|
||||
}
|
||||
}
|
||||
@@ -9,3 +9,4 @@ export * from "./blockquote";
|
||||
export * from "./inline";
|
||||
export * from "./list";
|
||||
export * from "./table";
|
||||
export * from "./macro";
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Tag } from "./base";
|
||||
import type { Token, TokenRole } from '../types';
|
||||
|
||||
interface MacroDefinition {
|
||||
name: string;
|
||||
template: string;
|
||||
toMarkdown?: (element: HTMLElement, callback: Function) => string;
|
||||
toHTML?: (token: Token, callback: Function) => string;
|
||||
toEditorNode?: (token: Token, callback: Function) => Node;
|
||||
renderer?: (config: {params: Record<string, any>, data: string }) => string;
|
||||
}
|
||||
|
||||
|
||||
export class Macro extends Tag {
|
||||
element: string[] = ['RIBBIT-MACRO'];
|
||||
opener: string[] = [':::'];
|
||||
closer: string = '';
|
||||
isBlock: boolean = true;
|
||||
showDelimitersInEditorNodes = false;
|
||||
name: string = '';
|
||||
template: string = '';
|
||||
|
||||
pattern: RegExp = new RegExp(
|
||||
'^'
|
||||
+ '(?<fence>' + this.opener[0] + ')'
|
||||
+ '\\s*(?<name>\\w+)\\s*\\n'
|
||||
+ '(?<source>(.*\n)+)'
|
||||
+ '\\k<fence>',
|
||||
'm'
|
||||
);
|
||||
|
||||
paramLinePattern: RegExp = /^:\s*(?<param>\w+)(?:\s*\=\s*(?<value>.*))/;
|
||||
|
||||
private registry: Record<string, MacroDefinition> = {};
|
||||
|
||||
private parseBlock(token: Token): Record<string, any> {
|
||||
var lines = token.groups!.source.split("\n");
|
||||
var params: Record<string, any> = {};
|
||||
for (const line of lines) {
|
||||
const match = this.paramLinePattern.exec(line);
|
||||
if (!match) {
|
||||
break;
|
||||
}
|
||||
params[match.groups!.param as string] = match.groups!.value || null;
|
||||
}
|
||||
lines = lines.slice(Object.keys(params).length);
|
||||
return {
|
||||
params: params,
|
||||
data: lines.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
private renderer: Function = (config: {params: Record<string, any>, data: string }) => {
|
||||
return config.data.trim();
|
||||
};
|
||||
|
||||
public register: Function = (definition: MacroDefinition): void => {
|
||||
this.registry[definition.name] = definition;
|
||||
}
|
||||
|
||||
public deregister: Function = (name: string): boolean => {
|
||||
return delete this.registry[name];
|
||||
}
|
||||
|
||||
override toHTML(token: Token, callback: Function): string {
|
||||
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}>`;
|
||||
}
|
||||
|
||||
override toMarkdown(element: HTMLElement, callback: Function): string {
|
||||
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`;
|
||||
}
|
||||
|
||||
override toEditorNode(token: Token, callback: Function): Node {
|
||||
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: HTMLElement = 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 as Node;
|
||||
};
|
||||
|
||||
override tokenize(role: TokenRole, sequence: string, source: string): Token | null {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-100
@@ -98,17 +98,6 @@ export class Tokenizer {
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
@@ -127,7 +116,9 @@ export class Tokenizer {
|
||||
continue;
|
||||
}
|
||||
|
||||
// check for autolinks
|
||||
const anchor = this.tags.getByElementName('A');
|
||||
if (anchor) {
|
||||
match = anchor!.tokenize('block', remaining[0], remaining);
|
||||
if (match && match.value?.length) {
|
||||
flushText();
|
||||
@@ -135,6 +126,7 @@ export class Tokenizer {
|
||||
position += match.value?.length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Plain character
|
||||
textBuffer += source[position];
|
||||
@@ -184,95 +176,6 @@ export class Tokenizer {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a code span starting at the given position.
|
||||
* Handles single backtick delimiters only (not multi-backtick).
|
||||
*/
|
||||
private matchCodeSpan(
|
||||
source: string,
|
||||
position: number,
|
||||
): { content: string; raw: string } | null {
|
||||
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.
|
||||
*/
|
||||
private matchLink(
|
||||
source: string,
|
||||
position: number,
|
||||
): { text: string; href: string; title?: string; length: number } | null {
|
||||
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.
|
||||
*/
|
||||
private matchAutolink(text: string): { url: string; length: number } | null {
|
||||
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.
|
||||
*/
|
||||
private matchBareUrl(text: string): { url: string; length: number } | null {
|
||||
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.
|
||||
*/
|
||||
private matchHtmlTag(text: string): { tag: string; length: number } | null {
|
||||
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
@@ -28,8 +28,7 @@ export interface TagType {
|
||||
|
||||
|
||||
export interface TagCollectionType {
|
||||
tags: Record<string, TagType>;
|
||||
ordered: TagType[];
|
||||
tags: TagType[];
|
||||
map: Map<string, TagType[]>;
|
||||
getByElementName(element: string): TagType | undefined;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { expect, test, suite, describe } from 'vitest'
|
||||
import { HopDown } from '../dist';
|
||||
import { Macro, TagCollection } from "../dist/tags";
|
||||
import dedent from "dedent";
|
||||
|
||||
var hopdown = new HopDown();
|
||||
|
||||
var jsonMacro = {
|
||||
name: 'json',
|
||||
template: dedent(`
|
||||
::: json
|
||||
: indent=2
|
||||
|
||||
{
|
||||
"Foo": "BAR",
|
||||
"one": 1
|
||||
}
|
||||
:::
|
||||
`),
|
||||
renderer: (macro) => {
|
||||
var src = macro.data.trim();
|
||||
return JSON.stringify(JSON.parse(src), null, parseInt(macro.params.indent));
|
||||
}
|
||||
};
|
||||
|
||||
var errorMacro = {
|
||||
name: 'error',
|
||||
template: dedent(`
|
||||
::: error
|
||||
: message=I AM ERROR
|
||||
|
||||
|
||||
:::
|
||||
`),
|
||||
renderer: (macro) => {
|
||||
return (
|
||||
'<h1>An Error Occurred</h1>'
|
||||
+ `<strong>${macro.params.message}</strong>`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
hopdown.macros.register(jsonMacro);
|
||||
hopdown.macros.register(errorMacro);
|
||||
|
||||
|
||||
suite('Markdown <=> HTML Roundtrips', () => {
|
||||
|
||||
function assert(name, input, output, expected = undefined) {
|
||||
return test(name, () => {
|
||||
const html = hopdown.toHTML(input);
|
||||
const markdown = hopdown.toMarkdown(html);
|
||||
expect(html).toBe(output);
|
||||
expect(markdown).toBe(expected || input);
|
||||
});
|
||||
}
|
||||
|
||||
describe('JSON', () => {
|
||||
var html = (
|
||||
'<p><ribbit-macro name="json" data-indent="2" ' +
|
||||
'data-macro-source="eyJwYXJhbXMiOnsiaW5kZW50IjoiMiJ9LCJkYXRhIjoiXG57XG4gICAgXCJGb29cIjogXCJCQVJcIixcbiAgICBcIm9uZVwiOiAxXG59XG4ifQ==">'
|
||||
+ '\n'
|
||||
+ '{\n'
|
||||
+ ' "Foo": "BAR",\n'
|
||||
+ ' "one": 1\n'
|
||||
+ '}\n'
|
||||
+ '</ribbit-macro></p>'
|
||||
);
|
||||
assert('macro', jsonMacro.template, html);
|
||||
});
|
||||
|
||||
describe('Messages', () => {
|
||||
var html = (
|
||||
'<p><ribbit-macro name="error" data-message="I AM ERROR" '
|
||||
+ 'data-macro-source="eyJwYXJhbXMiOnsibWVzc2FnZSI6IkkgQU0gRVJST1IifSwiZGF0YSI6IlxuXG4ifQ==">'
|
||||
+ '\n'
|
||||
+ "<h1>An Error Occurred</h1><strong>I AM ERROR</strong>\n"
|
||||
+ "</ribbit-macro></p>"
|
||||
);
|
||||
assert('Error', errorMacro.template, html);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
suite('Markdown <=> Editor Node Roundtrips', () => {
|
||||
|
||||
function assertBlock(name, input, output) {
|
||||
return test(name, () => {
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(hopdown.toEditorNode(input));
|
||||
expect(div.innerHTML).toStrictEqual(output);
|
||||
});
|
||||
}
|
||||
|
||||
describe('json', () => {
|
||||
var html = (
|
||||
'<ribbit-macro name="json" data-indent="2" '
|
||||
+ 'data-macro-source="eyJwYXJhbXMiOnsiaW5kZW50IjoiMiJ9LCJkYXRhIjoiXG57XG4gICAgXCJGb29cIjogXCJCQVJcIixcbiAgICBcIm9uZVwiOiAxXG59XG4ifQ==">'
|
||||
+ '\n'
|
||||
+ '{\n'
|
||||
+ ' "Foo": "BAR",\n'
|
||||
+ ' "one": 1\n'
|
||||
+ '}\n'
|
||||
+ '</ribbit-macro>'
|
||||
);
|
||||
assertBlock('Macro', jsonMacro.template, html);
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import { expect, test, suite, describe } from 'vitest'
|
||||
|
||||
import { HopDown } from '../dist';
|
||||
import { HopDown, defaultTags } from '../dist';
|
||||
import { TagCollection } from "../dist/tags";
|
||||
|
||||
const hopdown = new HopDown();
|
||||
|
||||
|
||||
suite('Markdown to HTML Round-Trips', () => {
|
||||
|
||||
function assert(name: string, input: string, output: string, expected: string | undefined = undefined) {
|
||||
|
||||
@@ -8,6 +8,7 @@ export default defineConfig({
|
||||
test: {
|
||||
include: [
|
||||
'test/**/*.{test,spec}.ts',
|
||||
'test/**/*.{test,spec}.js'
|
||||
],
|
||||
name: 'Release Acceptance',
|
||||
environment: 'happy-dom',
|
||||
|
||||
Reference in new issue
Block a user