From 241f67fb4d3aaea1184da2ca581587f40043a7651b42bfcc37c9c73b6c203d62 Mon Sep 17 00:00:00 2001 From: evilchili Date: Sat, 17 Oct 2026 13:28:47 -0700 Subject: [PATCH] implement macros --- dist/defaults.js | 1 + dist/hopdown.d.ts | 3 +- dist/hopdown.js | 5 ++ dist/tags/base.d.ts | 16 ++++- dist/tags/base.js | 40 ++++++++--- dist/tags/image.d.ts | 11 +++ dist/tags/image.js | 25 +++++++ dist/tags/index.d.ts | 1 + dist/tags/index.js | 1 + dist/tags/macro.d.ts | 22 ++++++ dist/tags/macro.js | 116 ++++++++++++++++++++++++++++++ dist/tokenizer.d.ts | 23 ------ dist/tokenizer.js | 103 +++----------------------- dist/types.d.ts | 3 +- package-lock.json | 17 +++++ package.json | 6 +- src/ts/defaults.ts | 1 + src/ts/hopdown.ts | 8 ++- src/ts/tags/base.ts | 50 +++++++++---- src/ts/tags/image.ts | 25 +++++++ src/ts/tags/index.ts | 1 + src/ts/tags/macro.ts | 143 +++++++++++++++++++++++++++++++++++++ src/ts/tokenizer.ts | 115 +++-------------------------- src/ts/types.ts | 3 +- test/hopdown.macro.test.js | 110 ++++++++++++++++++++++++++++ test/hopdown.test.ts | 4 +- vitest.config.ts | 1 + 27 files changed, 597 insertions(+), 257 deletions(-) create mode 100644 dist/tags/image.d.ts create mode 100644 dist/tags/image.js create mode 100644 dist/tags/macro.d.ts create mode 100644 dist/tags/macro.js create mode 100644 src/ts/tags/image.ts create mode 100644 src/ts/tags/macro.ts create mode 100644 test/hopdown.macro.test.js diff --git a/dist/defaults.js b/dist/defaults.js index 868330e..69a48e3 100644 --- a/dist/defaults.js +++ b/dist/defaults.js @@ -56,4 +56,5 @@ exports.defaultTags = new tags.TagCollection([ new tags.OrderedList(), new tags.UnorderedList(), new tags.Table(), + new tags.Macro(), ]); diff --git a/dist/hopdown.d.ts b/dist/hopdown.d.ts index 53c0614..f07dc0a 100644 --- a/dist/hopdown.d.ts +++ b/dist/hopdown.d.ts @@ -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); /** diff --git a/dist/hopdown.js b/dist/hopdown.js index e542164..2e0cafd 100644 --- a/dist/hopdown.js +++ b/dist/hopdown.js @@ -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. diff --git a/dist/tags/base.d.ts b/dist/tags/base.d.ts index d7d9b5a..e398ad7 100644 --- a/dist/tags/base.d.ts +++ b/dist/tags/base.d.ts @@ -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; - ordered: TagType[]; + tags: TagType[]; map: Map; constructor(tags: TagType[]); getByElementName(element: string): TagType | undefined; } +export {}; diff --git a/dist/tags/base.js b/dist/tags/base.js index 4864ce7..4db3609 100644 --- a/dist/tags/base.js +++ b/dist/tags/base.js @@ -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; } diff --git a/dist/tags/image.d.ts b/dist/tags/image.d.ts new file mode 100644 index 0000000..214a93b --- /dev/null +++ b/dist/tags/image.d.ts @@ -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; +} diff --git a/dist/tags/image.js b/dist/tags/image.js new file mode 100644 index 0000000..5dfb3b7 --- /dev/null +++ b/dist/tags/image.js @@ -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 = /(?\!\[(?[^\[\]]+)\]\((?[^\s)]+)(?:\s+"(?[^"]*)")?\))/; + 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; diff --git a/dist/tags/index.d.ts b/dist/tags/index.d.ts index 18fcb45..d268981 100644 --- a/dist/tags/index.d.ts +++ b/dist/tags/index.d.ts @@ -9,3 +9,4 @@ export * from "./blockquote"; export * from "./inline"; export * from "./list"; export * from "./table"; +export * from "./macro"; diff --git a/dist/tags/index.js b/dist/tags/index.js index ba08955..cdd5dd0 100644 --- a/dist/tags/index.js +++ b/dist/tags/index.js @@ -25,3 +25,4 @@ __exportStar(require("./blockquote"), exports); __exportStar(require("./inline"), exports); __exportStar(require("./list"), exports); __exportStar(require("./table"), exports); +__exportStar(require("./macro"), exports); diff --git a/dist/tags/macro.d.ts b/dist/tags/macro.d.ts new file mode 100644 index 0000000..cc8ec32 --- /dev/null +++ b/dist/tags/macro.d.ts @@ -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; +} diff --git a/dist/tags/macro.js b/dist/tags/macro.js new file mode 100644 index 0000000..e807d75 --- /dev/null +++ b/dist/tags/macro.js @@ -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; diff --git a/dist/tokenizer.d.ts b/dist/tokenizer.d.ts index 1eff8e2..3ef8992 100644 --- a/dist/tokenizer.d.ts +++ b/dist/tokenizer.d.ts @@ -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 diff --git a/dist/tokenizer.js b/dist/tokenizer.js index 283672c..b8c1f5b 100644 --- a/dist/tokenizer.js +++ b/dist/tokenizer.js @@ -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 diff --git a/dist/types.d.ts b/dist/types.d.ts index 18b5648..28759f7 100644 --- a/dist/types.d.ts +++ b/dist/types.d.ts @@ -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; } diff --git a/package-lock.json b/package-lock.json index a057e0f..fb96ccc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e6277a9..4155134 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/ts/defaults.ts b/src/ts/defaults.ts index c16bb4a..5e81f5d 100644 --- a/src/ts/defaults.ts +++ b/src/ts/defaults.ts @@ -22,4 +22,5 @@ export const defaultTags = new tags.TagCollection([ new tags.OrderedList(), new tags.UnorderedList(), new tags.Table(), + new tags.Macro(), ]); diff --git a/src/ts/hopdown.ts b/src/ts/hopdown.ts index bf6f0b4..1adec1b 100644 --- a/src/ts/hopdown.ts +++ b/src/ts/hopdown.ts @@ -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); } /** diff --git a/src/ts/tags/base.ts b/src/ts/tags/base.ts index 819b723..85a7bb8 100644 --- a/src/ts/tags/base.ts +++ b/src/ts/tags/base.ts @@ -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; } } - - diff --git a/src/ts/tags/image.ts b/src/ts/tags/image.ts new file mode 100644 index 0000000..001a591 --- /dev/null +++ b/src/ts/tags/image.ts @@ -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}")`; + } +} diff --git a/src/ts/tags/index.ts b/src/ts/tags/index.ts index 18fcb45..d268981 100644 --- a/src/ts/tags/index.ts +++ b/src/ts/tags/index.ts @@ -9,3 +9,4 @@ export * from "./blockquote"; export * from "./inline"; export * from "./list"; export * from "./table"; +export * from "./macro"; diff --git a/src/ts/tags/macro.ts b/src/ts/tags/macro.ts new file mode 100644 index 0000000..c2baf79 --- /dev/null +++ b/src/ts/tags/macro.ts @@ -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 + } + } + } +} diff --git a/src/ts/tokenizer.ts b/src/ts/tokenizer.ts index c22d8e3..62546ab 100644 --- a/src/ts/tokenizer.ts +++ b/src/ts/tokenizer.ts @@ -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,13 +116,16 @@ export class Tokenizer { 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 @@ -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 diff --git a/src/ts/types.ts b/src/ts/types.ts index d8b7e0d..34ee972 100644 --- a/src/ts/types.ts +++ b/src/ts/types.ts @@ -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; } diff --git a/test/hopdown.macro.test.js b/test/hopdown.macro.test.js new file mode 100644 index 0000000..e606803 --- /dev/null +++ b/test/hopdown.macro.test.js @@ -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); + + }); + +}); diff --git a/test/hopdown.test.ts b/test/hopdown.test.ts index a690eb7..bb054fa 100644 --- a/test/hopdown.test.ts +++ b/test/hopdown.test.ts @@ -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) { diff --git a/vitest.config.ts b/vitest.config.ts index bbe8404..d75a0dc 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ test: { include: [ 'test/**/*.{test,spec}.ts', + 'test/**/*.{test,spec}.js' ], name: 'Release Acceptance', environment: 'happy-dom',