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.OrderedList(),
new tags.UnorderedList(), new tags.UnorderedList(),
new tags.Table(), 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; export declare function escapeHtml(source: string): string;
/** /**
* A configurable markdown <=> HTML converter. * A configurable markdown <=> HTML converter.
@@ -24,6 +24,7 @@ export declare function escapeHtml(source: string): string;
*/ */
export declare class HopDown { export declare class HopDown {
tags: TagCollectionType; tags: TagCollectionType;
macros: TagType | null;
private tokenizer; private tokenizer;
constructor(tags?: TagCollectionType | null); constructor(tags?: TagCollectionType | null);
/** /**
+5
View File
@@ -39,10 +39,15 @@ function escapeHtml(source) {
*/ */
class HopDown { class HopDown {
tags; tags;
macros = null;
tokenizer; tokenizer;
constructor(tags) { constructor(tags) {
this.tags = tags || defaults_1.defaultTags; this.tags = tags || defaults_1.defaultTags;
this.tokenizer = new tokenizer_1.Tokenizer(this.tags); 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. * Convert a markdown string to tokens.
+14 -2
View File
@@ -1,4 +1,15 @@
import type { Token, TokenRole, TagType, TagCollectionType } from '../types'; 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 { export declare class Tag implements TagType {
element: string[]; element: string[];
opener: string[]; opener: string[];
@@ -9,6 +20,7 @@ export declare class Tag implements TagType {
isBlock: boolean; isBlock: boolean;
showDelimitersInEditorNodes: boolean; showDelimitersInEditorNodes: boolean;
template: string; template: string;
constructor(settings?: TagSettings);
childNodesToMarkdown(element: HTMLElement, callback: Function): string; childNodesToMarkdown(element: HTMLElement, callback: Function): string;
tokenize(role: TokenRole, sequence: string, source: string): Token | null; tokenize(role: TokenRole, sequence: string, source: string): Token | null;
toHTML(token: Token, callback: Function): string; toHTML(token: Token, callback: Function): string;
@@ -17,9 +29,9 @@ export declare class Tag implements TagType {
toEditorNode(token: Token, callback: Function): Node; toEditorNode(token: Token, callback: Function): Node;
} }
export declare class TagCollection implements TagCollectionType { export declare class TagCollection implements TagCollectionType {
tags: Record<string, TagType>; tags: TagType[];
ordered: TagType[];
map: Map<string, TagType[]>; map: Map<string, TagType[]>;
constructor(tags: TagType[]); constructor(tags: TagType[]);
getByElementName(element: string): TagType | undefined; getByElementName(element: string): TagType | undefined;
} }
export {};
+31 -9
View File
@@ -11,6 +11,28 @@ class Tag {
isBlock = false; isBlock = false;
showDelimitersInEditorNodes = true; showDelimitersInEditorNodes = true;
template = ''; 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) { childNodesToMarkdown(element, callback) {
return Array.from(element.childNodes) return Array.from(element.childNodes)
.map(child => callback(child)) .map(child => callback(child))
@@ -19,13 +41,13 @@ class Tag {
tokenize(role, sequence, source) { tokenize(role, sequence, source) {
if (this.pattern) { if (this.pattern) {
const match = this.pattern.exec(source); const match = this.pattern.exec(source);
if (!match?.groups) { if (!match) {
return null; return null;
} }
return { return {
'role': 'block', 'role': 'block',
'sequence': sequence, 'sequence': sequence,
'value': match.groups.captured, 'value': match.groups?.captured || match[0],
'groups': match.groups, 'groups': match.groups,
'tag': this 'tag': this
}; };
@@ -96,23 +118,23 @@ class Tag {
} }
exports.Tag = Tag; exports.Tag = Tag;
class TagCollection { class TagCollection {
tags = {}; tags;
ordered;
map; map;
constructor(tags) { constructor(tags) {
var seen = [];
for (const tag of tags) { 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?`); 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 p1 = a.precedence - (a.isBlock ? 100 : 0);
const p2 = b.precedence - (b.isBlock ? 100 : 0); const p2 = b.precedence - (b.isBlock ? 100 : 0);
return p1 < p2 ? -1 : 1; return p1 < p2 ? -1 : 1;
}); });
this.map = new Map(); this.map = new Map();
for (const tag of this.ordered) { for (const tag of this.tags) {
for (const key of tag.opener) { for (const key of tag.opener) {
if (!this.map.has(key)) { if (!this.map.has(key)) {
this.map.set(key, []); this.map.set(key, []);
@@ -123,7 +145,7 @@ class TagCollection {
} }
getByElementName(element) { getByElementName(element) {
const el = element.toUpperCase(); const el = element.toUpperCase();
return this.ordered.filter(t => { return this.tags.filter(t => {
return [...t.element, ...t.aliases ?? []].includes(el); return [...t.element, ...t.aliases ?? []].includes(el);
})[0] || undefined; })[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 "./inline";
export * from "./list"; export * from "./list";
export * from "./table"; export * from "./table";
export * from "./macro";
+1
View File
@@ -25,3 +25,4 @@ __exportStar(require("./blockquote"), exports);
__exportStar(require("./inline"), exports); __exportStar(require("./inline"), exports);
__exportStar(require("./list"), exports); __exportStar(require("./list"), exports);
__exportStar(require("./table"), 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. * Returns the resolved character and the length consumed, or null.
*/ */
private resolveEntity; 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 * Try to match a delimiter at the given position. For runs of the
* same character (e.g. *** = 3 asterisks), the run is split into * same character (e.g. *** = 3 asterisks), the run is split into
+9 -94
View File
@@ -80,16 +80,6 @@ class Tokenizer {
continue; 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; // HTML entity resolution: &name; or &#digits; or &#xhex;
if (source[position] === '&') { if (source[position] === '&') {
const resolved = this.resolveEntity(remaining); const resolved = this.resolveEntity(remaining);
@@ -106,13 +96,16 @@ class Tokenizer {
position += match.value?.length; position += match.value?.length;
continue; continue;
} }
// check for autolinks
const anchor = this.tags.getByElementName('A'); const anchor = this.tags.getByElementName('A');
match = anchor.tokenize('block', remaining[0], remaining); if (anchor) {
if (match && match.value?.length) { match = anchor.tokenize('block', remaining[0], remaining);
flushText(); if (match && match.value?.length) {
tokens.push(match); flushText();
position += match.value?.length; tokens.push(match);
continue; position += match.value?.length;
continue;
}
} }
// Plain character // Plain character
textBuffer += source[position]; textBuffer += source[position];
@@ -155,84 +148,6 @@ class Tokenizer {
} }
return null; 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 * Try to match a delimiter at the given position. For runs of the
* same character (e.g. *** = 3 asterisks), the run is split into * 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; toEditorNode: (token: Token, callback: Function) => Node | null;
} }
export interface TagCollectionType { export interface TagCollectionType {
tags: Record<string, TagType>; tags: TagType[];
ordered: TagType[];
map: Map<string, TagType[]>; map: Map<string, TagType[]>;
getByElementName(element: string): TagType | undefined; getByElementName(element: string): TagType | undefined;
} }
+17
View File
@@ -8,6 +8,9 @@
"name": "hopdown", "name": "hopdown",
"version": "1.0.0", "version": "1.0.0",
"license": "CC0-1.0", "license": "CC0-1.0",
"dependencies": {
"dedent": "^1.7.2"
},
"devDependencies": { "devDependencies": {
"@vitest/browser-playwright": "^4.1.10", "@vitest/browser-playwright": "^4.1.10",
"happy-dom": "^20.11.2", "happy-dom": "^20.11.2",
@@ -910,6 +913,20 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+3 -3
View File
@@ -2,14 +2,11 @@
"name": "hopdown", "name": "hopdown",
"version": "1.0.0", "version": "1.0.0",
"description": "Configurable HTML <=> Markdown converter for WYSIWYG editors.", "description": "Configurable HTML <=> Markdown converter for WYSIWYG editors.",
"main": "dist/index.js", "main": "dist/index.js",
"types": "src/ts/index.d.ts", "types": "src/ts/index.d.ts",
"files": [ "files": [
"dist/*" "dist/*"
], ],
"scripts": { "scripts": {
"tsc": "tsc -p tsconfig.json", "tsc": "tsc -p tsconfig.json",
"build": "npm run tsc", "build": "npm run tsc",
@@ -24,5 +21,8 @@
"playwright": "^1.62.1", "playwright": "^1.62.1",
"typescript": "^7.0.2", "typescript": "^7.0.2",
"vitest": "^4.1.10" "vitest": "^4.1.10"
},
"dependencies": {
"dedent": "^1.7.2"
} }
} }
+1
View File
@@ -22,4 +22,5 @@ export const defaultTags = new tags.TagCollection([
new tags.OrderedList(), new tags.OrderedList(),
new tags.UnorderedList(), new tags.UnorderedList(),
new tags.Table(), new tags.Table(),
new tags.Macro(),
]); ]);
+7 -1
View File
@@ -40,11 +40,17 @@ export function escapeHtml(source: string): string {
*/ */
export class HopDown { export class HopDown {
public tags: TagCollectionType; public tags: TagCollectionType;
public macros: TagType | null = null;
private tokenizer: Tokenizer; private tokenizer: Tokenizer;
constructor(tags?: TagCollectionType | null) { constructor(tags?: TagCollectionType | null) {
this.tags = tags || defaultTags; this.tags = tags || defaultTags;
this.tokenizer = new Tokenizer(this.tags); 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 => { public inlineToHTML = (markdown: string): string => {
const tokens = this.tokenize(markdown); const tokens = this.tokenize(markdown);
return this.tokensToHTML(tokens) return this.tokensToHTML(tokens);
} }
/** /**
+38 -12
View File
@@ -1,5 +1,17 @@
import type { Token, TokenRole, TagType, TagCollectionType } from '../types'; 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 { export class Tag implements TagType {
element: string[] = ['']; element: string[] = [''];
opener: string[] = ['']; opener: string[] = [''];
@@ -12,6 +24,22 @@ export class Tag implements TagType {
template = ''; 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 { childNodesToMarkdown(element: HTMLElement, callback: Function): string {
return Array.from(element.childNodes) return Array.from(element.childNodes)
.map(child => callback(child)) .map(child => callback(child))
@@ -20,14 +48,14 @@ export class Tag implements TagType {
tokenize(role: TokenRole, sequence: string, source: string): Token | null { tokenize(role: TokenRole, sequence: string, source: string): Token | null {
if (this.pattern) { if (this.pattern) {
const match = this.pattern!.exec(source); const match = this.pattern.exec(source);
if (!match?.groups) { if (!match) {
return null; return null;
} }
return { return {
'role': 'block', 'role': 'block',
'sequence': sequence, 'sequence': sequence,
'value': match.groups.captured, 'value': match.groups?.captured || match[0],
'groups': match.groups, 'groups': match.groups,
'tag': this 'tag': this
} }
@@ -101,25 +129,25 @@ export class Tag implements TagType {
export class TagCollection implements TagCollectionType { export class TagCollection implements TagCollectionType {
tags: Record<string, TagType> = {}; tags: TagType[];
ordered: TagType[];
map: Map<string, TagType[]>; map: Map<string, TagType[]>;
constructor(tags: TagType[]) { constructor(tags: TagType[]) {
var seen: string[] = [];
for (const tag of tags) { 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?`); 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 p1 = a.precedence - (a.isBlock ? 100 : 0);
const p2 = b.precedence - (b.isBlock ? 100 : 0); const p2 = b.precedence - (b.isBlock ? 100 : 0);
return p1 < p2 ? -1 : 1; return p1 < p2 ? -1 : 1;
}); });
this.map = new Map(); this.map = new Map();
for (const tag of this.ordered) { for (const tag of this.tags) {
for (const key of tag.opener) { for (const key of tag.opener) {
if (!this.map.has(key)) { if (!this.map.has(key)) {
this.map.set(key, []); this.map.set(key, []);
@@ -131,10 +159,8 @@ export class TagCollection implements TagCollectionType {
getByElementName(element: string): TagType | undefined { getByElementName(element: string): TagType | undefined {
const el = element.toUpperCase(); const el = element.toUpperCase();
return this.ordered.filter(t => { return this.tags.filter(t => {
return [...t.element, ...t.aliases ?? []].includes(el); return [...t.element, ...t.aliases ?? []].includes(el);
})[0] || undefined; })[0] || undefined;
} }
} }
+25
View File
@@ -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 `![${text}](${src} "${title}")`;
}
}
+1
View File
@@ -9,3 +9,4 @@ export * from "./blockquote";
export * from "./inline"; export * from "./inline";
export * from "./list"; export * from "./list";
export * from "./table"; export * from "./table";
export * from "./macro";
+143
View File
@@ -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
}
}
}
}
+9 -106
View File
@@ -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; // HTML entity resolution: &name; or &#digits; or &#xhex;
if (source[position] === '&') { if (source[position] === '&') {
const resolved = this.resolveEntity(remaining); const resolved = this.resolveEntity(remaining);
@@ -127,13 +116,16 @@ export class Tokenizer {
continue; continue;
} }
// check for autolinks
const anchor = this.tags.getByElementName('A'); const anchor = this.tags.getByElementName('A');
match = anchor!.tokenize('block', remaining[0], remaining); if (anchor) {
if (match && match.value?.length) { match = anchor!.tokenize('block', remaining[0], remaining);
flushText(); if (match && match.value?.length) {
tokens.push(match); flushText();
position += match.value?.length; tokens.push(match);
continue; position += match.value?.length;
continue;
}
} }
// Plain character // Plain character
@@ -184,95 +176,6 @@ export class Tokenizer {
return null; 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 * Try to match a delimiter at the given position. For runs of the
* same character (e.g. *** = 3 asterisks), the run is split into * same character (e.g. *** = 3 asterisks), the run is split into
+1 -2
View File
@@ -28,8 +28,7 @@ export interface TagType {
export interface TagCollectionType { export interface TagCollectionType {
tags: Record<string, TagType>; tags: TagType[];
ordered: TagType[];
map: Map<string, TagType[]>; map: Map<string, TagType[]>;
getByElementName(element: string): TagType | undefined; getByElementName(element: string): TagType | undefined;
} }
+110
View File
@@ -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);
});
});
+2 -2
View File
@@ -1,10 +1,10 @@
import { expect, test, suite, describe } from 'vitest' 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(); const hopdown = new HopDown();
suite('Markdown to HTML Round-Trips', () => { suite('Markdown to HTML Round-Trips', () => {
function assert(name: string, input: string, output: string, expected: string | undefined = undefined) { function assert(name: string, input: string, output: string, expected: string | undefined = undefined) {
+1
View File
@@ -8,6 +8,7 @@ export default defineConfig({
test: { test: {
include: [ include: [
'test/**/*.{test,spec}.ts', 'test/**/*.{test,spec}.ts',
'test/**/*.{test,spec}.js'
], ],
name: 'Release Acceptance', name: 'Release Acceptance',
environment: 'happy-dom', environment: 'happy-dom',