add dist/ to src
This commit is contained in:
Vendored
+378
@@ -0,0 +1,378 @@
|
||||
/*
|
||||
* hopdown.ts
|
||||
* - Configurable markdown <=> HTML converter.
|
||||
*/
|
||||
import { defaultTags } from "./defaults";
|
||||
import { Tokenizer } from "./tokenizer";
|
||||
export function escapeHtml(source) {
|
||||
return source
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
/**
|
||||
* A configurable markdown <=> HTML converter.
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* const hopdown = new HopDown();
|
||||
*
|
||||
* const html = hopdown.toHTML('**bold**');
|
||||
* html === '<p><strong>bold</strong></p>';
|
||||
*
|
||||
* const markdown = hopdown.toMarkdown(html);
|
||||
* markdown === '**bold**';
|
||||
*
|
||||
* const node = hopdown.toEditorNode('**bold**');
|
||||
* (node as HTMLElement).outerHTML === (
|
||||
* "<div>" +
|
||||
* "<span class='delim strong'>**</span>" +
|
||||
* "bold" +
|
||||
* "<span class='delim strong'>" +
|
||||
* "</div>"
|
||||
* );
|
||||
*/
|
||||
export class HopDown {
|
||||
tags;
|
||||
referenceLinks = new Map();
|
||||
tokenizer;
|
||||
constructor(tags) {
|
||||
this.tags = tags || defaultTags;
|
||||
this.tokenizer = new Tokenizer(this.tags);
|
||||
}
|
||||
/**
|
||||
* Convert a markdown string to tokens.
|
||||
*/
|
||||
tokenize(source) {
|
||||
let text = source;
|
||||
// Resolve reference links before tokenizing
|
||||
text = this.resolveReferenceLinks(text);
|
||||
// Normalize _ emphasis to *
|
||||
text = this.normalizeUnderscores(text);
|
||||
const tokens = this.tokenizer.tokenize(text);
|
||||
return tokens;
|
||||
}
|
||||
/**
|
||||
* Convert an HTML string or a DocumentFragment to markdown.
|
||||
*/
|
||||
toMarkdown = (html) => {
|
||||
var md = '';
|
||||
if (typeof (html) == 'string') {
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = html;
|
||||
md = this.nodeToMarkdown(container);
|
||||
}
|
||||
else {
|
||||
md = this.nodeToMarkdown(html);
|
||||
}
|
||||
return md.replace(/\n{3,}/g, '\n\n').trim();
|
||||
};
|
||||
/**
|
||||
* Convert a markdown string to an HTML string.
|
||||
*/
|
||||
toHTML = (markdown) => {
|
||||
const output = [];
|
||||
const preprocessed = this.preprocessMarkdown(markdown);
|
||||
if (!preprocessed.lines) {
|
||||
return '';
|
||||
}
|
||||
const blockTags = this.tags.ordered.filter(t => {
|
||||
return t.isBlock && t.constructor.name !== 'Paragraph';
|
||||
});
|
||||
const pTag = this.tags.getByElementName('p');
|
||||
if (pTag === undefined) {
|
||||
throw new Error("Cannot parse without a paragraph tag!");
|
||||
}
|
||||
let lineIndex = 0;
|
||||
while (lineIndex < preprocessed.lines.length) {
|
||||
let context;
|
||||
let matched = false;
|
||||
let token;
|
||||
for (const tag of blockTags) {
|
||||
context = {
|
||||
lines: preprocessed.lines,
|
||||
index: lineIndex,
|
||||
text: '',
|
||||
offset: 0,
|
||||
};
|
||||
token = tag.match(context, this.tags);
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
output.push(tag.toHTML(token, this.inlineToHTML));
|
||||
lineIndex += token.consumed;
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
if (matched) {
|
||||
continue;
|
||||
}
|
||||
/* always check the paragraph tag last */
|
||||
token = pTag.match(context, this.tags);
|
||||
if (token) {
|
||||
output.push(pTag.toHTML(token, this.inlineToHTML));
|
||||
lineIndex += token.consumed;
|
||||
continue;
|
||||
}
|
||||
lineIndex++;
|
||||
}
|
||||
return output.join('\n');
|
||||
};
|
||||
/**
|
||||
* Convert a markdown string to a DocumentFragment consisting of wysiwyg editor nodes.
|
||||
*/
|
||||
toEditorNode = (markdown) => {
|
||||
const tokens = this.tokenize(markdown);
|
||||
return this.tokensToEditorNodes(tokens);
|
||||
};
|
||||
/**
|
||||
* Recursive entrypoint for converting markdown to html.
|
||||
*/
|
||||
inlineToHTML = (markdown) => {
|
||||
const tokens = this.tokenize(markdown);
|
||||
return this.tokensToHTML(tokens);
|
||||
};
|
||||
/**
|
||||
* Recursive entrypoint for converting markdown to editor nodes.
|
||||
*/
|
||||
inlineToEditorNodes = (markdown) => {
|
||||
const tokens = this.tokenize(markdown);
|
||||
return this.tokensToEditorNodes(tokens);
|
||||
};
|
||||
/**
|
||||
* Process a markdown string by splitting it into lines, stripping empty lines, and capturing reference links.
|
||||
*/
|
||||
preprocessMarkdown(markdown) {
|
||||
const blankLine = /^\s*$/;
|
||||
const refDefinition = /^\[(?<label>[^\]]+)\]:\s+(?<url>\S+)(?:\s+"(?<title>[^"]*)")?$/;
|
||||
const referenceLinks = new Map;
|
||||
const output = [];
|
||||
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
|
||||
for (const line of lines) {
|
||||
const match = line.match(refDefinition);
|
||||
if (match?.groups) {
|
||||
this.referenceLinks.set(match.groups.label.toLowerCase(), {
|
||||
url: match.groups.url,
|
||||
title: match.groups.title,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!blankLine.test(line)) {
|
||||
output.push(line);
|
||||
}
|
||||
}
|
||||
return {
|
||||
lines: output,
|
||||
referenceLinks: referenceLinks
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Convert an HTML node to a markdown string. Used by toMarkdown().
|
||||
*/
|
||||
nodeToMarkdown = (node) => {
|
||||
// DocumentFragment
|
||||
if (node.nodeType == 11) {
|
||||
return Array.from(node.childNodes).map(child => {
|
||||
return this.nodeToMarkdown(child);
|
||||
}).join('');
|
||||
}
|
||||
// text
|
||||
if (node.nodeType == 3) {
|
||||
return node.textContent || '';
|
||||
// not a block-level element
|
||||
}
|
||||
else if (node.nodeType !== 1) {
|
||||
return '';
|
||||
}
|
||||
const tag = this.tags.getByElementName(node.nodeName);
|
||||
if (!tag) {
|
||||
return Array.from(node.childNodes)
|
||||
.map(child => this.nodeToMarkdown(child))
|
||||
.join('');
|
||||
}
|
||||
const ret = tag.toMarkdown(node, this.nodeToMarkdown);
|
||||
return ret;
|
||||
};
|
||||
/**
|
||||
* Replace [text][ref] and [text][] with [text](url) using the
|
||||
* reference definitions collected during block parsing.
|
||||
*/
|
||||
resolveReferenceLinks(text) {
|
||||
if (this.referenceLinks.size === 0) {
|
||||
return text;
|
||||
}
|
||||
const refLink = /\[(?<text>[^\[\]]+)\]\[(?<label>[^\]]*)\]/g;
|
||||
return text.replace(refLink, (...args) => {
|
||||
const groups = args[args.length - 1];
|
||||
const label = (groups.label || groups.text).toLowerCase();
|
||||
const ref = this.referenceLinks.get(label);
|
||||
if (!ref) {
|
||||
return args[0];
|
||||
}
|
||||
const titlePart = ref.title ? ` "${ref.title}"` : '';
|
||||
return `[${groups.text}](${ref.url}${titlePart})`;
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Normalize flanking underscore runs to asterisks so the tokenizer
|
||||
* only needs to handle * delimiters for emphasis.
|
||||
*/
|
||||
normalizeUnderscores(text) {
|
||||
// Protect backslash-escaped underscores from normalization
|
||||
const escapePlaceholder = '\x00U\x00';
|
||||
const safeText = text.replace(/\\_/g, escapePlaceholder);
|
||||
const punctuation = `[\\s.,;:!?'"()\\[\\]{}<>\\-/\\\\~#@&^|]`;
|
||||
const openRun = new RegExp(`(?<=^|${punctuation})` + // preceded by start, space, or punctuation
|
||||
`(_+)` + // one or more underscores
|
||||
`(?=\\S)`, // followed by non-whitespace
|
||||
'g');
|
||||
const closeRun = new RegExp(`(?<=\\S)` + // preceded by non-whitespace
|
||||
`(_+)` + // one or more underscores
|
||||
`(?=$|${punctuation})`, // followed by end, space, or punctuation
|
||||
'g');
|
||||
const toAsterisks = (_, run) => '*'.repeat(run.length);
|
||||
const normalized = safeText
|
||||
.replace(openRun, toAsterisks)
|
||||
.replace(closeRun, toAsterisks);
|
||||
return normalized.replace(/\x00U\x00/g, '\\_');
|
||||
}
|
||||
/**
|
||||
* Convert a token stream to editor nodes.
|
||||
*/
|
||||
tokensToEditorNodes(tokens) {
|
||||
// First pass: match open/close pairs using a stack
|
||||
const paired = this.pairDelimiters(tokens);
|
||||
// Second pass: build HTML from paired tokens
|
||||
const fragment = document.createDocumentFragment();
|
||||
var grouping = [];
|
||||
const groupAsNode = () => {
|
||||
const div = document.createElement('div');
|
||||
for (const node of grouping) {
|
||||
div.appendChild(node);
|
||||
}
|
||||
return div;
|
||||
};
|
||||
for (const token of paired) {
|
||||
if (token.role == 'html') {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = token.value;
|
||||
for (const node of Array.from(div.childNodes)) {
|
||||
grouping.push(node);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (token.tag) {
|
||||
const thisNode = token.tag.toEditorNode(token, this.inlineToEditorNodes);
|
||||
if (!thisNode) {
|
||||
continue;
|
||||
}
|
||||
if (token.role == 'open') {
|
||||
if (grouping.length) {
|
||||
fragment.append(groupAsNode());
|
||||
}
|
||||
grouping = [thisNode];
|
||||
continue;
|
||||
}
|
||||
if (token.role == 'close') {
|
||||
grouping.push(thisNode);
|
||||
fragment.append(groupAsNode());
|
||||
grouping = [];
|
||||
continue;
|
||||
}
|
||||
if (!grouping.length) {
|
||||
fragment.appendChild(thisNode.cloneNode(true));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const text = document.createTextNode(token.value);
|
||||
grouping.push(text);
|
||||
continue;
|
||||
}
|
||||
if (grouping.length) {
|
||||
fragment.appendChild(groupAsNode());
|
||||
}
|
||||
return fragment;
|
||||
}
|
||||
/**
|
||||
* Convert a token stream to HTML. Matches open/close delimiter
|
||||
* pairs and wraps their content in the appropriate HTML tags.
|
||||
* Unmatched delimiters are emitted as literal text.
|
||||
*/
|
||||
tokensToHTML(tokens) {
|
||||
// First pass: match open/close pairs using a stack
|
||||
const paired = this.pairDelimiters(tokens);
|
||||
// Second pass: build HTML from paired tokens
|
||||
let html = '';
|
||||
for (const token of paired) {
|
||||
console.log(token);
|
||||
if (token.role == 'text') {
|
||||
html += escapeHtml(token.value);
|
||||
continue;
|
||||
}
|
||||
if (token.role == 'html') {
|
||||
html += token.value;
|
||||
continue;
|
||||
}
|
||||
if (token.role == 'link') {
|
||||
html += this.tags.getByElementName('A').toHTML(token, this.inlineToHTML) || token.value;
|
||||
continue;
|
||||
}
|
||||
if (token.tag) {
|
||||
html += token.tag.toHTML(token, this.inlineToHTML);
|
||||
continue;
|
||||
}
|
||||
html += escapeHtml(token.value);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
/**
|
||||
* Matchn/close delimiter pairs in a token stream. Unmatched
|
||||
* openers/closers are converted to text tokens so they render
|
||||
* as literal characters.
|
||||
*/
|
||||
pairDelimiters(tokens) {
|
||||
const openStack = [];
|
||||
const result = [...tokens];
|
||||
// Track which delimiter types are currently open to prevent
|
||||
// forbidden nesting (e.g. <del> inside <del>, <em> inside <em>)
|
||||
const openDelimiters = new Set();
|
||||
for (let index = 0; index < result.length; index++) {
|
||||
const token = result[index];
|
||||
if (token.role === 'block') {
|
||||
token.role = openDelimiters.has(token.sequence) ? 'close' : 'open';
|
||||
}
|
||||
if (token.role === 'open') {
|
||||
// Don't open a delimiter that's already open (prevents nesting)
|
||||
if (openDelimiters.has(token.sequence)) {
|
||||
result[index] = {
|
||||
role: 'text',
|
||||
value: token.value,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
openStack.push(index);
|
||||
openDelimiters.add(token.sequence);
|
||||
}
|
||||
else if (token.role === 'close') {
|
||||
let matched = false;
|
||||
for (let stackIndex = openStack.length - 1; stackIndex >= 0; stackIndex--) {
|
||||
const openerIndex = openStack[stackIndex];
|
||||
if (result[openerIndex].sequence === token.sequence) {
|
||||
openStack.splice(stackIndex, 1);
|
||||
openDelimiters.delete(token.sequence);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
result[index] = {
|
||||
role: 'text',
|
||||
value: token.value,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user