- Replace complex multi-server setup (live-server + API) with unified Go server
- Serve all sites at /sites/{site_id} endpoints, eliminating port conflicts
- Fix content-type middleware to serve proper MIME types for static files
- Prevent script injection duplication with future-proof CDN-compatible detection
- Remove auto page reload from enhance button to eliminate editing interruptions
- Enable seamless content editing workflow with manual enhancement control
Development now requires only 'just dev' instead of complex demo commands.
All sites immediately available at localhost:8080 without hot reload conflicts.
4124 lines
173 KiB
JavaScript
4124 lines
173 KiB
JavaScript
var Insertr = (function () {
|
||
'use strict';
|
||
|
||
/**
|
||
* InsertrCore - Core functionality for content management
|
||
*/
|
||
class InsertrCore {
|
||
constructor(options = {}) {
|
||
this.options = {
|
||
apiEndpoint: options.apiEndpoint || '/api/content',
|
||
siteId: options.siteId || 'default',
|
||
...options
|
||
};
|
||
}
|
||
|
||
// Find all enhanced elements on the page with container expansion
|
||
findEnhancedElements() {
|
||
const directElements = document.querySelectorAll('.insertr');
|
||
const expandedElements = [];
|
||
|
||
directElements.forEach(element => {
|
||
if (this.isContainer(element) && !element.classList.contains('insertr-group')) {
|
||
// Container element (.insertr) - expand to viable children
|
||
const children = this.findViableChildren(element);
|
||
expandedElements.push(...children);
|
||
} else {
|
||
// Regular element or group (.insertr-group)
|
||
expandedElements.push(element);
|
||
}
|
||
});
|
||
|
||
return expandedElements;
|
||
}
|
||
|
||
// Check if element is a container that should expand to children
|
||
isContainer(element) {
|
||
const containerTags = new Set([
|
||
'div', 'section', 'article', 'header',
|
||
'footer', 'main', 'aside', 'nav'
|
||
]);
|
||
|
||
return containerTags.has(element.tagName.toLowerCase());
|
||
}
|
||
|
||
// Find viable children for editing (elements with only text content)
|
||
findViableChildren(containerElement) {
|
||
const viable = [];
|
||
|
||
for (const child of containerElement.children) {
|
||
// Skip elements that already have .insertr class
|
||
if (child.classList.contains('insertr')) {
|
||
continue;
|
||
}
|
||
|
||
// Skip self-closing elements
|
||
if (this.isSelfClosing(child)) {
|
||
continue;
|
||
}
|
||
|
||
// Check if element has only text content (no nested HTML elements)
|
||
if (this.hasOnlyTextContent(child)) {
|
||
viable.push(child);
|
||
}
|
||
}
|
||
|
||
return viable;
|
||
}
|
||
|
||
// Check if element is viable for editing (allows simple formatting)
|
||
hasOnlyTextContent(element) {
|
||
// Allow elements with simple formatting tags
|
||
const allowedTags = new Set(['strong', 'b', 'em', 'i', 'a', 'span', 'code']);
|
||
|
||
for (const child of element.children) {
|
||
const tagName = child.tagName.toLowerCase();
|
||
|
||
// If child is not an allowed formatting tag, reject
|
||
if (!allowedTags.has(tagName)) {
|
||
return false;
|
||
}
|
||
|
||
// If formatting tag has nested complex elements, reject
|
||
if (child.children.length > 0) {
|
||
// Recursively check nested content isn't too complex
|
||
for (const nestedChild of child.children) {
|
||
const nestedTag = nestedChild.tagName.toLowerCase();
|
||
if (!allowedTags.has(nestedTag)) {
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Element has only text and/or simple formatting - this is viable
|
||
return element.textContent.trim().length > 0;
|
||
}
|
||
|
||
// Check if element is self-closing
|
||
isSelfClosing(element) {
|
||
const selfClosingTags = new Set([
|
||
'img', 'input', 'br', 'hr', 'meta', 'link',
|
||
'area', 'base', 'col', 'embed', 'source', 'track', 'wbr'
|
||
]);
|
||
|
||
return selfClosingTags.has(element.tagName.toLowerCase());
|
||
}
|
||
|
||
// Get element metadata
|
||
getElementMetadata(element) {
|
||
const existingId = element.getAttribute('data-content-id');
|
||
|
||
// Ensure element has insertr class for server processing
|
||
if (!element.classList.contains('insertr')) {
|
||
element.classList.add('insertr');
|
||
}
|
||
|
||
// Send HTML markup to server for unified ID generation
|
||
return {
|
||
contentId: existingId, // null if new content, existing ID if updating
|
||
contentType: element.getAttribute('data-content-type') || this.detectContentType(element),
|
||
element: element,
|
||
htmlMarkup: element.outerHTML // Server will generate ID from this
|
||
};
|
||
}
|
||
|
||
// Get current file path from URL for consistent ID generation
|
||
getCurrentFilePath() {
|
||
const path = window.location.pathname;
|
||
if (path === '/' || path === '') {
|
||
return 'index.html';
|
||
}
|
||
// Remove leading slash: "/about.html" → "about.html"
|
||
return path.replace(/^\//, '');
|
||
}
|
||
|
||
// Detect content type for elements without data-content-type
|
||
detectContentType(element) {
|
||
const tag = element.tagName.toLowerCase();
|
||
|
||
if (element.classList.contains('insertr-group')) {
|
||
return 'markdown';
|
||
}
|
||
|
||
switch (tag) {
|
||
case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6':
|
||
return 'text';
|
||
case 'p':
|
||
return 'textarea';
|
||
case 'a': case 'button':
|
||
return 'link';
|
||
case 'div': case 'section':
|
||
return 'markdown';
|
||
case 'span':
|
||
return 'markdown'; // Match backend: spans support inline markdown
|
||
default:
|
||
return 'text';
|
||
}
|
||
}
|
||
|
||
// Get all elements with their metadata, including group elements
|
||
getAllElements() {
|
||
const directElements = document.querySelectorAll('.insertr, .insertr-group');
|
||
const processedElements = [];
|
||
|
||
directElements.forEach(element => {
|
||
if (element.classList.contains('insertr-group')) {
|
||
// Group element - treat as single editable unit
|
||
processedElements.push(element);
|
||
} else if (this.isContainer(element)) {
|
||
// Container element - expand to children
|
||
const children = this.findViableChildren(element);
|
||
processedElements.push(...children);
|
||
} else {
|
||
// Regular element
|
||
processedElements.push(element);
|
||
}
|
||
});
|
||
|
||
return Array.from(processedElements).map(el => this.getElementMetadata(el));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* marked v16.2.1 - a markdown parser
|
||
* Copyright (c) 2011-2025, Christopher Jeffrey. (MIT Licensed)
|
||
* https://github.com/markedjs/marked
|
||
*/
|
||
|
||
/**
|
||
* DO NOT EDIT THIS FILE
|
||
* The code in this file is generated from files in ./src/
|
||
*/
|
||
|
||
function L(){return {async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var O=L();function H(l){O=l;}var E={exec:()=>null};function h(l,e=""){let t=typeof l=="string"?l:l.source,n={replace:(r,i)=>{let s=typeof i=="string"?i:i.source;return s=s.replace(m.caret,"$1"),t=t.replace(r,s),n},getRegex:()=>new RegExp(t,e)};return n}var m={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^<a /i,endATag:/^<\/a>/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^</,endAngleBracket:/>$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:l=>new RegExp(`^( {0,3}${l})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),hrRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}#`),htmlBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}<(?:[a-z].*>|!--)`,"i")},xe=/^(?:[ \t]*(?:\n|$))+/,be=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,Re=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,C=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,Oe=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,j=/(?:[*+-]|\d{1,9}[.)])/,se=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,ie=h(se).replace(/bull/g,j).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),Te=h(se).replace(/bull/g,j).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),F=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,we=/^[^\n]+/,Q=/(?!\s*\])(?:\\[\s\S]|[^\[\]\\])+/,ye=h(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",Q).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),Pe=h(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,j).getRegex(),v="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",U=/<!--(?:-?>|[\s\S]*?(?:-->|$))/,Se=h("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:</\\1>[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|<![A-Z][\\s\\S]*?(?:>\\n*|$)|<!\\[CDATA\\[[\\s\\S]*?(?:\\]\\]>\\n*|$)|</?(tag)(?: +|\\n|/?>)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|</(?!script|pre|style|textarea)[a-z][\\w-]*\\s*>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",U).replace("tag",v).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),oe=h(F).replace("hr",C).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","</?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex(),$e=h(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",oe).getRegex(),K={blockquote:$e,code:be,def:ye,fences:Re,heading:Oe,hr:C,html:Se,lheading:ie,list:Pe,newline:xe,paragraph:oe,table:E,text:we},re=h("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",C).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","</?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex(),_e={...K,lheading:Te,table:re,paragraph:h(F).replace("hr",C).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",re).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","</?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex()},Le={...K,html:h(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+?</\\1> *(?:\\n{2,}|\\s*$)|<tag(?:"[^"]*"|'[^']*'|\\s[^'"/>\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",U).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *<?([^\s>]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:E,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:h(F).replace("hr",C).replace("heading",` *#{1,6} *[^
|
||
]`).replace("lheading",ie).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},Me=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,ze=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,ae=/^( {2,}|\\)\n(?!\s*$)/,Ae=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\<!\[`*_]|\b_|$)|[^ ](?= {2,}\n)))/,D=/[\p{P}\p{S}]/u,W=/[\s\p{P}\p{S}]/u,le=/[^\s\p{P}\p{S}]/u,Ee=h(/^((?![*_])punctSpace)/,"u").replace(/punctSpace/g,W).getRegex(),ue=/(?!~)[\p{P}\p{S}]/u,Ce=/(?!~)[\s\p{P}\p{S}]/u,Ie=/(?:[^\s\p{P}\p{S}]|~)/u,Be=/\[[^\[\]]*?\]\((?:\\[\s\S]|[^\\\(\)]|\((?:\\[\s\S]|[^\\\(\)])*\))*\)|`[^`]*?`|<(?! )[^<>]*?>/g,pe=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,qe=h(pe,"u").replace(/punct/g,D).getRegex(),ve=h(pe,"u").replace(/punct/g,ue).getRegex(),ce="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",De=h(ce,"gu").replace(/notPunctSpace/g,le).replace(/punctSpace/g,W).replace(/punct/g,D).getRegex(),Ze=h(ce,"gu").replace(/notPunctSpace/g,Ie).replace(/punctSpace/g,Ce).replace(/punct/g,ue).getRegex(),Ge=h("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,le).replace(/punctSpace/g,W).replace(/punct/g,D).getRegex(),He=h(/\\(punct)/,"gu").replace(/punct/g,D).getRegex(),Ne=h(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),je=h(U).replace("(?:-->|$)","-->").getRegex(),Fe=h("^comment|^</[a-zA-Z][\\w:-]*\\s*>|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^<![a-zA-Z]+\\s[\\s\\S]*?>|^<!\\[CDATA\\[[\\s\\S]*?\\]\\]>").replace("comment",je).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),q=/(?:\[(?:\\[\s\S]|[^\[\]\\])*\]|\\[\s\S]|`[^`]*`|[^\[\]\\`])*?/,Qe=h(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]*(?:\n[ \t]*)?)(title))?\s*\)/).replace("label",q).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),he=h(/^!?\[(label)\]\[(ref)\]/).replace("label",q).replace("ref",Q).getRegex(),de=h(/^!?\[(ref)\](?:\[\])?/).replace("ref",Q).getRegex(),Ue=h("reflink|nolink(?!\\()","g").replace("reflink",he).replace("nolink",de).getRegex(),X={_backpedal:E,anyPunctuation:He,autolink:Ne,blockSkip:Be,br:ae,code:ze,del:E,emStrongLDelim:qe,emStrongRDelimAst:De,emStrongRDelimUnd:Ge,escape:Me,link:Qe,nolink:de,punctuation:Ee,reflink:he,reflinkSearch:Ue,tag:Fe,text:Ae,url:E},Ke={...X,link:h(/^!?\[(label)\]\((.*?)\)/).replace("label",q).getRegex(),reflink:h(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",q).getRegex()},N={...X,emStrongRDelimAst:Ze,emStrongLDelim:ve,url:h(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,"i").replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\[\s\S]|[^\\])*?(?:\\[\s\S]|[^\s~\\]))\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\<!\[`*~_]|\b_|https?:\/\/|ftp:\/\/|www\.|$)|[^ ](?= {2,}\n)|[^a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-](?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)))/},We={...N,br:h(ae).replace("{2,}","*").getRegex(),text:h(N.text).replace("\\b_","\\b_| {2,}\\n").replace(/\{2,\}/g,"*").getRegex()},I={normal:K,gfm:_e,pedantic:Le},M={normal:X,gfm:N,breaks:We,pedantic:Ke};var Xe={"&":"&","<":"<",">":">",'"':""","'":"'"},ke=l=>Xe[l];function w(l,e){if(e){if(m.escapeTest.test(l))return l.replace(m.escapeReplace,ke)}else if(m.escapeTestNoEncode.test(l))return l.replace(m.escapeReplaceNoEncode,ke);return l}function J(l){try{l=encodeURI(l).replace(m.percentDecode,"%");}catch{return null}return l}function V(l,e){let t=l.replace(m.findPipe,(i,s,o)=>{let a=!1,u=s;for(;--u>=0&&o[u]==="\\";)a=!a;return a?"|":" |"}),n=t.split(m.splitPipe),r=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),e)if(n.length>e)n.splice(e);else for(;n.length<e;)n.push("");for(;r<n.length;r++)n[r]=n[r].trim().replace(m.slashPipe,"|");return n}function z(l,e,t){let n=l.length;if(n===0)return "";let r=0;for(;r<n;){let i=l.charAt(n-r-1);if(i===e&&!t)r++;else if(i!==e&&t)r++;else break}return l.slice(0,n-r)}function ge(l,e){if(l.indexOf(e[1])===-1)return -1;let t=0;for(let n=0;n<l.length;n++)if(l[n]==="\\")n++;else if(l[n]===e[0])t++;else if(l[n]===e[1]&&(t--,t<0))return n;return t>0?-2:-1}function fe(l,e,t,n,r){let i=e.href,s=e.title||null,o=l[1].replace(r.other.outputLinkReplace,"$1");n.state.inLink=!0;let a={type:l[0].charAt(0)==="!"?"image":"link",raw:t,href:i,title:s,text:o,tokens:n.inlineTokens(o)};return n.state.inLink=!1,a}function Je(l,e,t){let n=l.match(t.other.indentCodeCompensation);if(n===null)return e;let r=n[1];return e.split(`
|
||
`).map(i=>{let s=i.match(t.other.beginningSpace);if(s===null)return i;let[o]=s;return o.length>=r.length?i.slice(r.length):i}).join(`
|
||
`)}var y=class{options;rules;lexer;constructor(e){this.options=e||O;}space(e){let t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)return {type:"space",raw:t[0]}}code(e){let t=this.rules.block.code.exec(e);if(t){let n=t[0].replace(this.rules.other.codeRemoveIndent,"");return {type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?n:z(n,`
|
||
`)}}}fences(e){let t=this.rules.block.fences.exec(e);if(t){let n=t[0],r=Je(n,t[3]||"",this.rules);return {type:"code",raw:n,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:r}}}heading(e){let t=this.rules.block.heading.exec(e);if(t){let n=t[2].trim();if(this.rules.other.endingHash.test(n)){let r=z(n,"#");(this.options.pedantic||!r||this.rules.other.endingSpaceChar.test(r))&&(n=r.trim());}return {type:"heading",raw:t[0],depth:t[1].length,text:n,tokens:this.lexer.inline(n)}}}hr(e){let t=this.rules.block.hr.exec(e);if(t)return {type:"hr",raw:z(t[0],`
|
||
`)}}blockquote(e){let t=this.rules.block.blockquote.exec(e);if(t){let n=z(t[0],`
|
||
`).split(`
|
||
`),r="",i="",s=[];for(;n.length>0;){let o=!1,a=[],u;for(u=0;u<n.length;u++)if(this.rules.other.blockquoteStart.test(n[u]))a.push(n[u]),o=!0;else if(!o)a.push(n[u]);else break;n=n.slice(u);let p=a.join(`
|
||
`),c=p.replace(this.rules.other.blockquoteSetextReplace,`
|
||
$1`).replace(this.rules.other.blockquoteSetextReplace2,"");r=r?`${r}
|
||
${p}`:p,i=i?`${i}
|
||
${c}`:c;let f=this.lexer.state.top;if(this.lexer.state.top=!0,this.lexer.blockTokens(c,s,!0),this.lexer.state.top=f,n.length===0)break;let k=s.at(-1);if(k?.type==="code")break;if(k?.type==="blockquote"){let x=k,g=x.raw+`
|
||
`+n.join(`
|
||
`),T=this.blockquote(g);s[s.length-1]=T,r=r.substring(0,r.length-x.raw.length)+T.raw,i=i.substring(0,i.length-x.text.length)+T.text;break}else if(k?.type==="list"){let x=k,g=x.raw+`
|
||
`+n.join(`
|
||
`),T=this.list(g);s[s.length-1]=T,r=r.substring(0,r.length-k.raw.length)+T.raw,i=i.substring(0,i.length-x.raw.length)+T.raw,n=g.substring(s.at(-1).raw.length).split(`
|
||
`);continue}}return {type:"blockquote",raw:r,tokens:s,text:i}}}list(e){let t=this.rules.block.list.exec(e);if(t){let n=t[1].trim(),r=n.length>1,i={type:"list",raw:"",ordered:r,start:r?+n.slice(0,-1):"",loose:!1,items:[]};n=r?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=r?n:"[*+-]");let s=this.rules.other.listItemRegex(n),o=!1;for(;e;){let u=!1,p="",c="";if(!(t=s.exec(e))||this.rules.block.hr.test(e))break;p=t[0],e=e.substring(p.length);let f=t[2].split(`
|
||
`,1)[0].replace(this.rules.other.listReplaceTabs,Z=>" ".repeat(3*Z.length)),k=e.split(`
|
||
`,1)[0],x=!f.trim(),g=0;if(this.options.pedantic?(g=2,c=f.trimStart()):x?g=t[1].length+1:(g=t[2].search(this.rules.other.nonSpaceChar),g=g>4?1:g,c=f.slice(g),g+=t[1].length),x&&this.rules.other.blankLine.test(k)&&(p+=k+`
|
||
`,e=e.substring(k.length+1),u=!0),!u){let Z=this.rules.other.nextBulletRegex(g),ee=this.rules.other.hrRegex(g),te=this.rules.other.fencesBeginRegex(g),ne=this.rules.other.headingBeginRegex(g),me=this.rules.other.htmlBeginRegex(g);for(;e;){let G=e.split(`
|
||
`,1)[0],A;if(k=G,this.options.pedantic?(k=k.replace(this.rules.other.listReplaceNesting," "),A=k):A=k.replace(this.rules.other.tabCharGlobal," "),te.test(k)||ne.test(k)||me.test(k)||Z.test(k)||ee.test(k))break;if(A.search(this.rules.other.nonSpaceChar)>=g||!k.trim())c+=`
|
||
`+A.slice(g);else {if(x||f.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||te.test(f)||ne.test(f)||ee.test(f))break;c+=`
|
||
`+k;}!x&&!k.trim()&&(x=!0),p+=G+`
|
||
`,e=e.substring(G.length+1),f=A.slice(g);}}i.loose||(o?i.loose=!0:this.rules.other.doubleBlankLine.test(p)&&(o=!0));let T=null,Y;this.options.gfm&&(T=this.rules.other.listIsTask.exec(c),T&&(Y=T[0]!=="[ ] ",c=c.replace(this.rules.other.listReplaceTask,""))),i.items.push({type:"list_item",raw:p,task:!!T,checked:Y,loose:!1,text:c,tokens:[]}),i.raw+=p;}let a=i.items.at(-1);if(a)a.raw=a.raw.trimEnd(),a.text=a.text.trimEnd();else return;i.raw=i.raw.trimEnd();for(let u=0;u<i.items.length;u++)if(this.lexer.state.top=!1,i.items[u].tokens=this.lexer.blockTokens(i.items[u].text,[]),!i.loose){let p=i.items[u].tokens.filter(f=>f.type==="space"),c=p.length>0&&p.some(f=>this.rules.other.anyLine.test(f.raw));i.loose=c;}if(i.loose)for(let u=0;u<i.items.length;u++)i.items[u].loose=!0;return i}}html(e){let t=this.rules.block.html.exec(e);if(t)return {type:"html",block:!0,raw:t[0],pre:t[1]==="pre"||t[1]==="script"||t[1]==="style",text:t[0]}}def(e){let t=this.rules.block.def.exec(e);if(t){let n=t[1].toLowerCase().replace(this.rules.other.multipleSpaceGlobal," "),r=t[2]?t[2].replace(this.rules.other.hrefBrackets,"$1").replace(this.rules.inline.anyPunctuation,"$1"):"",i=t[3]?t[3].substring(1,t[3].length-1).replace(this.rules.inline.anyPunctuation,"$1"):t[3];return {type:"def",tag:n,raw:t[0],href:r,title:i}}}table(e){let t=this.rules.block.table.exec(e);if(!t||!this.rules.other.tableDelimiter.test(t[2]))return;let n=V(t[1]),r=t[2].replace(this.rules.other.tableAlignChars,"").split("|"),i=t[3]?.trim()?t[3].replace(this.rules.other.tableRowBlankLine,"").split(`
|
||
`):[],s={type:"table",raw:t[0],header:[],align:[],rows:[]};if(n.length===r.length){for(let o of r)this.rules.other.tableAlignRight.test(o)?s.align.push("right"):this.rules.other.tableAlignCenter.test(o)?s.align.push("center"):this.rules.other.tableAlignLeft.test(o)?s.align.push("left"):s.align.push(null);for(let o=0;o<n.length;o++)s.header.push({text:n[o],tokens:this.lexer.inline(n[o]),header:!0,align:s.align[o]});for(let o of i)s.rows.push(V(o,s.header.length).map((a,u)=>({text:a,tokens:this.lexer.inline(a),header:!1,align:s.align[u]})));return s}}lheading(e){let t=this.rules.block.lheading.exec(e);if(t)return {type:"heading",raw:t[0],depth:t[2].charAt(0)==="="?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){let t=this.rules.block.paragraph.exec(e);if(t){let n=t[1].charAt(t[1].length-1)===`
|
||
`?t[1].slice(0,-1):t[1];return {type:"paragraph",raw:t[0],text:n,tokens:this.lexer.inline(n)}}}text(e){let t=this.rules.block.text.exec(e);if(t)return {type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){let t=this.rules.inline.escape.exec(e);if(t)return {type:"escape",raw:t[0],text:t[1]}}tag(e){let t=this.rules.inline.tag.exec(e);if(t)return !this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){let t=this.rules.inline.link.exec(e);if(t){let n=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(n)){if(!this.rules.other.endAngleBracket.test(n))return;let s=z(n.slice(0,-1),"\\");if((n.length-s.length)%2===0)return}else {let s=ge(t[2],"()");if(s===-2)return;if(s>-1){let a=(t[0].indexOf("!")===0?5:4)+t[1].length+s;t[2]=t[2].substring(0,s),t[0]=t[0].substring(0,a).trim(),t[3]="";}}let r=t[2],i="";if(this.options.pedantic){let s=this.rules.other.pedanticHrefTitle.exec(r);s&&(r=s[1],i=s[3]);}else i=t[3]?t[3].slice(1,-1):"";return r=r.trim(),this.rules.other.startAngleBracket.test(r)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(n)?r=r.slice(1):r=r.slice(1,-1)),fe(t,{href:r&&r.replace(this.rules.inline.anyPunctuation,"$1"),title:i&&i.replace(this.rules.inline.anyPunctuation,"$1")},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){let r=(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," "),i=t[r.toLowerCase()];if(!i){let s=n[0].charAt(0);return {type:"text",raw:s,text:s}}return fe(n,i,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let r=this.rules.inline.emStrongLDelim.exec(e);if(!r||r[3]&&n.match(this.rules.other.unicodeAlphaNumeric))return;if(!(r[1]||r[2]||"")||!n||this.rules.inline.punctuation.exec(n)){let s=[...r[0]].length-1,o,a,u=s,p=0,c=r[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(c.lastIndex=0,t=t.slice(-1*e.length+s);(r=c.exec(t))!=null;){if(o=r[1]||r[2]||r[3]||r[4]||r[5]||r[6],!o)continue;if(a=[...o].length,r[3]||r[4]){u+=a;continue}else if((r[5]||r[6])&&s%3&&!((s+a)%3)){p+=a;continue}if(u-=a,u>0)continue;a=Math.min(a,a+u+p);let f=[...r[0]][0].length,k=e.slice(0,s+r.index+f+a);if(Math.min(s,a)%2){let g=k.slice(1,-1);return {type:"em",raw:k,text:g,tokens:this.lexer.inlineTokens(g)}}let x=k.slice(2,-2);return {type:"strong",raw:k,text:x,tokens:this.lexer.inlineTokens(x)}}}}codespan(e){let t=this.rules.inline.code.exec(e);if(t){let n=t[2].replace(this.rules.other.newLineCharGlobal," "),r=this.rules.other.nonSpaceChar.test(n),i=this.rules.other.startingSpaceChar.test(n)&&this.rules.other.endingSpaceChar.test(n);return r&&i&&(n=n.substring(1,n.length-1)),{type:"codespan",raw:t[0],text:n}}}br(e){let t=this.rules.inline.br.exec(e);if(t)return {type:"br",raw:t[0]}}del(e){let t=this.rules.inline.del.exec(e);if(t)return {type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){let t=this.rules.inline.autolink.exec(e);if(t){let n,r;return t[2]==="@"?(n=t[1],r="mailto:"+n):(n=t[1],r=n),{type:"link",raw:t[0],text:n,href:r,tokens:[{type:"text",raw:n,text:n}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let n,r;if(t[2]==="@")n=t[0],r="mailto:"+n;else {let i;do i=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??"";while(i!==t[0]);n=t[0],t[1]==="www."?r="http://"+t[0]:r=t[0];}return {type:"link",raw:t[0],text:n,href:r,tokens:[{type:"text",raw:n,text:n}]}}}inlineText(e){let t=this.rules.inline.text.exec(e);if(t){let n=this.lexer.state.inRawBlock;return {type:"text",raw:t[0],text:t[0],escaped:n}}}};var b=class l{tokens;options;state;tokenizer;inlineQueue;constructor(e){this.tokens=[],this.tokens.links=Object.create(null),this.options=e||O,this.options.tokenizer=this.options.tokenizer||new y,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let t={other:m,block:I.normal,inline:M.normal};this.options.pedantic?(t.block=I.pedantic,t.inline=M.pedantic):this.options.gfm&&(t.block=I.gfm,this.options.breaks?t.inline=M.breaks:t.inline=M.gfm),this.tokenizer.rules=t;}static get rules(){return {block:I,inline:M}}static lex(e,t){return new l(t).lex(e)}static lexInline(e,t){return new l(t).inlineTokens(e)}lex(e){e=e.replace(m.carriageReturn,`
|
||
`),this.blockTokens(e,this.tokens);for(let t=0;t<this.inlineQueue.length;t++){let n=this.inlineQueue[t];this.inlineTokens(n.src,n.tokens);}return this.inlineQueue=[],this.tokens}blockTokens(e,t=[],n=!1){for(this.options.pedantic&&(e=e.replace(m.tabCharGlobal," ").replace(m.spaceLine,""));e;){let r;if(this.options.extensions?.block?.some(s=>(r=s.call({lexer:this},e,t))?(e=e.substring(r.raw.length),t.push(r),!0):!1))continue;if(r=this.tokenizer.space(e)){e=e.substring(r.raw.length);let s=t.at(-1);r.raw.length===1&&s!==void 0?s.raw+=`
|
||
`:t.push(r);continue}if(r=this.tokenizer.code(e)){e=e.substring(r.raw.length);let s=t.at(-1);s?.type==="paragraph"||s?.type==="text"?(s.raw+=(s.raw.endsWith(`
|
||
`)?"":`
|
||
`)+r.raw,s.text+=`
|
||
`+r.text,this.inlineQueue.at(-1).src=s.text):t.push(r);continue}if(r=this.tokenizer.fences(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.heading(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.hr(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.blockquote(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.list(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.html(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.def(e)){e=e.substring(r.raw.length);let s=t.at(-1);s?.type==="paragraph"||s?.type==="text"?(s.raw+=(s.raw.endsWith(`
|
||
`)?"":`
|
||
`)+r.raw,s.text+=`
|
||
`+r.raw,this.inlineQueue.at(-1).src=s.text):this.tokens.links[r.tag]||(this.tokens.links[r.tag]={href:r.href,title:r.title},t.push(r));continue}if(r=this.tokenizer.table(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.lheading(e)){e=e.substring(r.raw.length),t.push(r);continue}let i=e;if(this.options.extensions?.startBlock){let s=1/0,o=e.slice(1),a;this.options.extensions.startBlock.forEach(u=>{a=u.call({lexer:this},o),typeof a=="number"&&a>=0&&(s=Math.min(s,a));}),s<1/0&&s>=0&&(i=e.substring(0,s+1));}if(this.state.top&&(r=this.tokenizer.paragraph(i))){let s=t.at(-1);n&&s?.type==="paragraph"?(s.raw+=(s.raw.endsWith(`
|
||
`)?"":`
|
||
`)+r.raw,s.text+=`
|
||
`+r.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=s.text):t.push(r),n=i.length!==e.length,e=e.substring(r.raw.length);continue}if(r=this.tokenizer.text(e)){e=e.substring(r.raw.length);let s=t.at(-1);s?.type==="text"?(s.raw+=(s.raw.endsWith(`
|
||
`)?"":`
|
||
`)+r.raw,s.text+=`
|
||
`+r.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=s.text):t.push(r);continue}if(e){let s="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(s);break}else throw new Error(s)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n=e,r=null;if(this.tokens.links){let o=Object.keys(this.tokens.links);if(o.length>0)for(;(r=this.tokenizer.rules.inline.reflinkSearch.exec(n))!=null;)o.includes(r[0].slice(r[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,r.index)+"["+"a".repeat(r[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex));}for(;(r=this.tokenizer.rules.inline.anyPunctuation.exec(n))!=null;)n=n.slice(0,r.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;(r=this.tokenizer.rules.inline.blockSkip.exec(n))!=null;)n=n.slice(0,r.index)+"["+"a".repeat(r[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);let i=!1,s="";for(;e;){i||(s=""),i=!1;let o;if(this.options.extensions?.inline?.some(u=>(o=u.call({lexer:this},e,t))?(e=e.substring(o.raw.length),t.push(o),!0):!1))continue;if(o=this.tokenizer.escape(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.tag(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.link(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(o.raw.length);let u=t.at(-1);o.type==="text"&&u?.type==="text"?(u.raw+=o.raw,u.text+=o.text):t.push(o);continue}if(o=this.tokenizer.emStrong(e,n,s)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.codespan(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.br(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.del(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.autolink(e)){e=e.substring(o.raw.length),t.push(o);continue}if(!this.state.inLink&&(o=this.tokenizer.url(e))){e=e.substring(o.raw.length),t.push(o);continue}let a=e;if(this.options.extensions?.startInline){let u=1/0,p=e.slice(1),c;this.options.extensions.startInline.forEach(f=>{c=f.call({lexer:this},p),typeof c=="number"&&c>=0&&(u=Math.min(u,c));}),u<1/0&&u>=0&&(a=e.substring(0,u+1));}if(o=this.tokenizer.inlineText(a)){e=e.substring(o.raw.length),o.raw.slice(-1)!=="_"&&(s=o.raw.slice(-1)),i=!0;let u=t.at(-1);u?.type==="text"?(u.raw+=o.raw,u.text+=o.text):t.push(o);continue}if(e){let u="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(u);break}else throw new Error(u)}}return t}};var P=class{options;parser;constructor(e){this.options=e||O;}space(e){return ""}code({text:e,lang:t,escaped:n}){let r=(t||"").match(m.notSpaceStart)?.[0],i=e.replace(m.endingNewline,"")+`
|
||
`;return r?'<pre><code class="language-'+w(r)+'">'+(n?i:w(i,!0))+`</code></pre>
|
||
`:"<pre><code>"+(n?i:w(i,!0))+`</code></pre>
|
||
`}blockquote({tokens:e}){return `<blockquote>
|
||
${this.parser.parse(e)}</blockquote>
|
||
`}html({text:e}){return e}def(e){return ""}heading({tokens:e,depth:t}){return `<h${t}>${this.parser.parseInline(e)}</h${t}>
|
||
`}hr(e){return `<hr>
|
||
`}list(e){let t=e.ordered,n=e.start,r="";for(let o=0;o<e.items.length;o++){let a=e.items[o];r+=this.listitem(a);}let i=t?"ol":"ul",s=t&&n!==1?' start="'+n+'"':"";return "<"+i+s+`>
|
||
`+r+"</"+i+`>
|
||
`}listitem(e){let t="";if(e.task){let n=this.checkbox({checked:!!e.checked});e.loose?e.tokens[0]?.type==="paragraph"?(e.tokens[0].text=n+" "+e.tokens[0].text,e.tokens[0].tokens&&e.tokens[0].tokens.length>0&&e.tokens[0].tokens[0].type==="text"&&(e.tokens[0].tokens[0].text=n+" "+w(e.tokens[0].tokens[0].text),e.tokens[0].tokens[0].escaped=!0)):e.tokens.unshift({type:"text",raw:n+" ",text:n+" ",escaped:!0}):t+=n+" ";}return t+=this.parser.parse(e.tokens,!!e.loose),`<li>${t}</li>
|
||
`}checkbox({checked:e}){return "<input "+(e?'checked="" ':"")+'disabled="" type="checkbox">'}paragraph({tokens:e}){return `<p>${this.parser.parseInline(e)}</p>
|
||
`}table(e){let t="",n="";for(let i=0;i<e.header.length;i++)n+=this.tablecell(e.header[i]);t+=this.tablerow({text:n});let r="";for(let i=0;i<e.rows.length;i++){let s=e.rows[i];n="";for(let o=0;o<s.length;o++)n+=this.tablecell(s[o]);r+=this.tablerow({text:n});}return r&&(r=`<tbody>${r}</tbody>`),`<table>
|
||
<thead>
|
||
`+t+`</thead>
|
||
`+r+`</table>
|
||
`}tablerow({text:e}){return `<tr>
|
||
${e}</tr>
|
||
`}tablecell(e){let t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return (e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+`</${n}>
|
||
`}strong({tokens:e}){return `<strong>${this.parser.parseInline(e)}</strong>`}em({tokens:e}){return `<em>${this.parser.parseInline(e)}</em>`}codespan({text:e}){return `<code>${w(e,!0)}</code>`}br(e){return "<br>"}del({tokens:e}){return `<del>${this.parser.parseInline(e)}</del>`}link({href:e,title:t,tokens:n}){let r=this.parser.parseInline(n),i=J(e);if(i===null)return r;e=i;let s='<a href="'+e+'"';return t&&(s+=' title="'+w(t)+'"'),s+=">"+r+"</a>",s}image({href:e,title:t,text:n,tokens:r}){r&&(n=this.parser.parseInline(r,this.parser.textRenderer));let i=J(e);if(i===null)return w(n);e=i;let s=`<img src="${e}" alt="${n}"`;return t&&(s+=` title="${w(t)}"`),s+=">",s}text(e){return "tokens"in e&&e.tokens?this.parser.parseInline(e.tokens):"escaped"in e&&e.escaped?e.text:w(e.text)}};var S=class{strong({text:e}){return e}em({text:e}){return e}codespan({text:e}){return e}del({text:e}){return e}html({text:e}){return e}text({text:e}){return e}link({text:e}){return ""+e}image({text:e}){return ""+e}br(){return ""}};var R=class l{options;renderer;textRenderer;constructor(e){this.options=e||O,this.options.renderer=this.options.renderer||new P,this.renderer=this.options.renderer,this.renderer.options=this.options,this.renderer.parser=this,this.textRenderer=new S;}static parse(e,t){return new l(t).parse(e)}static parseInline(e,t){return new l(t).parseInline(e)}parse(e,t=!0){let n="";for(let r=0;r<e.length;r++){let i=e[r];if(this.options.extensions?.renderers?.[i.type]){let o=i,a=this.options.extensions.renderers[o.type].call({parser:this},o);if(a!==!1||!["space","hr","heading","code","table","blockquote","list","html","def","paragraph","text"].includes(o.type)){n+=a||"";continue}}let s=i;switch(s.type){case"space":{n+=this.renderer.space(s);continue}case"hr":{n+=this.renderer.hr(s);continue}case"heading":{n+=this.renderer.heading(s);continue}case"code":{n+=this.renderer.code(s);continue}case"table":{n+=this.renderer.table(s);continue}case"blockquote":{n+=this.renderer.blockquote(s);continue}case"list":{n+=this.renderer.list(s);continue}case"html":{n+=this.renderer.html(s);continue}case"def":{n+=this.renderer.def(s);continue}case"paragraph":{n+=this.renderer.paragraph(s);continue}case"text":{let o=s,a=this.renderer.text(o);for(;r+1<e.length&&e[r+1].type==="text";)o=e[++r],a+=`
|
||
`+this.renderer.text(o);t?n+=this.renderer.paragraph({type:"paragraph",raw:a,text:a,tokens:[{type:"text",raw:a,text:a,escaped:!0}]}):n+=a;continue}default:{let o='Token with "'+s.type+'" type was not found.';if(this.options.silent)return console.error(o),"";throw new Error(o)}}}return n}parseInline(e,t=this.renderer){let n="";for(let r=0;r<e.length;r++){let i=e[r];if(this.options.extensions?.renderers?.[i.type]){let o=this.options.extensions.renderers[i.type].call({parser:this},i);if(o!==!1||!["escape","html","link","image","strong","em","codespan","br","del","text"].includes(i.type)){n+=o||"";continue}}let s=i;switch(s.type){case"escape":{n+=t.text(s);break}case"html":{n+=t.html(s);break}case"link":{n+=t.link(s);break}case"image":{n+=t.image(s);break}case"strong":{n+=t.strong(s);break}case"em":{n+=t.em(s);break}case"codespan":{n+=t.codespan(s);break}case"br":{n+=t.br(s);break}case"del":{n+=t.del(s);break}case"text":{n+=t.text(s);break}default:{let o='Token with "'+s.type+'" type was not found.';if(this.options.silent)return console.error(o),"";throw new Error(o)}}}return n}};var $=class{options;block;constructor(e){this.options=e||O;}static passThroughHooks=new Set(["preprocess","postprocess","processAllTokens"]);preprocess(e){return e}postprocess(e){return e}processAllTokens(e){return e}provideLexer(){return this.block?b.lex:b.lexInline}provideParser(){return this.block?R.parse:R.parseInline}};var B=class{defaults=L();options=this.setOptions;parse=this.parseMarkdown(!0);parseInline=this.parseMarkdown(!1);Parser=R;Renderer=P;TextRenderer=S;Lexer=b;Tokenizer=y;Hooks=$;constructor(...e){this.use(...e);}walkTokens(e,t){let n=[];for(let r of e)switch(n=n.concat(t.call(this,r)),r.type){case"table":{let i=r;for(let s of i.header)n=n.concat(this.walkTokens(s.tokens,t));for(let s of i.rows)for(let o of s)n=n.concat(this.walkTokens(o.tokens,t));break}case"list":{let i=r;n=n.concat(this.walkTokens(i.items,t));break}default:{let i=r;this.defaults.extensions?.childTokens?.[i.type]?this.defaults.extensions.childTokens[i.type].forEach(s=>{let o=i[s].flat(1/0);n=n.concat(this.walkTokens(o,t));}):i.tokens&&(n=n.concat(this.walkTokens(i.tokens,t)));}}return n}use(...e){let t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach(n=>{let r={...n};if(r.async=this.defaults.async||r.async||!1,n.extensions&&(n.extensions.forEach(i=>{if(!i.name)throw new Error("extension name required");if("renderer"in i){let s=t.renderers[i.name];s?t.renderers[i.name]=function(...o){let a=i.renderer.apply(this,o);return a===!1&&(a=s.apply(this,o)),a}:t.renderers[i.name]=i.renderer;}if("tokenizer"in i){if(!i.level||i.level!=="block"&&i.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");let s=t[i.level];s?s.unshift(i.tokenizer):t[i.level]=[i.tokenizer],i.start&&(i.level==="block"?t.startBlock?t.startBlock.push(i.start):t.startBlock=[i.start]:i.level==="inline"&&(t.startInline?t.startInline.push(i.start):t.startInline=[i.start]));}"childTokens"in i&&i.childTokens&&(t.childTokens[i.name]=i.childTokens);}),r.extensions=t),n.renderer){let i=this.defaults.renderer||new P(this.defaults);for(let s in n.renderer){if(!(s in i))throw new Error(`renderer '${s}' does not exist`);if(["options","parser"].includes(s))continue;let o=s,a=n.renderer[o],u=i[o];i[o]=(...p)=>{let c=a.apply(i,p);return c===!1&&(c=u.apply(i,p)),c||""};}r.renderer=i;}if(n.tokenizer){let i=this.defaults.tokenizer||new y(this.defaults);for(let s in n.tokenizer){if(!(s in i))throw new Error(`tokenizer '${s}' does not exist`);if(["options","rules","lexer"].includes(s))continue;let o=s,a=n.tokenizer[o],u=i[o];i[o]=(...p)=>{let c=a.apply(i,p);return c===!1&&(c=u.apply(i,p)),c};}r.tokenizer=i;}if(n.hooks){let i=this.defaults.hooks||new $;for(let s in n.hooks){if(!(s in i))throw new Error(`hook '${s}' does not exist`);if(["options","block"].includes(s))continue;let o=s,a=n.hooks[o],u=i[o];$.passThroughHooks.has(s)?i[o]=p=>{if(this.defaults.async)return Promise.resolve(a.call(i,p)).then(f=>u.call(i,f));let c=a.call(i,p);return u.call(i,c)}:i[o]=(...p)=>{let c=a.apply(i,p);return c===!1&&(c=u.apply(i,p)),c};}r.hooks=i;}if(n.walkTokens){let i=this.defaults.walkTokens,s=n.walkTokens;r.walkTokens=function(o){let a=[];return a.push(s.call(this,o)),i&&(a=a.concat(i.call(this,o))),a};}this.defaults={...this.defaults,...r};}),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return b.lex(e,t??this.defaults)}parser(e,t){return R.parse(e,t??this.defaults)}parseMarkdown(e){return (n,r)=>{let i={...r},s={...this.defaults,...i},o=this.onError(!!s.silent,!!s.async);if(this.defaults.async===!0&&i.async===!1)return o(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof n>"u"||n===null)return o(new Error("marked(): input parameter is undefined or null"));if(typeof n!="string")return o(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));s.hooks&&(s.hooks.options=s,s.hooks.block=e);let a=s.hooks?s.hooks.provideLexer():e?b.lex:b.lexInline,u=s.hooks?s.hooks.provideParser():e?R.parse:R.parseInline;if(s.async)return Promise.resolve(s.hooks?s.hooks.preprocess(n):n).then(p=>a(p,s)).then(p=>s.hooks?s.hooks.processAllTokens(p):p).then(p=>s.walkTokens?Promise.all(this.walkTokens(p,s.walkTokens)).then(()=>p):p).then(p=>u(p,s)).then(p=>s.hooks?s.hooks.postprocess(p):p).catch(o);try{s.hooks&&(n=s.hooks.preprocess(n));let p=a(n,s);s.hooks&&(p=s.hooks.processAllTokens(p)),s.walkTokens&&this.walkTokens(p,s.walkTokens);let c=u(p,s);return s.hooks&&(c=s.hooks.postprocess(c)),c}catch(p){return o(p)}}}onError(e,t){return n=>{if(n.message+=`
|
||
Please report this to https://github.com/markedjs/marked.`,e){let r="<p>An error occurred:</p><pre>"+w(n.message+"",!0)+"</pre>";return t?Promise.resolve(r):r}if(t)return Promise.reject(n);throw n}}};var _=new B;function d(l,e){return _.parse(l,e)}d.options=d.setOptions=function(l){return _.setOptions(l),d.defaults=_.defaults,H(d.defaults),d};d.getDefaults=L;d.defaults=O;d.use=function(...l){return _.use(...l),d.defaults=_.defaults,H(d.defaults),d};d.walkTokens=function(l,e){return _.walkTokens(l,e)};d.parseInline=_.parseInline;d.Parser=R;d.parser=R.parse;d.Renderer=P;d.TextRenderer=S;d.Lexer=b;d.lexer=b.lex;d.Tokenizer=y;d.Hooks=$;d.parse=d;d.options;d.setOptions;d.use;d.walkTokens;d.parseInline;R.parse;b.lex;
|
||
|
||
function extend (destination) {
|
||
for (var i = 1; i < arguments.length; i++) {
|
||
var source = arguments[i];
|
||
for (var key in source) {
|
||
if (source.hasOwnProperty(key)) destination[key] = source[key];
|
||
}
|
||
}
|
||
return destination
|
||
}
|
||
|
||
function repeat (character, count) {
|
||
return Array(count + 1).join(character)
|
||
}
|
||
|
||
function trimLeadingNewlines (string) {
|
||
return string.replace(/^\n*/, '')
|
||
}
|
||
|
||
function trimTrailingNewlines (string) {
|
||
// avoid match-at-end regexp bottleneck, see #370
|
||
var indexEnd = string.length;
|
||
while (indexEnd > 0 && string[indexEnd - 1] === '\n') indexEnd--;
|
||
return string.substring(0, indexEnd)
|
||
}
|
||
|
||
var blockElements = [
|
||
'ADDRESS', 'ARTICLE', 'ASIDE', 'AUDIO', 'BLOCKQUOTE', 'BODY', 'CANVAS',
|
||
'CENTER', 'DD', 'DIR', 'DIV', 'DL', 'DT', 'FIELDSET', 'FIGCAPTION', 'FIGURE',
|
||
'FOOTER', 'FORM', 'FRAMESET', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER',
|
||
'HGROUP', 'HR', 'HTML', 'ISINDEX', 'LI', 'MAIN', 'MENU', 'NAV', 'NOFRAMES',
|
||
'NOSCRIPT', 'OL', 'OUTPUT', 'P', 'PRE', 'SECTION', 'TABLE', 'TBODY', 'TD',
|
||
'TFOOT', 'TH', 'THEAD', 'TR', 'UL'
|
||
];
|
||
|
||
function isBlock (node) {
|
||
return is(node, blockElements)
|
||
}
|
||
|
||
var voidElements = [
|
||
'AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT',
|
||
'KEYGEN', 'LINK', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR'
|
||
];
|
||
|
||
function isVoid (node) {
|
||
return is(node, voidElements)
|
||
}
|
||
|
||
function hasVoid (node) {
|
||
return has(node, voidElements)
|
||
}
|
||
|
||
var meaningfulWhenBlankElements = [
|
||
'A', 'TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TH', 'TD', 'IFRAME', 'SCRIPT',
|
||
'AUDIO', 'VIDEO'
|
||
];
|
||
|
||
function isMeaningfulWhenBlank (node) {
|
||
return is(node, meaningfulWhenBlankElements)
|
||
}
|
||
|
||
function hasMeaningfulWhenBlank (node) {
|
||
return has(node, meaningfulWhenBlankElements)
|
||
}
|
||
|
||
function is (node, tagNames) {
|
||
return tagNames.indexOf(node.nodeName) >= 0
|
||
}
|
||
|
||
function has (node, tagNames) {
|
||
return (
|
||
node.getElementsByTagName &&
|
||
tagNames.some(function (tagName) {
|
||
return node.getElementsByTagName(tagName).length
|
||
})
|
||
)
|
||
}
|
||
|
||
var rules = {};
|
||
|
||
rules.paragraph = {
|
||
filter: 'p',
|
||
|
||
replacement: function (content) {
|
||
return '\n\n' + content + '\n\n'
|
||
}
|
||
};
|
||
|
||
rules.lineBreak = {
|
||
filter: 'br',
|
||
|
||
replacement: function (content, node, options) {
|
||
return options.br + '\n'
|
||
}
|
||
};
|
||
|
||
rules.heading = {
|
||
filter: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
|
||
|
||
replacement: function (content, node, options) {
|
||
var hLevel = Number(node.nodeName.charAt(1));
|
||
|
||
if (options.headingStyle === 'setext' && hLevel < 3) {
|
||
var underline = repeat((hLevel === 1 ? '=' : '-'), content.length);
|
||
return (
|
||
'\n\n' + content + '\n' + underline + '\n\n'
|
||
)
|
||
} else {
|
||
return '\n\n' + repeat('#', hLevel) + ' ' + content + '\n\n'
|
||
}
|
||
}
|
||
};
|
||
|
||
rules.blockquote = {
|
||
filter: 'blockquote',
|
||
|
||
replacement: function (content) {
|
||
content = content.replace(/^\n+|\n+$/g, '');
|
||
content = content.replace(/^/gm, '> ');
|
||
return '\n\n' + content + '\n\n'
|
||
}
|
||
};
|
||
|
||
rules.list = {
|
||
filter: ['ul', 'ol'],
|
||
|
||
replacement: function (content, node) {
|
||
var parent = node.parentNode;
|
||
if (parent.nodeName === 'LI' && parent.lastElementChild === node) {
|
||
return '\n' + content
|
||
} else {
|
||
return '\n\n' + content + '\n\n'
|
||
}
|
||
}
|
||
};
|
||
|
||
rules.listItem = {
|
||
filter: 'li',
|
||
|
||
replacement: function (content, node, options) {
|
||
var prefix = options.bulletListMarker + ' ';
|
||
var parent = node.parentNode;
|
||
if (parent.nodeName === 'OL') {
|
||
var start = parent.getAttribute('start');
|
||
var index = Array.prototype.indexOf.call(parent.children, node);
|
||
prefix = (start ? Number(start) + index : index + 1) + '. ';
|
||
}
|
||
content = content
|
||
.replace(/^\n+/, '') // remove leading newlines
|
||
.replace(/\n+$/, '\n') // replace trailing newlines with just a single one
|
||
.replace(/\n/gm, '\n' + ' '.repeat(prefix.length)); // indent
|
||
return (
|
||
prefix + content + (node.nextSibling && !/\n$/.test(content) ? '\n' : '')
|
||
)
|
||
}
|
||
};
|
||
|
||
rules.indentedCodeBlock = {
|
||
filter: function (node, options) {
|
||
return (
|
||
options.codeBlockStyle === 'indented' &&
|
||
node.nodeName === 'PRE' &&
|
||
node.firstChild &&
|
||
node.firstChild.nodeName === 'CODE'
|
||
)
|
||
},
|
||
|
||
replacement: function (content, node, options) {
|
||
return (
|
||
'\n\n ' +
|
||
node.firstChild.textContent.replace(/\n/g, '\n ') +
|
||
'\n\n'
|
||
)
|
||
}
|
||
};
|
||
|
||
rules.fencedCodeBlock = {
|
||
filter: function (node, options) {
|
||
return (
|
||
options.codeBlockStyle === 'fenced' &&
|
||
node.nodeName === 'PRE' &&
|
||
node.firstChild &&
|
||
node.firstChild.nodeName === 'CODE'
|
||
)
|
||
},
|
||
|
||
replacement: function (content, node, options) {
|
||
var className = node.firstChild.getAttribute('class') || '';
|
||
var language = (className.match(/language-(\S+)/) || [null, ''])[1];
|
||
var code = node.firstChild.textContent;
|
||
|
||
var fenceChar = options.fence.charAt(0);
|
||
var fenceSize = 3;
|
||
var fenceInCodeRegex = new RegExp('^' + fenceChar + '{3,}', 'gm');
|
||
|
||
var match;
|
||
while ((match = fenceInCodeRegex.exec(code))) {
|
||
if (match[0].length >= fenceSize) {
|
||
fenceSize = match[0].length + 1;
|
||
}
|
||
}
|
||
|
||
var fence = repeat(fenceChar, fenceSize);
|
||
|
||
return (
|
||
'\n\n' + fence + language + '\n' +
|
||
code.replace(/\n$/, '') +
|
||
'\n' + fence + '\n\n'
|
||
)
|
||
}
|
||
};
|
||
|
||
rules.horizontalRule = {
|
||
filter: 'hr',
|
||
|
||
replacement: function (content, node, options) {
|
||
return '\n\n' + options.hr + '\n\n'
|
||
}
|
||
};
|
||
|
||
rules.inlineLink = {
|
||
filter: function (node, options) {
|
||
return (
|
||
options.linkStyle === 'inlined' &&
|
||
node.nodeName === 'A' &&
|
||
node.getAttribute('href')
|
||
)
|
||
},
|
||
|
||
replacement: function (content, node) {
|
||
var href = node.getAttribute('href');
|
||
if (href) href = href.replace(/([()])/g, '\\$1');
|
||
var title = cleanAttribute(node.getAttribute('title'));
|
||
if (title) title = ' "' + title.replace(/"/g, '\\"') + '"';
|
||
return '[' + content + '](' + href + title + ')'
|
||
}
|
||
};
|
||
|
||
rules.referenceLink = {
|
||
filter: function (node, options) {
|
||
return (
|
||
options.linkStyle === 'referenced' &&
|
||
node.nodeName === 'A' &&
|
||
node.getAttribute('href')
|
||
)
|
||
},
|
||
|
||
replacement: function (content, node, options) {
|
||
var href = node.getAttribute('href');
|
||
var title = cleanAttribute(node.getAttribute('title'));
|
||
if (title) title = ' "' + title + '"';
|
||
var replacement;
|
||
var reference;
|
||
|
||
switch (options.linkReferenceStyle) {
|
||
case 'collapsed':
|
||
replacement = '[' + content + '][]';
|
||
reference = '[' + content + ']: ' + href + title;
|
||
break
|
||
case 'shortcut':
|
||
replacement = '[' + content + ']';
|
||
reference = '[' + content + ']: ' + href + title;
|
||
break
|
||
default:
|
||
var id = this.references.length + 1;
|
||
replacement = '[' + content + '][' + id + ']';
|
||
reference = '[' + id + ']: ' + href + title;
|
||
}
|
||
|
||
this.references.push(reference);
|
||
return replacement
|
||
},
|
||
|
||
references: [],
|
||
|
||
append: function (options) {
|
||
var references = '';
|
||
if (this.references.length) {
|
||
references = '\n\n' + this.references.join('\n') + '\n\n';
|
||
this.references = []; // Reset references
|
||
}
|
||
return references
|
||
}
|
||
};
|
||
|
||
rules.emphasis = {
|
||
filter: ['em', 'i'],
|
||
|
||
replacement: function (content, node, options) {
|
||
if (!content.trim()) return ''
|
||
return options.emDelimiter + content + options.emDelimiter
|
||
}
|
||
};
|
||
|
||
rules.strong = {
|
||
filter: ['strong', 'b'],
|
||
|
||
replacement: function (content, node, options) {
|
||
if (!content.trim()) return ''
|
||
return options.strongDelimiter + content + options.strongDelimiter
|
||
}
|
||
};
|
||
|
||
rules.code = {
|
||
filter: function (node) {
|
||
var hasSiblings = node.previousSibling || node.nextSibling;
|
||
var isCodeBlock = node.parentNode.nodeName === 'PRE' && !hasSiblings;
|
||
|
||
return node.nodeName === 'CODE' && !isCodeBlock
|
||
},
|
||
|
||
replacement: function (content) {
|
||
if (!content) return ''
|
||
content = content.replace(/\r?\n|\r/g, ' ');
|
||
|
||
var extraSpace = /^`|^ .*?[^ ].* $|`$/.test(content) ? ' ' : '';
|
||
var delimiter = '`';
|
||
var matches = content.match(/`+/gm) || [];
|
||
while (matches.indexOf(delimiter) !== -1) delimiter = delimiter + '`';
|
||
|
||
return delimiter + extraSpace + content + extraSpace + delimiter
|
||
}
|
||
};
|
||
|
||
rules.image = {
|
||
filter: 'img',
|
||
|
||
replacement: function (content, node) {
|
||
var alt = cleanAttribute(node.getAttribute('alt'));
|
||
var src = node.getAttribute('src') || '';
|
||
var title = cleanAttribute(node.getAttribute('title'));
|
||
var titlePart = title ? ' "' + title + '"' : '';
|
||
return src ? '![' + alt + ']' + '(' + src + titlePart + ')' : ''
|
||
}
|
||
};
|
||
|
||
function cleanAttribute (attribute) {
|
||
return attribute ? attribute.replace(/(\n+\s*)+/g, '\n') : ''
|
||
}
|
||
|
||
/**
|
||
* Manages a collection of rules used to convert HTML to Markdown
|
||
*/
|
||
|
||
function Rules (options) {
|
||
this.options = options;
|
||
this._keep = [];
|
||
this._remove = [];
|
||
|
||
this.blankRule = {
|
||
replacement: options.blankReplacement
|
||
};
|
||
|
||
this.keepReplacement = options.keepReplacement;
|
||
|
||
this.defaultRule = {
|
||
replacement: options.defaultReplacement
|
||
};
|
||
|
||
this.array = [];
|
||
for (var key in options.rules) this.array.push(options.rules[key]);
|
||
}
|
||
|
||
Rules.prototype = {
|
||
add: function (key, rule) {
|
||
this.array.unshift(rule);
|
||
},
|
||
|
||
keep: function (filter) {
|
||
this._keep.unshift({
|
||
filter: filter,
|
||
replacement: this.keepReplacement
|
||
});
|
||
},
|
||
|
||
remove: function (filter) {
|
||
this._remove.unshift({
|
||
filter: filter,
|
||
replacement: function () {
|
||
return ''
|
||
}
|
||
});
|
||
},
|
||
|
||
forNode: function (node) {
|
||
if (node.isBlank) return this.blankRule
|
||
var rule;
|
||
|
||
if ((rule = findRule(this.array, node, this.options))) return rule
|
||
if ((rule = findRule(this._keep, node, this.options))) return rule
|
||
if ((rule = findRule(this._remove, node, this.options))) return rule
|
||
|
||
return this.defaultRule
|
||
},
|
||
|
||
forEach: function (fn) {
|
||
for (var i = 0; i < this.array.length; i++) fn(this.array[i], i);
|
||
}
|
||
};
|
||
|
||
function findRule (rules, node, options) {
|
||
for (var i = 0; i < rules.length; i++) {
|
||
var rule = rules[i];
|
||
if (filterValue(rule, node, options)) return rule
|
||
}
|
||
return void 0
|
||
}
|
||
|
||
function filterValue (rule, node, options) {
|
||
var filter = rule.filter;
|
||
if (typeof filter === 'string') {
|
||
if (filter === node.nodeName.toLowerCase()) return true
|
||
} else if (Array.isArray(filter)) {
|
||
if (filter.indexOf(node.nodeName.toLowerCase()) > -1) return true
|
||
} else if (typeof filter === 'function') {
|
||
if (filter.call(rule, node, options)) return true
|
||
} else {
|
||
throw new TypeError('`filter` needs to be a string, array, or function')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The collapseWhitespace function is adapted from collapse-whitespace
|
||
* by Luc Thevenard.
|
||
*
|
||
* The MIT License (MIT)
|
||
*
|
||
* Copyright (c) 2014 Luc Thevenard <lucthevenard@gmail.com>
|
||
*
|
||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||
* of this software and associated documentation files (the "Software"), to deal
|
||
* in the Software without restriction, including without limitation the rights
|
||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||
* copies of the Software, and to permit persons to whom the Software is
|
||
* furnished to do so, subject to the following conditions:
|
||
*
|
||
* The above copyright notice and this permission notice shall be included in
|
||
* all copies or substantial portions of the Software.
|
||
*
|
||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||
* THE SOFTWARE.
|
||
*/
|
||
|
||
/**
|
||
* collapseWhitespace(options) removes extraneous whitespace from an the given element.
|
||
*
|
||
* @param {Object} options
|
||
*/
|
||
function collapseWhitespace (options) {
|
||
var element = options.element;
|
||
var isBlock = options.isBlock;
|
||
var isVoid = options.isVoid;
|
||
var isPre = options.isPre || function (node) {
|
||
return node.nodeName === 'PRE'
|
||
};
|
||
|
||
if (!element.firstChild || isPre(element)) return
|
||
|
||
var prevText = null;
|
||
var keepLeadingWs = false;
|
||
|
||
var prev = null;
|
||
var node = next(prev, element, isPre);
|
||
|
||
while (node !== element) {
|
||
if (node.nodeType === 3 || node.nodeType === 4) { // Node.TEXT_NODE or Node.CDATA_SECTION_NODE
|
||
var text = node.data.replace(/[ \r\n\t]+/g, ' ');
|
||
|
||
if ((!prevText || / $/.test(prevText.data)) &&
|
||
!keepLeadingWs && text[0] === ' ') {
|
||
text = text.substr(1);
|
||
}
|
||
|
||
// `text` might be empty at this point.
|
||
if (!text) {
|
||
node = remove(node);
|
||
continue
|
||
}
|
||
|
||
node.data = text;
|
||
|
||
prevText = node;
|
||
} else if (node.nodeType === 1) { // Node.ELEMENT_NODE
|
||
if (isBlock(node) || node.nodeName === 'BR') {
|
||
if (prevText) {
|
||
prevText.data = prevText.data.replace(/ $/, '');
|
||
}
|
||
|
||
prevText = null;
|
||
keepLeadingWs = false;
|
||
} else if (isVoid(node) || isPre(node)) {
|
||
// Avoid trimming space around non-block, non-BR void elements and inline PRE.
|
||
prevText = null;
|
||
keepLeadingWs = true;
|
||
} else if (prevText) {
|
||
// Drop protection if set previously.
|
||
keepLeadingWs = false;
|
||
}
|
||
} else {
|
||
node = remove(node);
|
||
continue
|
||
}
|
||
|
||
var nextNode = next(prev, node, isPre);
|
||
prev = node;
|
||
node = nextNode;
|
||
}
|
||
|
||
if (prevText) {
|
||
prevText.data = prevText.data.replace(/ $/, '');
|
||
if (!prevText.data) {
|
||
remove(prevText);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* remove(node) removes the given node from the DOM and returns the
|
||
* next node in the sequence.
|
||
*
|
||
* @param {Node} node
|
||
* @return {Node} node
|
||
*/
|
||
function remove (node) {
|
||
var next = node.nextSibling || node.parentNode;
|
||
|
||
node.parentNode.removeChild(node);
|
||
|
||
return next
|
||
}
|
||
|
||
/**
|
||
* next(prev, current, isPre) returns the next node in the sequence, given the
|
||
* current and previous nodes.
|
||
*
|
||
* @param {Node} prev
|
||
* @param {Node} current
|
||
* @param {Function} isPre
|
||
* @return {Node}
|
||
*/
|
||
function next (prev, current, isPre) {
|
||
if ((prev && prev.parentNode === current) || isPre(current)) {
|
||
return current.nextSibling || current.parentNode
|
||
}
|
||
|
||
return current.firstChild || current.nextSibling || current.parentNode
|
||
}
|
||
|
||
/*
|
||
* Set up window for Node.js
|
||
*/
|
||
|
||
var root = (typeof window !== 'undefined' ? window : {});
|
||
|
||
/*
|
||
* Parsing HTML strings
|
||
*/
|
||
|
||
function canParseHTMLNatively () {
|
||
var Parser = root.DOMParser;
|
||
var canParse = false;
|
||
|
||
// Adapted from https://gist.github.com/1129031
|
||
// Firefox/Opera/IE throw errors on unsupported types
|
||
try {
|
||
// WebKit returns null on unsupported types
|
||
if (new Parser().parseFromString('', 'text/html')) {
|
||
canParse = true;
|
||
}
|
||
} catch (e) {}
|
||
|
||
return canParse
|
||
}
|
||
|
||
function createHTMLParser () {
|
||
var Parser = function () {};
|
||
|
||
{
|
||
var domino = require('@mixmark-io/domino');
|
||
Parser.prototype.parseFromString = function (string) {
|
||
return domino.createDocument(string)
|
||
};
|
||
}
|
||
return Parser
|
||
}
|
||
|
||
var HTMLParser = canParseHTMLNatively() ? root.DOMParser : createHTMLParser();
|
||
|
||
function RootNode (input, options) {
|
||
var root;
|
||
if (typeof input === 'string') {
|
||
var doc = htmlParser().parseFromString(
|
||
// DOM parsers arrange elements in the <head> and <body>.
|
||
// Wrapping in a custom element ensures elements are reliably arranged in
|
||
// a single element.
|
||
'<x-turndown id="turndown-root">' + input + '</x-turndown>',
|
||
'text/html'
|
||
);
|
||
root = doc.getElementById('turndown-root');
|
||
} else {
|
||
root = input.cloneNode(true);
|
||
}
|
||
collapseWhitespace({
|
||
element: root,
|
||
isBlock: isBlock,
|
||
isVoid: isVoid,
|
||
isPre: options.preformattedCode ? isPreOrCode : null
|
||
});
|
||
|
||
return root
|
||
}
|
||
|
||
var _htmlParser;
|
||
function htmlParser () {
|
||
_htmlParser = _htmlParser || new HTMLParser();
|
||
return _htmlParser
|
||
}
|
||
|
||
function isPreOrCode (node) {
|
||
return node.nodeName === 'PRE' || node.nodeName === 'CODE'
|
||
}
|
||
|
||
function Node (node, options) {
|
||
node.isBlock = isBlock(node);
|
||
node.isCode = node.nodeName === 'CODE' || node.parentNode.isCode;
|
||
node.isBlank = isBlank(node);
|
||
node.flankingWhitespace = flankingWhitespace(node, options);
|
||
return node
|
||
}
|
||
|
||
function isBlank (node) {
|
||
return (
|
||
!isVoid(node) &&
|
||
!isMeaningfulWhenBlank(node) &&
|
||
/^\s*$/i.test(node.textContent) &&
|
||
!hasVoid(node) &&
|
||
!hasMeaningfulWhenBlank(node)
|
||
)
|
||
}
|
||
|
||
function flankingWhitespace (node, options) {
|
||
if (node.isBlock || (options.preformattedCode && node.isCode)) {
|
||
return { leading: '', trailing: '' }
|
||
}
|
||
|
||
var edges = edgeWhitespace(node.textContent);
|
||
|
||
// abandon leading ASCII WS if left-flanked by ASCII WS
|
||
if (edges.leadingAscii && isFlankedByWhitespace('left', node, options)) {
|
||
edges.leading = edges.leadingNonAscii;
|
||
}
|
||
|
||
// abandon trailing ASCII WS if right-flanked by ASCII WS
|
||
if (edges.trailingAscii && isFlankedByWhitespace('right', node, options)) {
|
||
edges.trailing = edges.trailingNonAscii;
|
||
}
|
||
|
||
return { leading: edges.leading, trailing: edges.trailing }
|
||
}
|
||
|
||
function edgeWhitespace (string) {
|
||
var m = string.match(/^(([ \t\r\n]*)(\s*))(?:(?=\S)[\s\S]*\S)?((\s*?)([ \t\r\n]*))$/);
|
||
return {
|
||
leading: m[1], // whole string for whitespace-only strings
|
||
leadingAscii: m[2],
|
||
leadingNonAscii: m[3],
|
||
trailing: m[4], // empty for whitespace-only strings
|
||
trailingNonAscii: m[5],
|
||
trailingAscii: m[6]
|
||
}
|
||
}
|
||
|
||
function isFlankedByWhitespace (side, node, options) {
|
||
var sibling;
|
||
var regExp;
|
||
var isFlanked;
|
||
|
||
if (side === 'left') {
|
||
sibling = node.previousSibling;
|
||
regExp = / $/;
|
||
} else {
|
||
sibling = node.nextSibling;
|
||
regExp = /^ /;
|
||
}
|
||
|
||
if (sibling) {
|
||
if (sibling.nodeType === 3) {
|
||
isFlanked = regExp.test(sibling.nodeValue);
|
||
} else if (options.preformattedCode && sibling.nodeName === 'CODE') {
|
||
isFlanked = false;
|
||
} else if (sibling.nodeType === 1 && !isBlock(sibling)) {
|
||
isFlanked = regExp.test(sibling.textContent);
|
||
}
|
||
}
|
||
return isFlanked
|
||
}
|
||
|
||
var reduce = Array.prototype.reduce;
|
||
var escapes = [
|
||
[/\\/g, '\\\\'],
|
||
[/\*/g, '\\*'],
|
||
[/^-/g, '\\-'],
|
||
[/^\+ /g, '\\+ '],
|
||
[/^(=+)/g, '\\$1'],
|
||
[/^(#{1,6}) /g, '\\$1 '],
|
||
[/`/g, '\\`'],
|
||
[/^~~~/g, '\\~~~'],
|
||
[/\[/g, '\\['],
|
||
[/\]/g, '\\]'],
|
||
[/^>/g, '\\>'],
|
||
[/_/g, '\\_'],
|
||
[/^(\d+)\. /g, '$1\\. ']
|
||
];
|
||
|
||
function TurndownService (options) {
|
||
if (!(this instanceof TurndownService)) return new TurndownService(options)
|
||
|
||
var defaults = {
|
||
rules: rules,
|
||
headingStyle: 'setext',
|
||
hr: '* * *',
|
||
bulletListMarker: '*',
|
||
codeBlockStyle: 'indented',
|
||
fence: '```',
|
||
emDelimiter: '_',
|
||
strongDelimiter: '**',
|
||
linkStyle: 'inlined',
|
||
linkReferenceStyle: 'full',
|
||
br: ' ',
|
||
preformattedCode: false,
|
||
blankReplacement: function (content, node) {
|
||
return node.isBlock ? '\n\n' : ''
|
||
},
|
||
keepReplacement: function (content, node) {
|
||
return node.isBlock ? '\n\n' + node.outerHTML + '\n\n' : node.outerHTML
|
||
},
|
||
defaultReplacement: function (content, node) {
|
||
return node.isBlock ? '\n\n' + content + '\n\n' : content
|
||
}
|
||
};
|
||
this.options = extend({}, defaults, options);
|
||
this.rules = new Rules(this.options);
|
||
}
|
||
|
||
TurndownService.prototype = {
|
||
/**
|
||
* The entry point for converting a string or DOM node to Markdown
|
||
* @public
|
||
* @param {String|HTMLElement} input The string or DOM node to convert
|
||
* @returns A Markdown representation of the input
|
||
* @type String
|
||
*/
|
||
|
||
turndown: function (input) {
|
||
if (!canConvert(input)) {
|
||
throw new TypeError(
|
||
input + ' is not a string, or an element/document/fragment node.'
|
||
)
|
||
}
|
||
|
||
if (input === '') return ''
|
||
|
||
var output = process.call(this, new RootNode(input, this.options));
|
||
return postProcess.call(this, output)
|
||
},
|
||
|
||
/**
|
||
* Add one or more plugins
|
||
* @public
|
||
* @param {Function|Array} plugin The plugin or array of plugins to add
|
||
* @returns The Turndown instance for chaining
|
||
* @type Object
|
||
*/
|
||
|
||
use: function (plugin) {
|
||
if (Array.isArray(plugin)) {
|
||
for (var i = 0; i < plugin.length; i++) this.use(plugin[i]);
|
||
} else if (typeof plugin === 'function') {
|
||
plugin(this);
|
||
} else {
|
||
throw new TypeError('plugin must be a Function or an Array of Functions')
|
||
}
|
||
return this
|
||
},
|
||
|
||
/**
|
||
* Adds a rule
|
||
* @public
|
||
* @param {String} key The unique key of the rule
|
||
* @param {Object} rule The rule
|
||
* @returns The Turndown instance for chaining
|
||
* @type Object
|
||
*/
|
||
|
||
addRule: function (key, rule) {
|
||
this.rules.add(key, rule);
|
||
return this
|
||
},
|
||
|
||
/**
|
||
* Keep a node (as HTML) that matches the filter
|
||
* @public
|
||
* @param {String|Array|Function} filter The unique key of the rule
|
||
* @returns The Turndown instance for chaining
|
||
* @type Object
|
||
*/
|
||
|
||
keep: function (filter) {
|
||
this.rules.keep(filter);
|
||
return this
|
||
},
|
||
|
||
/**
|
||
* Remove a node that matches the filter
|
||
* @public
|
||
* @param {String|Array|Function} filter The unique key of the rule
|
||
* @returns The Turndown instance for chaining
|
||
* @type Object
|
||
*/
|
||
|
||
remove: function (filter) {
|
||
this.rules.remove(filter);
|
||
return this
|
||
},
|
||
|
||
/**
|
||
* Escapes Markdown syntax
|
||
* @public
|
||
* @param {String} string The string to escape
|
||
* @returns A string with Markdown syntax escaped
|
||
* @type String
|
||
*/
|
||
|
||
escape: function (string) {
|
||
return escapes.reduce(function (accumulator, escape) {
|
||
return accumulator.replace(escape[0], escape[1])
|
||
}, string)
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Reduces a DOM node down to its Markdown string equivalent
|
||
* @private
|
||
* @param {HTMLElement} parentNode The node to convert
|
||
* @returns A Markdown representation of the node
|
||
* @type String
|
||
*/
|
||
|
||
function process (parentNode) {
|
||
var self = this;
|
||
return reduce.call(parentNode.childNodes, function (output, node) {
|
||
node = new Node(node, self.options);
|
||
|
||
var replacement = '';
|
||
if (node.nodeType === 3) {
|
||
replacement = node.isCode ? node.nodeValue : self.escape(node.nodeValue);
|
||
} else if (node.nodeType === 1) {
|
||
replacement = replacementForNode.call(self, node);
|
||
}
|
||
|
||
return join(output, replacement)
|
||
}, '')
|
||
}
|
||
|
||
/**
|
||
* Appends strings as each rule requires and trims the output
|
||
* @private
|
||
* @param {String} output The conversion output
|
||
* @returns A trimmed version of the ouput
|
||
* @type String
|
||
*/
|
||
|
||
function postProcess (output) {
|
||
var self = this;
|
||
this.rules.forEach(function (rule) {
|
||
if (typeof rule.append === 'function') {
|
||
output = join(output, rule.append(self.options));
|
||
}
|
||
});
|
||
|
||
return output.replace(/^[\t\r\n]+/, '').replace(/[\t\r\n\s]+$/, '')
|
||
}
|
||
|
||
/**
|
||
* Converts an element node to its Markdown equivalent
|
||
* @private
|
||
* @param {HTMLElement} node The node to convert
|
||
* @returns A Markdown representation of the node
|
||
* @type String
|
||
*/
|
||
|
||
function replacementForNode (node) {
|
||
var rule = this.rules.forNode(node);
|
||
var content = process.call(this, node);
|
||
var whitespace = node.flankingWhitespace;
|
||
if (whitespace.leading || whitespace.trailing) content = content.trim();
|
||
return (
|
||
whitespace.leading +
|
||
rule.replacement(content, node, this.options) +
|
||
whitespace.trailing
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Joins replacement to the current output with appropriate number of new lines
|
||
* @private
|
||
* @param {String} output The current conversion output
|
||
* @param {String} replacement The string to append to the output
|
||
* @returns Joined output
|
||
* @type String
|
||
*/
|
||
|
||
function join (output, replacement) {
|
||
var s1 = trimTrailingNewlines(output);
|
||
var s2 = trimLeadingNewlines(replacement);
|
||
var nls = Math.max(output.length - s1.length, replacement.length - s2.length);
|
||
var separator = '\n\n'.substring(0, nls);
|
||
|
||
return s1 + separator + s2
|
||
}
|
||
|
||
/**
|
||
* Determines whether an input can be converted
|
||
* @private
|
||
* @param {String|HTMLElement} input Describe this parameter
|
||
* @returns Describe what it returns
|
||
* @type String|Object|Array|Boolean|Number
|
||
*/
|
||
|
||
function canConvert (input) {
|
||
return (
|
||
input != null && (
|
||
typeof input === 'string' ||
|
||
(input.nodeType && (
|
||
input.nodeType === 1 || input.nodeType === 9 || input.nodeType === 11
|
||
))
|
||
)
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Markdown conversion utilities using Marked and Turndown
|
||
*/
|
||
|
||
/**
|
||
* MarkdownConverter - Handles bidirectional HTML ↔ Markdown conversion
|
||
*/
|
||
class MarkdownConverter {
|
||
constructor() {
|
||
this.initializeMarked();
|
||
this.initializeTurndown();
|
||
}
|
||
|
||
/**
|
||
* Configure marked for HTML output - MINIMAL MODE
|
||
* Only supports: **bold**, *italic*, and [links](url)
|
||
* Matches server-side goldmark configuration
|
||
*/
|
||
initializeMarked() {
|
||
d.setOptions({
|
||
gfm: false, // Disable GFM to match server minimal mode
|
||
breaks: true, // Convert \n to <br> (matches server)
|
||
pedantic: false, // Don't be overly strict
|
||
sanitize: false, // Allow HTML (we control the input)
|
||
smartLists: false, // Disable lists (not supported on server)
|
||
smartypants: false // Don't convert quotes/dashes
|
||
});
|
||
|
||
// Override renderers to restrict to minimal feature set
|
||
d.use({
|
||
renderer: {
|
||
// Disable headings - treat as plain text
|
||
heading(text, level) {
|
||
return text;
|
||
},
|
||
// Disable lists - treat as plain text
|
||
list(body, ordered, start) {
|
||
return body.replace(/<\/?li>/g, '');
|
||
},
|
||
listitem(text) {
|
||
return text + '\n';
|
||
},
|
||
// Disable code blocks - treat as plain text
|
||
code(code, language) {
|
||
return code;
|
||
},
|
||
blockquote(quote) {
|
||
return quote; // Disable blockquotes - treat as plain text
|
||
},
|
||
// Disable horizontal rules
|
||
hr() {
|
||
return '';
|
||
},
|
||
// Disable tables
|
||
table(header, body) {
|
||
return header + body;
|
||
},
|
||
tablecell(content, flags) {
|
||
return content;
|
||
},
|
||
tablerow(content) {
|
||
return content;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Configure turndown for markdown output - MINIMAL MODE
|
||
* Only supports: **bold**, *italic*, and [links](url)
|
||
* Matches server-side goldmark configuration
|
||
*/
|
||
initializeTurndown() {
|
||
this.turndown = new TurndownService({
|
||
// Minimal configuration - only basic formatting
|
||
headingStyle: 'atx', // # headers (but will be disabled)
|
||
hr: '---', // horizontal rule (but will be disabled)
|
||
bulletListMarker: '-', // bullet list (but will be disabled)
|
||
codeBlockStyle: 'fenced', // code blocks (but will be disabled)
|
||
fence: '```', // fence marker (but will be disabled)
|
||
emDelimiter: '*', // *italic* - matches server
|
||
strongDelimiter: '**', // **bold** - matches server
|
||
linkStyle: 'inlined', // [text](url) - matches server
|
||
linkReferenceStyle: 'full' // full reference links
|
||
});
|
||
|
||
// Add custom rules for better conversion
|
||
this.addTurndownRules();
|
||
}
|
||
|
||
/**
|
||
* Add custom turndown rules - MINIMAL MODE
|
||
* Only supports: **bold**, *italic*, and [links](url)
|
||
* Disables all other formatting to match server
|
||
*/
|
||
addTurndownRules() {
|
||
// Handle paragraph spacing properly - ensure double newlines between paragraphs
|
||
this.turndown.addRule('paragraph', {
|
||
filter: 'p',
|
||
replacement: function (content) {
|
||
if (!content.trim()) return '';
|
||
return content.trim() + '\n\n';
|
||
}
|
||
});
|
||
|
||
// Handle bold text in markdown - keep this (supported)
|
||
this.turndown.addRule('bold', {
|
||
filter: ['strong', 'b'],
|
||
replacement: function (content) {
|
||
if (!content.trim()) return '';
|
||
return '**' + content + '**';
|
||
}
|
||
});
|
||
|
||
// Handle italic text in markdown - keep this (supported)
|
||
this.turndown.addRule('italic', {
|
||
filter: ['em', 'i'],
|
||
replacement: function (content) {
|
||
if (!content.trim()) return '';
|
||
return '*' + content + '*';
|
||
}
|
||
});
|
||
|
||
// DISABLE unsupported features - convert to plain text
|
||
this.turndown.addRule('disableHeadings', {
|
||
filter: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
|
||
replacement: function (content) {
|
||
return content; // Just return text content, no # markup
|
||
}
|
||
});
|
||
|
||
this.turndown.addRule('disableLists', {
|
||
filter: ['ul', 'ol', 'li'],
|
||
replacement: function (content) {
|
||
return content; // Just return text content, no list markup
|
||
}
|
||
});
|
||
|
||
this.turndown.addRule('disableCode', {
|
||
filter: ['pre', 'code'],
|
||
replacement: function (content) {
|
||
return content; // Just return text content, no code markup
|
||
}
|
||
});
|
||
|
||
this.turndown.addRule('disableBlockquotes', {
|
||
filter: 'blockquote',
|
||
replacement: function (content) {
|
||
return content; // Just return text content, no > markup
|
||
}
|
||
});
|
||
|
||
this.turndown.addRule('disableHR', {
|
||
filter: 'hr',
|
||
replacement: function () {
|
||
return ''; // Remove horizontal rules entirely
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Convert HTML to Markdown
|
||
* @param {string} html - HTML string to convert
|
||
* @returns {string} - Markdown string
|
||
*/
|
||
htmlToMarkdown(html) {
|
||
if (!html || html.trim() === '') {
|
||
return '';
|
||
}
|
||
|
||
try {
|
||
const markdown = this.turndown.turndown(html);
|
||
// Clean up and normalize newlines for proper paragraph separation
|
||
return markdown
|
||
.replace(/\n{3,}/g, '\n\n') // Replace 3+ newlines with 2
|
||
.replace(/^\n+|\n+$/g, '') // Remove leading/trailing newlines
|
||
.trim(); // Remove other whitespace
|
||
} catch (error) {
|
||
console.warn('HTML to Markdown conversion failed:', error);
|
||
// Fallback: extract text content
|
||
const tempDiv = document.createElement('div');
|
||
tempDiv.innerHTML = html;
|
||
return tempDiv.textContent || tempDiv.innerText || '';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Convert Markdown to HTML
|
||
* @param {string} markdown - Markdown string to convert
|
||
* @returns {string} - HTML string
|
||
*/
|
||
markdownToHtml(markdown) {
|
||
if (!markdown || markdown.trim() === '') {
|
||
return '';
|
||
}
|
||
|
||
try {
|
||
const html = d(markdown);
|
||
return html;
|
||
} catch (error) {
|
||
console.warn('Markdown to HTML conversion failed:', error);
|
||
// Fallback: convert line breaks to paragraphs
|
||
return markdown
|
||
.split(/\n\s*\n/)
|
||
.filter(p => p.trim())
|
||
.map(p => `<p>${p.trim()}</p>`)
|
||
.join('');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Extract HTML content from a group of elements
|
||
* @param {HTMLElement[]} elements - Array of DOM elements
|
||
* @returns {string} - Combined HTML content
|
||
*/
|
||
extractGroupHTML(elements) {
|
||
const htmlParts = [];
|
||
|
||
elements.forEach(element => {
|
||
// Wrap inner content in paragraph tags to preserve structure
|
||
const html = element.innerHTML.trim();
|
||
if (html) {
|
||
// If element is already a paragraph, use its outer HTML
|
||
if (element.tagName.toLowerCase() === 'p') {
|
||
htmlParts.push(element.outerHTML);
|
||
} else {
|
||
// Wrap in paragraph tags
|
||
htmlParts.push(`<p>${html}</p>`);
|
||
}
|
||
}
|
||
});
|
||
|
||
return htmlParts.join('\n');
|
||
}
|
||
|
||
/**
|
||
* Convert HTML content from group elements to markdown
|
||
* @param {HTMLElement[]} elements - Array of DOM elements
|
||
* @returns {string} - Markdown representation
|
||
*/
|
||
extractGroupMarkdown(elements) {
|
||
const html = this.extractGroupHTML(elements);
|
||
const markdown = this.htmlToMarkdown(html);
|
||
return markdown;
|
||
}
|
||
|
||
/**
|
||
* Update group elements with markdown content
|
||
* @param {HTMLElement[]} elements - Array of DOM elements to update
|
||
* @param {string} markdown - Markdown content to render
|
||
*/
|
||
updateGroupElements(elements, markdown) {
|
||
const html = this.markdownToHtml(markdown);
|
||
|
||
// Split HTML into paragraphs
|
||
const tempDiv = document.createElement('div');
|
||
tempDiv.innerHTML = html;
|
||
|
||
const paragraphs = Array.from(tempDiv.querySelectorAll('p, div, h1, h2, h3, h4, h5, h6'));
|
||
|
||
// Handle case where we have more/fewer paragraphs than elements
|
||
const maxCount = Math.max(elements.length, paragraphs.length);
|
||
|
||
for (let i = 0; i < maxCount; i++) {
|
||
if (i < elements.length && i < paragraphs.length) {
|
||
// Update existing element with corresponding paragraph
|
||
elements[i].innerHTML = paragraphs[i].innerHTML;
|
||
} else if (i < elements.length) {
|
||
// More elements than paragraphs - clear extra elements
|
||
elements[i].innerHTML = '';
|
||
} else if (i < paragraphs.length) {
|
||
// More paragraphs than elements - create new element
|
||
const newElement = document.createElement('p');
|
||
newElement.innerHTML = paragraphs[i].innerHTML;
|
||
|
||
// Insert after the last existing element
|
||
const lastElement = elements[elements.length - 1];
|
||
lastElement.parentNode.insertBefore(newElement, lastElement.nextSibling);
|
||
elements.push(newElement); // Add to our elements array for future updates
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Export singleton instance
|
||
const markdownConverter = new MarkdownConverter();
|
||
|
||
/**
|
||
* Previewer - Handles live preview for all content types
|
||
*/
|
||
|
||
class Previewer {
|
||
constructor() {
|
||
this.previewTimeout = null;
|
||
this.activeContext = null;
|
||
this.resizeObserver = null;
|
||
this.onHeightChange = null;
|
||
}
|
||
|
||
/**
|
||
* Set the active editing context for preview
|
||
*/
|
||
setActiveContext(context) {
|
||
this.clearPreview();
|
||
this.activeContext = context;
|
||
this.startResizeObserver();
|
||
}
|
||
|
||
/**
|
||
* Schedule a preview update with debouncing
|
||
*/
|
||
schedulePreview(context, content) {
|
||
// Clear existing timeout
|
||
if (this.previewTimeout) {
|
||
clearTimeout(this.previewTimeout);
|
||
}
|
||
|
||
// Schedule new preview with 500ms debounce
|
||
this.previewTimeout = setTimeout(() => {
|
||
this.updatePreview(context, content);
|
||
}, 500);
|
||
}
|
||
|
||
/**
|
||
* Update preview with new content
|
||
*/
|
||
updatePreview(context, content) {
|
||
// Store original content if first preview
|
||
if (!context.originalContent) {
|
||
context.storeOriginalContent();
|
||
}
|
||
|
||
// Apply preview content to elements
|
||
this.applyPreviewContent(context, content);
|
||
context.applyPreviewStyling();
|
||
}
|
||
|
||
/**
|
||
* Apply preview content to context elements
|
||
*/
|
||
applyPreviewContent(context, content) {
|
||
if (context.elements.length === 1) {
|
||
const element = context.elements[0];
|
||
|
||
// Handle links specially
|
||
if (element.tagName.toLowerCase() === 'a') {
|
||
if (typeof content === 'object') {
|
||
// Update link text (markdown to HTML)
|
||
if (content.text !== undefined) {
|
||
const html = markdownConverter.markdownToHtml(content.text);
|
||
element.innerHTML = html;
|
||
}
|
||
// Update link URL
|
||
if (content.url !== undefined && content.url.trim()) {
|
||
element.href = content.url;
|
||
}
|
||
} else if (content && content.trim()) {
|
||
// Just markdown content for link text
|
||
const html = markdownConverter.markdownToHtml(content);
|
||
element.innerHTML = html;
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Regular single element
|
||
if (content && content.trim()) {
|
||
const html = markdownConverter.markdownToHtml(content);
|
||
element.innerHTML = html;
|
||
}
|
||
} else {
|
||
// Multiple elements - use group update
|
||
if (content && content.trim()) {
|
||
markdownConverter.updateGroupElements(context.elements, content);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Clear all preview state and restore original content
|
||
*/
|
||
clearPreview() {
|
||
if (this.activeContext) {
|
||
this.activeContext.restoreOriginalContent();
|
||
this.activeContext.removePreviewStyling();
|
||
this.activeContext = null;
|
||
}
|
||
|
||
if (this.previewTimeout) {
|
||
clearTimeout(this.previewTimeout);
|
||
this.previewTimeout = null;
|
||
}
|
||
|
||
this.stopResizeObserver();
|
||
}
|
||
|
||
/**
|
||
* Start observing element size changes for modal repositioning
|
||
*/
|
||
startResizeObserver() {
|
||
this.stopResizeObserver();
|
||
|
||
if (this.activeContext) {
|
||
this.resizeObserver = new ResizeObserver(() => {
|
||
// Handle height changes for modal repositioning
|
||
if (this.onHeightChange) {
|
||
this.onHeightChange(this.activeContext.primaryElement);
|
||
}
|
||
});
|
||
|
||
// Observe all elements in the context
|
||
this.activeContext.elements.forEach(el => {
|
||
this.resizeObserver.observe(el);
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Stop observing element size changes
|
||
*/
|
||
stopResizeObserver() {
|
||
if (this.resizeObserver) {
|
||
this.resizeObserver.disconnect();
|
||
this.resizeObserver = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Set callback for height changes (for modal repositioning)
|
||
*/
|
||
setHeightChangeCallback(callback) {
|
||
this.onHeightChange = callback;
|
||
}
|
||
|
||
/**
|
||
* Get unique element ID for tracking
|
||
*/
|
||
getElementId(element) {
|
||
if (!element._insertrId) {
|
||
element._insertrId = 'insertr_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||
}
|
||
return element._insertrId;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Editor - Handles all content types with markdown-first approach
|
||
*/
|
||
|
||
class Editor {
|
||
constructor() {
|
||
this.currentOverlay = null;
|
||
this.previewer = new Previewer();
|
||
}
|
||
|
||
/**
|
||
* Edit any content element with markdown interface
|
||
* @param {Object} meta - Element metadata {element, contentId, contentType}
|
||
* @param {string|Object} currentContent - Current content value
|
||
* @param {Function} onSave - Save callback
|
||
* @param {Function} onCancel - Cancel callback
|
||
*/
|
||
edit(meta, currentContent, onSave, onCancel) {
|
||
const { element } = meta;
|
||
|
||
// Handle both single elements and groups uniformly
|
||
const elements = Array.isArray(element) ? element : [element];
|
||
const context = new EditContext(elements, currentContent);
|
||
|
||
// Close any existing editor
|
||
this.close();
|
||
|
||
// Create editor form
|
||
const form = this.createForm(context, meta);
|
||
const overlay = this.createOverlay(form);
|
||
|
||
// Position relative to primary element
|
||
this.positionForm(context.primaryElement, overlay);
|
||
|
||
// Setup event handlers
|
||
this.setupEventHandlers(form, overlay, context, { onSave, onCancel });
|
||
|
||
// Show editor
|
||
document.body.appendChild(overlay);
|
||
this.currentOverlay = overlay;
|
||
|
||
// Focus textarea
|
||
const textarea = form.querySelector('textarea');
|
||
if (textarea) {
|
||
setTimeout(() => textarea.focus(), 100);
|
||
}
|
||
|
||
return overlay;
|
||
}
|
||
|
||
/**
|
||
* Create editing form for any content type
|
||
*/
|
||
createForm(context, meta) {
|
||
const config = this.getFieldConfig(context);
|
||
const currentContent = context.extractContent();
|
||
|
||
const form = document.createElement('div');
|
||
form.className = 'insertr-edit-form';
|
||
|
||
// Build form HTML
|
||
let formHTML = `<div class="insertr-form-header">${config.label}</div>`;
|
||
|
||
// Markdown textarea (always present)
|
||
formHTML += this.createMarkdownField(config, currentContent);
|
||
|
||
// URL field (for links only)
|
||
if (config.includeUrl) {
|
||
formHTML += this.createUrlField(currentContent);
|
||
}
|
||
|
||
// Form actions
|
||
formHTML += `
|
||
<div class="insertr-form-actions">
|
||
<button type="button" class="insertr-btn-save">Save</button>
|
||
<button type="button" class="insertr-btn-cancel">Cancel</button>
|
||
<button type="button" class="insertr-btn-history" data-content-id="${meta.contentId}">View History</button>
|
||
</div>
|
||
`;
|
||
|
||
form.innerHTML = formHTML;
|
||
return form;
|
||
}
|
||
|
||
/**
|
||
* Get field configuration for any element type (markdown-first)
|
||
*/
|
||
getFieldConfig(context) {
|
||
const elementCount = context.elements.length;
|
||
const primaryElement = context.primaryElement;
|
||
const isLink = primaryElement.tagName.toLowerCase() === 'a';
|
||
|
||
// Multi-element groups
|
||
if (elementCount > 1) {
|
||
return {
|
||
type: 'markdown',
|
||
includeUrl: false,
|
||
label: `Group Content (${elementCount} elements)`,
|
||
rows: Math.max(8, elementCount * 2),
|
||
placeholder: 'Edit all content together using markdown...'
|
||
};
|
||
}
|
||
|
||
// Single elements - all get markdown by default
|
||
const tag = primaryElement.tagName.toLowerCase();
|
||
const baseConfig = {
|
||
type: 'markdown',
|
||
includeUrl: isLink,
|
||
placeholder: 'Enter content using markdown...'
|
||
};
|
||
|
||
// Customize by element type
|
||
switch (tag) {
|
||
case 'h1':
|
||
return { ...baseConfig, label: 'Main Headline', rows: 1, placeholder: 'Enter main headline...' };
|
||
case 'h2':
|
||
return { ...baseConfig, label: 'Subheading', rows: 1, placeholder: 'Enter subheading...' };
|
||
case 'h3': case 'h4': case 'h5': case 'h6':
|
||
return { ...baseConfig, label: 'Heading', rows: 2, placeholder: 'Enter heading (markdown supported)...' };
|
||
case 'p':
|
||
return { ...baseConfig, label: 'Content', rows: 4, placeholder: 'Enter content using markdown...' };
|
||
case 'span':
|
||
return { ...baseConfig, label: 'Text', rows: 2, placeholder: 'Enter text (markdown supported)...' };
|
||
case 'button':
|
||
return { ...baseConfig, label: 'Button Text', rows: 1, placeholder: 'Enter button text...' };
|
||
case 'a':
|
||
return { ...baseConfig, label: 'Link', rows: 2, placeholder: 'Enter link text (markdown supported)...' };
|
||
default:
|
||
return { ...baseConfig, label: 'Content', rows: 3, placeholder: 'Enter content using markdown...' };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Create markdown textarea field
|
||
*/
|
||
createMarkdownField(config, content) {
|
||
const textContent = typeof content === 'object' ? content.text || '' : content;
|
||
|
||
return `
|
||
<div class="insertr-form-group">
|
||
<textarea class="insertr-form-textarea insertr-markdown-editor" name="content"
|
||
rows="${config.rows}"
|
||
placeholder="${config.placeholder}">${this.escapeHtml(textContent)}</textarea>
|
||
<div class="insertr-form-help">
|
||
Supports Markdown formatting (bold, italic, links, etc.)
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* Create URL field for links
|
||
*/
|
||
createUrlField(content) {
|
||
const url = typeof content === 'object' ? content.url || '' : '';
|
||
|
||
return `
|
||
<div class="insertr-form-group">
|
||
<label class="insertr-form-label">Link URL:</label>
|
||
<input type="url" class="insertr-form-input" name="url"
|
||
value="${this.escapeHtml(url)}"
|
||
placeholder="https://example.com">
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* Setup event handlers
|
||
*/
|
||
setupEventHandlers(form, overlay, context, { onSave, onCancel }) {
|
||
const textarea = form.querySelector('textarea');
|
||
const urlInput = form.querySelector('input[name="url"]');
|
||
const saveBtn = form.querySelector('.insertr-btn-save');
|
||
const cancelBtn = form.querySelector('.insertr-btn-cancel');
|
||
const historyBtn = form.querySelector('.insertr-btn-history');
|
||
|
||
// Initialize previewer
|
||
this.previewer.setActiveContext(context);
|
||
|
||
// Setup live preview for content changes
|
||
if (textarea) {
|
||
textarea.addEventListener('input', () => {
|
||
const content = this.extractFormData(form);
|
||
this.previewer.schedulePreview(context, content);
|
||
});
|
||
}
|
||
|
||
// Setup live preview for URL changes (links only)
|
||
if (urlInput) {
|
||
urlInput.addEventListener('input', () => {
|
||
const content = this.extractFormData(form);
|
||
this.previewer.schedulePreview(context, content);
|
||
});
|
||
}
|
||
|
||
// Save handler
|
||
if (saveBtn) {
|
||
saveBtn.addEventListener('click', () => {
|
||
const content = this.extractFormData(form);
|
||
|
||
// Apply final content to elements
|
||
context.applyContent(content);
|
||
|
||
// Update stored original content to match current state
|
||
// This makes the saved content the new baseline for future edits
|
||
context.updateOriginalContent();
|
||
|
||
// Clear preview styling (won't restore content since original matches current)
|
||
this.previewer.clearPreview();
|
||
|
||
// Callback with the content
|
||
onSave(content);
|
||
this.close();
|
||
});
|
||
}
|
||
|
||
// Cancel handler
|
||
if (cancelBtn) {
|
||
cancelBtn.addEventListener('click', () => {
|
||
this.previewer.clearPreview();
|
||
onCancel();
|
||
this.close();
|
||
});
|
||
}
|
||
|
||
// History handler
|
||
if (historyBtn) {
|
||
historyBtn.addEventListener('click', () => {
|
||
const contentId = historyBtn.getAttribute('data-content-id');
|
||
console.log('Version history not implemented yet for:', contentId);
|
||
// TODO: Implement version history integration
|
||
});
|
||
}
|
||
|
||
// ESC key handler
|
||
const keyHandler = (e) => {
|
||
if (e.key === 'Escape') {
|
||
this.previewer.clearPreview();
|
||
onCancel();
|
||
this.close();
|
||
document.removeEventListener('keydown', keyHandler);
|
||
}
|
||
};
|
||
document.addEventListener('keydown', keyHandler);
|
||
|
||
// Click outside handler
|
||
overlay.addEventListener('click', (e) => {
|
||
if (e.target === overlay) {
|
||
this.previewer.clearPreview();
|
||
onCancel();
|
||
this.close();
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Extract form data consistently
|
||
*/
|
||
extractFormData(form) {
|
||
const textarea = form.querySelector('textarea[name="content"]');
|
||
const urlInput = form.querySelector('input[name="url"]');
|
||
|
||
const content = textarea ? textarea.value : '';
|
||
|
||
if (urlInput) {
|
||
// Link content
|
||
return {
|
||
text: content,
|
||
url: urlInput.value
|
||
};
|
||
}
|
||
|
||
// Regular content
|
||
return content;
|
||
}
|
||
|
||
/**
|
||
* Create overlay with backdrop
|
||
*/
|
||
createOverlay(form) {
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'insertr-form-overlay';
|
||
overlay.appendChild(form);
|
||
return overlay;
|
||
}
|
||
|
||
/**
|
||
* Position form relative to primary element
|
||
*/
|
||
positionForm(element, overlay) {
|
||
const rect = element.getBoundingClientRect();
|
||
const form = overlay.querySelector('.insertr-edit-form');
|
||
const viewportWidth = window.innerWidth;
|
||
|
||
// Calculate optimal width
|
||
let formWidth;
|
||
if (viewportWidth < 768) {
|
||
formWidth = Math.min(viewportWidth - 40, 500);
|
||
} else {
|
||
const minComfortableWidth = 600;
|
||
const maxWidth = Math.min(viewportWidth * 0.9, 800);
|
||
formWidth = Math.max(minComfortableWidth, Math.min(rect.width * 1.5, maxWidth));
|
||
}
|
||
|
||
form.style.width = `${formWidth}px`;
|
||
|
||
// Position below element
|
||
const top = rect.bottom + window.scrollY + 10;
|
||
const centerLeft = rect.left + window.scrollX + (rect.width / 2) - (formWidth / 2);
|
||
const minLeft = 20;
|
||
const maxLeft = window.innerWidth - formWidth - 20;
|
||
const left = Math.max(minLeft, Math.min(centerLeft, maxLeft));
|
||
|
||
overlay.style.position = 'absolute';
|
||
overlay.style.top = `${top}px`;
|
||
overlay.style.left = `${left}px`;
|
||
overlay.style.zIndex = '10000';
|
||
|
||
// Ensure visibility
|
||
this.ensureModalVisible(overlay);
|
||
}
|
||
|
||
/**
|
||
* Ensure modal is visible by scrolling if needed
|
||
*/
|
||
ensureModalVisible(overlay) {
|
||
requestAnimationFrame(() => {
|
||
const modal = overlay.querySelector('.insertr-edit-form');
|
||
const modalRect = modal.getBoundingClientRect();
|
||
const viewportHeight = window.innerHeight;
|
||
|
||
if (modalRect.bottom > viewportHeight) {
|
||
const scrollAmount = modalRect.bottom - viewportHeight + 20;
|
||
window.scrollBy({
|
||
top: scrollAmount,
|
||
behavior: 'smooth'
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Close current editor
|
||
*/
|
||
close() {
|
||
if (this.previewer) {
|
||
this.previewer.clearPreview();
|
||
}
|
||
|
||
if (this.currentOverlay) {
|
||
this.currentOverlay.remove();
|
||
this.currentOverlay = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Escape HTML to prevent XSS
|
||
*/
|
||
escapeHtml(text) {
|
||
if (typeof text !== 'string') return '';
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* EditContext - Represents content elements for editing
|
||
*/
|
||
class EditContext {
|
||
constructor(elements, currentContent) {
|
||
this.elements = elements;
|
||
this.primaryElement = elements[0];
|
||
this.originalContent = null;
|
||
this.currentContent = currentContent;
|
||
}
|
||
|
||
/**
|
||
* Extract content from elements in markdown format
|
||
*/
|
||
extractContent() {
|
||
if (this.elements.length === 1) {
|
||
const element = this.elements[0];
|
||
|
||
// Handle links specially
|
||
if (element.tagName.toLowerCase() === 'a') {
|
||
return {
|
||
text: markdownConverter.htmlToMarkdown(element.innerHTML),
|
||
url: element.href
|
||
};
|
||
}
|
||
|
||
// Single element - convert to markdown
|
||
return markdownConverter.htmlToMarkdown(element.innerHTML);
|
||
} else {
|
||
// Multiple elements - use group extraction
|
||
return markdownConverter.extractGroupMarkdown(this.elements);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Apply content to elements from markdown/object
|
||
*/
|
||
applyContent(content) {
|
||
if (this.elements.length === 1) {
|
||
const element = this.elements[0];
|
||
|
||
// Handle links specially
|
||
if (element.tagName.toLowerCase() === 'a' && typeof content === 'object') {
|
||
element.innerHTML = markdownConverter.markdownToHtml(content.text || '');
|
||
if (content.url) {
|
||
element.href = content.url;
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Single element - convert markdown to HTML
|
||
const html = markdownConverter.markdownToHtml(content);
|
||
element.innerHTML = html;
|
||
} else {
|
||
// Multiple elements - use group update
|
||
markdownConverter.updateGroupElements(this.elements, content);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Store original content for preview restoration
|
||
*/
|
||
storeOriginalContent() {
|
||
this.originalContent = this.elements.map(el => ({
|
||
innerHTML: el.innerHTML,
|
||
href: el.href // Store href for links
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Restore original content (for preview cancellation)
|
||
*/
|
||
restoreOriginalContent() {
|
||
if (this.originalContent) {
|
||
this.elements.forEach((el, index) => {
|
||
if (this.originalContent[index] !== undefined) {
|
||
el.innerHTML = this.originalContent[index].innerHTML;
|
||
if (this.originalContent[index].href) {
|
||
el.href = this.originalContent[index].href;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Update original content to match current element state (after save)
|
||
* This makes the current content the new baseline for future cancellations
|
||
*/
|
||
updateOriginalContent() {
|
||
this.originalContent = this.elements.map(el => ({
|
||
innerHTML: el.innerHTML,
|
||
href: el.href // Store href for links
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Apply preview styling to all elements
|
||
*/
|
||
applyPreviewStyling() {
|
||
this.elements.forEach(el => {
|
||
el.classList.add('insertr-preview-active');
|
||
});
|
||
|
||
// Also apply to containers if they're groups
|
||
if (this.primaryElement.classList.contains('insertr-group')) {
|
||
this.primaryElement.classList.add('insertr-preview-active');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Remove preview styling from all elements
|
||
*/
|
||
removePreviewStyling() {
|
||
this.elements.forEach(el => {
|
||
el.classList.remove('insertr-preview-active');
|
||
});
|
||
|
||
// Also remove from containers
|
||
if (this.primaryElement.classList.contains('insertr-group')) {
|
||
this.primaryElement.classList.remove('insertr-preview-active');
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* InsertrFormRenderer - Form renderer using markdown-first approach
|
||
* Thin wrapper around the Editor system
|
||
*/
|
||
|
||
class InsertrFormRenderer {
|
||
constructor(apiClient = null) {
|
||
this.apiClient = apiClient;
|
||
this.editor = new Editor();
|
||
this.setupStyles();
|
||
}
|
||
|
||
/**
|
||
* Show edit form for any content element
|
||
* @param {Object} meta - Element metadata {element, contentId, contentType}
|
||
* @param {string|Object} currentContent - Current content value
|
||
* @param {Function} onSave - Save callback
|
||
* @param {Function} onCancel - Cancel callback
|
||
*/
|
||
showEditForm(meta, currentContent, onSave, onCancel) {
|
||
const { element } = meta;
|
||
|
||
// Handle insertr-group elements by getting their viable children
|
||
if (element.classList.contains('insertr-group')) {
|
||
const children = this.getGroupChildren(element);
|
||
const groupMeta = { ...meta, element: children };
|
||
return this.editor.edit(groupMeta, currentContent, onSave, onCancel);
|
||
}
|
||
|
||
// All other elements use the editor directly
|
||
return this.editor.edit(meta, currentContent, onSave, onCancel);
|
||
}
|
||
|
||
/**
|
||
* Get viable children from group element
|
||
*/
|
||
getGroupChildren(groupElement) {
|
||
const children = [];
|
||
for (const child of groupElement.children) {
|
||
// Skip elements that don't have meaningful text content
|
||
if (child.textContent.trim().length > 0) {
|
||
children.push(child);
|
||
}
|
||
}
|
||
return children;
|
||
}
|
||
|
||
/**
|
||
* Close current form
|
||
*/
|
||
closeForm() {
|
||
this.editor.close();
|
||
}
|
||
|
||
/**
|
||
* Show version history modal (placeholder for future implementation)
|
||
*/
|
||
async showVersionHistory(contentId, element, onRestore) {
|
||
try {
|
||
// Get version history from API
|
||
const apiClient = this.getApiClient();
|
||
if (!apiClient) {
|
||
console.warn('No API client configured for version history');
|
||
return;
|
||
}
|
||
|
||
const versions = await apiClient.getContentVersions(contentId);
|
||
|
||
// Create version history modal
|
||
const historyModal = this.createVersionHistoryModal(contentId, versions, onRestore);
|
||
document.body.appendChild(historyModal);
|
||
|
||
// Setup handlers
|
||
this.setupVersionHistoryHandlers(historyModal, contentId);
|
||
|
||
} catch (error) {
|
||
console.error('Failed to load version history:', error);
|
||
this.showVersionHistoryError('Failed to load version history. Please try again.');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Create version history modal (simplified placeholder)
|
||
*/
|
||
createVersionHistoryModal(contentId, versions, onRestore) {
|
||
const modal = document.createElement('div');
|
||
modal.className = 'insertr-version-modal';
|
||
|
||
let versionsHTML = '';
|
||
if (versions && versions.length > 0) {
|
||
versionsHTML = versions.map((version, index) => `
|
||
<div class="insertr-version-item" data-version-id="${version.version_id}">
|
||
<div class="insertr-version-meta">
|
||
<span class="insertr-version-label">${index === 0 ? 'Previous Version' : `Version ${versions.length - index}`}</span>
|
||
<span class="insertr-version-date">${this.formatDate(version.created_at)}</span>
|
||
${version.created_by ? `<span class="insertr-version-user">by ${version.created_by}</span>` : ''}
|
||
</div>
|
||
<div class="insertr-version-content">${this.escapeHtml(this.truncateContent(version.value, 100))}</div>
|
||
<div class="insertr-version-actions">
|
||
<button type="button" class="insertr-btn-restore" data-version-id="${version.version_id}">Restore</button>
|
||
<button type="button" class="insertr-btn-view-diff" data-version-id="${version.version_id}">View Full</button>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
} else {
|
||
versionsHTML = '<div class="insertr-version-empty">No previous versions found</div>';
|
||
}
|
||
|
||
modal.innerHTML = `
|
||
<div class="insertr-version-backdrop">
|
||
<div class="insertr-version-content-modal">
|
||
<div class="insertr-version-header">
|
||
<h3>Version History</h3>
|
||
<button type="button" class="insertr-btn-close">×</button>
|
||
</div>
|
||
<div class="insertr-version-list">
|
||
${versionsHTML}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
return modal;
|
||
}
|
||
|
||
/**
|
||
* Setup version history modal handlers
|
||
*/
|
||
setupVersionHistoryHandlers(modal, contentId) {
|
||
const closeBtn = modal.querySelector('.insertr-btn-close');
|
||
const backdrop = modal.querySelector('.insertr-version-backdrop');
|
||
|
||
// Close handlers
|
||
if (closeBtn) {
|
||
closeBtn.addEventListener('click', () => modal.remove());
|
||
}
|
||
|
||
backdrop.addEventListener('click', (e) => {
|
||
if (e.target === backdrop) {
|
||
modal.remove();
|
||
}
|
||
});
|
||
|
||
// Restore handlers
|
||
const restoreButtons = modal.querySelectorAll('.insertr-btn-restore');
|
||
restoreButtons.forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
const versionId = btn.getAttribute('data-version-id');
|
||
if (await this.confirmRestore()) {
|
||
await this.restoreVersion(contentId, versionId);
|
||
modal.remove();
|
||
this.closeForm();
|
||
}
|
||
});
|
||
});
|
||
|
||
// View diff handlers
|
||
const viewButtons = modal.querySelectorAll('.insertr-btn-view-diff');
|
||
viewButtons.forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const versionId = btn.getAttribute('data-version-id');
|
||
this.showVersionDetails(versionId);
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Helper methods for version history
|
||
*/
|
||
formatDate(dateString) {
|
||
const date = new Date(dateString);
|
||
const now = new Date();
|
||
const diff = now - date;
|
||
|
||
// Less than 24 hours ago
|
||
if (diff < 24 * 60 * 60 * 1000) {
|
||
const hours = Math.floor(diff / (60 * 60 * 1000));
|
||
if (hours < 1) {
|
||
const minutes = Math.floor(diff / (60 * 1000));
|
||
return `${minutes}m ago`;
|
||
}
|
||
return `${hours}h ago`;
|
||
}
|
||
|
||
// Less than 7 days ago
|
||
if (diff < 7 * 24 * 60 * 60 * 1000) {
|
||
const days = Math.floor(diff / (24 * 60 * 60 * 1000));
|
||
return `${days}d ago`;
|
||
}
|
||
|
||
// Older - show actual date
|
||
return date.toLocaleDateString();
|
||
}
|
||
|
||
truncateContent(content, maxLength) {
|
||
if (content.length <= maxLength) return content;
|
||
return content.substring(0, maxLength) + '...';
|
||
}
|
||
|
||
async confirmRestore() {
|
||
return confirm('Are you sure you want to restore this version? This will replace the current content.');
|
||
}
|
||
|
||
async restoreVersion(contentId, versionId) {
|
||
try {
|
||
const apiClient = this.getApiClient();
|
||
await apiClient.rollbackContent(contentId, versionId);
|
||
return true;
|
||
} catch (error) {
|
||
console.error('Failed to restore version:', error);
|
||
alert('Failed to restore version. Please try again.');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
showVersionDetails(versionId) {
|
||
// TODO: Implement detailed version view with diff
|
||
alert(`Version details not implemented yet (Version ID: ${versionId})`);
|
||
}
|
||
|
||
showVersionHistoryError(message) {
|
||
alert(message);
|
||
}
|
||
|
||
// Helper to get API client
|
||
getApiClient() {
|
||
return this.apiClient || window.insertrAPIClient || null;
|
||
}
|
||
|
||
/**
|
||
* Escape HTML to prevent XSS
|
||
*/
|
||
escapeHtml(text) {
|
||
if (typeof text !== 'string') return '';
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
/**
|
||
* Setup form styles (consolidated and simplified)
|
||
*/
|
||
setupStyles() {
|
||
const styles = `
|
||
/* Overlay and Form Container */
|
||
.insertr-form-overlay {
|
||
position: absolute;
|
||
z-index: 10000;
|
||
}
|
||
|
||
.insertr-edit-form {
|
||
background: white;
|
||
border: 2px solid #007cba;
|
||
border-radius: 8px;
|
||
padding: 1rem;
|
||
box-shadow: 0 8px 25px rgba(0,0,0,0.15);
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
min-width: 600px;
|
||
max-width: 800px;
|
||
}
|
||
|
||
/* Form Header */
|
||
.insertr-form-header {
|
||
font-weight: 600;
|
||
color: #1f2937;
|
||
margin-bottom: 1rem;
|
||
padding-bottom: 0.5rem;
|
||
border-bottom: 1px solid #e5e7eb;
|
||
font-size: 0.875rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
/* Form Groups and Fields */
|
||
.insertr-form-group {
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.insertr-form-group:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.insertr-form-label {
|
||
display: block;
|
||
font-weight: 600;
|
||
color: #374151;
|
||
margin-bottom: 0.5rem;
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
.insertr-form-input,
|
||
.insertr-form-textarea {
|
||
width: 100%;
|
||
padding: 0.75rem;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 6px;
|
||
font-family: inherit;
|
||
font-size: 1rem;
|
||
transition: border-color 0.2s, box-shadow 0.2s;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.insertr-form-input:focus,
|
||
.insertr-form-textarea:focus {
|
||
outline: none;
|
||
border-color: #007cba;
|
||
box-shadow: 0 0 0 3px rgba(0, 124, 186, 0.1);
|
||
}
|
||
|
||
/* Markdown Editor Styling */
|
||
.insertr-form-textarea {
|
||
min-height: 120px;
|
||
resize: vertical;
|
||
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||
}
|
||
|
||
.insertr-markdown-editor {
|
||
min-height: 200px;
|
||
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||
font-size: 0.9rem;
|
||
line-height: 1.5;
|
||
background-color: #f8fafc;
|
||
}
|
||
|
||
/* Form Actions */
|
||
.insertr-form-actions {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
justify-content: flex-end;
|
||
margin-top: 1rem;
|
||
padding-top: 1rem;
|
||
border-top: 1px solid #e5e7eb;
|
||
}
|
||
|
||
.insertr-btn-save {
|
||
background: #10b981;
|
||
color: white;
|
||
border: none;
|
||
padding: 0.5rem 1rem;
|
||
border-radius: 6px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: background-color 0.2s;
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
.insertr-btn-save:hover {
|
||
background: #059669;
|
||
}
|
||
|
||
.insertr-btn-cancel {
|
||
background: #6b7280;
|
||
color: white;
|
||
border: none;
|
||
padding: 0.5rem 1rem;
|
||
border-radius: 6px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: background-color 0.2s;
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
.insertr-btn-cancel:hover {
|
||
background: #4b5563;
|
||
}
|
||
|
||
.insertr-btn-history {
|
||
background: #6f42c1;
|
||
color: white;
|
||
border: none;
|
||
padding: 0.5rem 1rem;
|
||
border-radius: 6px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: background-color 0.2s;
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
.insertr-btn-history:hover {
|
||
background: #5a359a;
|
||
}
|
||
|
||
.insertr-form-help {
|
||
font-size: 0.75rem;
|
||
color: #6b7280;
|
||
margin-top: 0.25rem;
|
||
}
|
||
|
||
/* Live Preview Styles */
|
||
.insertr-preview-active {
|
||
position: relative;
|
||
background: rgba(0, 124, 186, 0.05) !important;
|
||
outline: 2px solid #007cba !important;
|
||
outline-offset: 2px;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.insertr-preview-active::after {
|
||
content: "Preview";
|
||
position: absolute;
|
||
top: -25px;
|
||
left: 0;
|
||
background: #007cba;
|
||
color: white;
|
||
padding: 2px 8px;
|
||
border-radius: 3px;
|
||
font-size: 0.75rem;
|
||
font-weight: 500;
|
||
z-index: 10001;
|
||
white-space: nowrap;
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
}
|
||
|
||
/* Responsive Design */
|
||
@media (max-width: 768px) {
|
||
.insertr-edit-form {
|
||
min-width: 90vw;
|
||
max-width: 90vw;
|
||
}
|
||
|
||
.insertr-preview-active::after {
|
||
top: -20px;
|
||
font-size: 0.7rem;
|
||
padding: 1px 6px;
|
||
}
|
||
}
|
||
|
||
/* Version History Modal Styles */
|
||
.insertr-version-modal {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 10001;
|
||
}
|
||
|
||
.insertr-version-backdrop {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background-color: rgba(0, 0, 0, 0.5);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 20px;
|
||
}
|
||
|
||
.insertr-version-content-modal {
|
||
background: white;
|
||
border-radius: 8px;
|
||
max-width: 600px;
|
||
width: 100%;
|
||
max-height: 80vh;
|
||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.insertr-version-header {
|
||
padding: 20px 20px 0;
|
||
border-bottom: 1px solid #eee;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.insertr-version-header h3 {
|
||
margin: 0 0 20px;
|
||
color: #333;
|
||
font-size: 18px;
|
||
}
|
||
|
||
.insertr-btn-close {
|
||
background: none;
|
||
border: none;
|
||
font-size: 24px;
|
||
cursor: pointer;
|
||
color: #666;
|
||
padding: 0;
|
||
width: 30px;
|
||
height: 30px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.insertr-btn-close:hover {
|
||
color: #333;
|
||
}
|
||
|
||
.insertr-version-list {
|
||
overflow-y: auto;
|
||
padding: 20px;
|
||
flex: 1;
|
||
}
|
||
|
||
.insertr-version-item {
|
||
border: 1px solid #e1e5e9;
|
||
border-radius: 6px;
|
||
padding: 16px;
|
||
margin-bottom: 12px;
|
||
background: #f8f9fa;
|
||
}
|
||
|
||
.insertr-version-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 8px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.insertr-version-label {
|
||
font-weight: 600;
|
||
color: #0969da;
|
||
}
|
||
|
||
.insertr-version-date {
|
||
color: #656d76;
|
||
}
|
||
|
||
.insertr-version-user {
|
||
color: #656d76;
|
||
}
|
||
|
||
.insertr-version-content {
|
||
margin-bottom: 12px;
|
||
padding: 8px;
|
||
background: white;
|
||
border-radius: 4px;
|
||
font-family: monospace;
|
||
font-size: 14px;
|
||
color: #24292f;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.insertr-version-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.insertr-btn-restore {
|
||
background: #0969da;
|
||
color: white;
|
||
border: none;
|
||
padding: 6px 12px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.insertr-btn-restore:hover {
|
||
background: #0860ca;
|
||
}
|
||
|
||
.insertr-btn-view-diff {
|
||
background: #f6f8fa;
|
||
color: #24292f;
|
||
border: 1px solid #d1d9e0;
|
||
padding: 6px 12px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.insertr-btn-view-diff:hover {
|
||
background: #f3f4f6;
|
||
}
|
||
|
||
.insertr-version-empty {
|
||
text-align: center;
|
||
color: #656d76;
|
||
font-style: italic;
|
||
padding: 40px 20px;
|
||
}
|
||
`;
|
||
|
||
const styleSheet = document.createElement('style');
|
||
styleSheet.type = 'text/css';
|
||
styleSheet.innerHTML = styles;
|
||
document.head.appendChild(styleSheet);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* InsertrEditor - Visual editing functionality
|
||
*/
|
||
class InsertrEditor {
|
||
constructor(core, auth, apiClient, options = {}) {
|
||
this.core = core;
|
||
this.auth = auth;
|
||
this.apiClient = apiClient;
|
||
this.options = options;
|
||
this.isActive = false;
|
||
this.formRenderer = new InsertrFormRenderer(apiClient);
|
||
}
|
||
|
||
start() {
|
||
if (this.isActive) return;
|
||
|
||
console.log('🚀 Starting Insertr Editor');
|
||
this.isActive = true;
|
||
|
||
// Add editor styles
|
||
this.addEditorStyles();
|
||
|
||
// Initialize all enhanced elements
|
||
const elements = this.core.getAllElements();
|
||
console.log(`📝 Found ${elements.length} editable elements`);
|
||
|
||
elements.forEach(meta => this.initializeElement(meta));
|
||
}
|
||
|
||
initializeElement(meta) {
|
||
const { element, contentId, contentType } = meta;
|
||
|
||
// Add visual indicators
|
||
element.style.cursor = 'pointer';
|
||
element.style.position = 'relative';
|
||
|
||
// Add interaction handlers
|
||
this.addHoverEffects(element);
|
||
this.addClickHandler(element, meta);
|
||
}
|
||
|
||
addHoverEffects(element) {
|
||
element.addEventListener('mouseenter', () => {
|
||
element.classList.add('insertr-editing-hover');
|
||
});
|
||
|
||
element.addEventListener('mouseleave', () => {
|
||
element.classList.remove('insertr-editing-hover');
|
||
});
|
||
}
|
||
|
||
addClickHandler(element, meta) {
|
||
element.addEventListener('click', (e) => {
|
||
// Only allow editing if authenticated and in edit mode
|
||
if (!this.auth.isAuthenticated() || !this.auth.isEditMode()) {
|
||
return; // Let normal click behavior happen
|
||
}
|
||
|
||
e.preventDefault();
|
||
this.openEditor(meta);
|
||
});
|
||
}
|
||
|
||
openEditor(meta) {
|
||
const { element } = meta;
|
||
const currentContent = this.extractCurrentContent(element);
|
||
|
||
// Show professional form instead of prompt
|
||
this.formRenderer.showEditForm(
|
||
meta,
|
||
currentContent,
|
||
(formData) => this.handleSave(meta, formData),
|
||
() => this.handleCancel(meta)
|
||
);
|
||
}
|
||
|
||
extractCurrentContent(element) {
|
||
// For links, extract both text and URL
|
||
if (element.tagName.toLowerCase() === 'a') {
|
||
return {
|
||
text: element.textContent.trim(),
|
||
url: element.getAttribute('href') || ''
|
||
};
|
||
}
|
||
|
||
// For other elements, just return text content
|
||
return element.textContent.trim();
|
||
}
|
||
|
||
async handleSave(meta, formData) {
|
||
console.log('💾 Saving content:', meta.contentId, formData);
|
||
|
||
try {
|
||
// Extract content value based on type
|
||
let contentValue;
|
||
if (meta.element.tagName.toLowerCase() === 'a') {
|
||
// For links, save the text content (URL is handled separately if needed)
|
||
contentValue = formData.text || formData;
|
||
} else {
|
||
contentValue = formData.text || formData;
|
||
}
|
||
|
||
// Universal upsert - server handles ID extraction/generation from markup
|
||
const contentType = this.determineContentType(meta.element);
|
||
const result = await this.apiClient.createContent(
|
||
contentValue,
|
||
contentType,
|
||
meta.htmlMarkup // Always send HTML markup - server is smart about ID handling
|
||
);
|
||
|
||
if (result) {
|
||
// Store the backend-generated/confirmed ID in the element
|
||
meta.element.setAttribute('data-content-id', result.id);
|
||
meta.element.setAttribute('data-content-type', result.type);
|
||
console.log(`✅ Content saved: ${result.id}`, contentValue);
|
||
} else {
|
||
console.error('❌ Failed to save content to server');
|
||
}
|
||
|
||
// Close form
|
||
this.formRenderer.closeForm();
|
||
|
||
} catch (error) {
|
||
console.error('❌ Error saving content:', error);
|
||
this.formRenderer.closeForm();
|
||
}
|
||
}
|
||
|
||
determineContentType(element) {
|
||
const tagName = element.tagName.toLowerCase();
|
||
|
||
if (tagName === 'a' || tagName === 'button') {
|
||
return 'link';
|
||
}
|
||
|
||
// ALL text elements use markdown for consistent editing experience
|
||
return 'markdown';
|
||
}
|
||
|
||
handleCancel(meta) {
|
||
console.log('❌ Edit cancelled:', meta.contentId);
|
||
}
|
||
|
||
|
||
addEditorStyles() {
|
||
const styles = `
|
||
.insertr-editing-hover {
|
||
outline: 2px dashed #007cba !important;
|
||
outline-offset: 2px !important;
|
||
background-color: rgba(0, 124, 186, 0.05) !important;
|
||
}
|
||
|
||
.insertr:hover::after {
|
||
content: "✏️ " attr(data-content-type);
|
||
position: absolute;
|
||
top: -25px;
|
||
left: 0;
|
||
background: #007cba;
|
||
color: white;
|
||
padding: 2px 6px;
|
||
font-size: 11px;
|
||
border-radius: 3px;
|
||
white-space: nowrap;
|
||
z-index: 1000;
|
||
font-family: monospace;
|
||
}
|
||
|
||
/* Version History Modal Styles */
|
||
.insertr-version-modal {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 10001;
|
||
}
|
||
|
||
.insertr-version-backdrop {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background-color: rgba(0, 0, 0, 0.5);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 20px;
|
||
}
|
||
|
||
.insertr-version-content-modal {
|
||
background: white;
|
||
border-radius: 8px;
|
||
max-width: 600px;
|
||
width: 100%;
|
||
max-height: 80vh;
|
||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.insertr-version-header {
|
||
padding: 20px 20px 0;
|
||
border-bottom: 1px solid #eee;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.insertr-version-header h3 {
|
||
margin: 0 0 20px;
|
||
color: #333;
|
||
font-size: 18px;
|
||
}
|
||
|
||
.insertr-btn-close {
|
||
background: none;
|
||
border: none;
|
||
font-size: 24px;
|
||
cursor: pointer;
|
||
color: #666;
|
||
padding: 0;
|
||
width: 30px;
|
||
height: 30px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.insertr-btn-close:hover {
|
||
color: #333;
|
||
}
|
||
|
||
.insertr-version-list {
|
||
overflow-y: auto;
|
||
padding: 20px;
|
||
flex: 1;
|
||
}
|
||
|
||
.insertr-version-item {
|
||
border: 1px solid #e1e5e9;
|
||
border-radius: 6px;
|
||
padding: 16px;
|
||
margin-bottom: 12px;
|
||
background: #f8f9fa;
|
||
}
|
||
|
||
.insertr-version-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 8px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.insertr-version-label {
|
||
font-weight: 600;
|
||
color: #0969da;
|
||
}
|
||
|
||
.insertr-version-date {
|
||
color: #656d76;
|
||
}
|
||
|
||
.insertr-version-user {
|
||
color: #656d76;
|
||
}
|
||
|
||
.insertr-version-content {
|
||
margin-bottom: 12px;
|
||
padding: 8px;
|
||
background: white;
|
||
border-radius: 4px;
|
||
font-family: monospace;
|
||
font-size: 14px;
|
||
color: #24292f;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.insertr-version-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.insertr-btn-restore {
|
||
background: #0969da;
|
||
color: white;
|
||
border: none;
|
||
padding: 6px 12px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.insertr-btn-restore:hover {
|
||
background: #0860ca;
|
||
}
|
||
|
||
.insertr-btn-view-diff {
|
||
background: #f6f8fa;
|
||
color: #24292f;
|
||
border: 1px solid #d1d9e0;
|
||
padding: 6px 12px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.insertr-btn-view-diff:hover {
|
||
background: #f3f4f6;
|
||
}
|
||
|
||
.insertr-version-empty {
|
||
text-align: center;
|
||
color: #656d76;
|
||
font-style: italic;
|
||
padding: 40px 20px;
|
||
}
|
||
|
||
/* History Button in Form */
|
||
.insertr-btn-history {
|
||
background: #6f42c1;
|
||
color: white;
|
||
border: none;
|
||
padding: 8px 16px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
margin-left: auto;
|
||
}
|
||
|
||
.insertr-btn-history:hover {
|
||
background: #5a359a;
|
||
}
|
||
`;
|
||
|
||
const styleSheet = document.createElement('style');
|
||
styleSheet.type = 'text/css';
|
||
styleSheet.innerHTML = styles;
|
||
document.head.appendChild(styleSheet);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* InsertrAuth - Authentication and state management
|
||
* Handles user authentication, edit mode, and visual state changes
|
||
*/
|
||
class InsertrAuth {
|
||
constructor(options = {}) {
|
||
this.options = {
|
||
mockAuth: options.mockAuth !== false, // Enable mock auth by default
|
||
hideGatesAfterAuth: options.hideGatesAfterAuth === true, // Keep gates visible by default
|
||
...options
|
||
};
|
||
|
||
// Authentication state
|
||
this.state = {
|
||
isAuthenticated: false,
|
||
editMode: false,
|
||
currentUser: null,
|
||
activeEditor: null,
|
||
isInitialized: false,
|
||
isAuthenticating: false
|
||
};
|
||
|
||
this.statusIndicator = null;
|
||
}
|
||
|
||
/**
|
||
* Initialize gate system (called on page load)
|
||
*/
|
||
init() {
|
||
console.log('🔧 Insertr: Scanning for editor gates');
|
||
|
||
this.setupEditorGates();
|
||
}
|
||
|
||
/**
|
||
* Initialize full editing system (called after successful OAuth)
|
||
*/
|
||
initializeFullSystem() {
|
||
if (this.state.isInitialized) {
|
||
return; // Already initialized
|
||
}
|
||
|
||
console.log('🔐 Initializing Insertr Editing System');
|
||
|
||
this.createAuthControls();
|
||
this.setupAuthenticationControls();
|
||
this.createStatusIndicator();
|
||
this.updateBodyClasses();
|
||
|
||
// Auto-enable edit mode after OAuth
|
||
this.state.editMode = true;
|
||
this.state.isInitialized = true;
|
||
|
||
// Start the editor system
|
||
if (window.Insertr && window.Insertr.startEditor) {
|
||
window.Insertr.startEditor();
|
||
}
|
||
|
||
this.updateButtonStates();
|
||
this.updateStatusIndicator();
|
||
|
||
console.log('📱 Editing system active - Controls in bottom-right corner');
|
||
console.log('✏️ Edit mode enabled - Click elements to edit');
|
||
}
|
||
|
||
/**
|
||
* Setup editor gate click handlers for any .insertr-gate elements
|
||
*/
|
||
setupEditorGates() {
|
||
const gates = document.querySelectorAll('.insertr-gate');
|
||
|
||
if (gates.length === 0) {
|
||
console.log('ℹ️ No .insertr-gate elements found - editor access disabled');
|
||
return;
|
||
}
|
||
|
||
console.log(`🚪 Found ${gates.length} editor gate(s)`);
|
||
|
||
// Add gate styles
|
||
this.addGateStyles();
|
||
|
||
gates.forEach((gate, index) => {
|
||
// Store original text for later restoration
|
||
if (!gate.hasAttribute('data-original-text')) {
|
||
gate.setAttribute('data-original-text', gate.textContent);
|
||
}
|
||
|
||
gate.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
this.handleGateClick(gate, index);
|
||
});
|
||
|
||
// Add subtle styling to indicate it's clickable
|
||
gate.style.cursor = 'pointer';
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Handle click on an editor gate element
|
||
*/
|
||
async handleGateClick(gateElement, gateIndex) {
|
||
// Prevent multiple simultaneous authentication attempts
|
||
if (this.state.isAuthenticating) {
|
||
console.log('⏳ Authentication already in progress...');
|
||
return;
|
||
}
|
||
|
||
console.log(`🚀 Editor gate activated (gate ${gateIndex + 1})`);
|
||
this.state.isAuthenticating = true;
|
||
|
||
// Store original text and show loading state
|
||
const originalText = gateElement.textContent;
|
||
gateElement.setAttribute('data-original-text', originalText);
|
||
gateElement.textContent = '⏳ Signing in...';
|
||
gateElement.style.pointerEvents = 'none';
|
||
|
||
try {
|
||
// Perform OAuth authentication
|
||
await this.performOAuthFlow();
|
||
|
||
// Initialize full editing system
|
||
this.initializeFullSystem();
|
||
|
||
// Conditionally hide gates based on options
|
||
if (this.options.hideGatesAfterAuth) {
|
||
this.hideAllGates();
|
||
} else {
|
||
this.updateGateState();
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('❌ Authentication failed:', error);
|
||
|
||
// Restore clicked gate to original state
|
||
const originalText = gateElement.getAttribute('data-original-text');
|
||
if (originalText) {
|
||
gateElement.textContent = originalText;
|
||
}
|
||
gateElement.style.pointerEvents = '';
|
||
} finally {
|
||
this.state.isAuthenticating = false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Perform OAuth authentication flow
|
||
*/
|
||
async performOAuthFlow() {
|
||
// In development, simulate OAuth flow
|
||
if (this.options.mockAuth) {
|
||
console.log('🔐 Mock OAuth: Simulating authentication...');
|
||
|
||
// Simulate network delay
|
||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||
|
||
// Set authenticated state
|
||
this.state.isAuthenticated = true;
|
||
this.state.currentUser = {
|
||
name: 'Site Owner',
|
||
email: 'owner@example.com',
|
||
role: 'admin'
|
||
};
|
||
|
||
console.log('✅ Mock OAuth: Authentication successful');
|
||
return;
|
||
}
|
||
|
||
// TODO: In production, implement real OAuth flow
|
||
// This would redirect to OAuth provider, handle callback, etc.
|
||
throw new Error('Production OAuth not implemented yet');
|
||
}
|
||
|
||
/**
|
||
* Hide all editor gates after successful authentication (optional)
|
||
*/
|
||
hideAllGates() {
|
||
document.body.classList.add('insertr-hide-gates');
|
||
console.log('🚪 Editor gates hidden (hideGatesAfterAuth enabled)');
|
||
}
|
||
|
||
/**
|
||
* Update gate state after authentication (restore normal appearance)
|
||
*/
|
||
updateGateState() {
|
||
const gates = document.querySelectorAll('.insertr-gate');
|
||
gates.forEach(gate => {
|
||
// Restore original text if it was saved
|
||
const originalText = gate.getAttribute('data-original-text');
|
||
if (originalText) {
|
||
gate.textContent = originalText;
|
||
}
|
||
|
||
// Restore interactive state
|
||
gate.style.pointerEvents = '';
|
||
gate.style.opacity = '';
|
||
});
|
||
|
||
console.log('🚪 Editor gates restored to original state');
|
||
}
|
||
|
||
/**
|
||
* Create authentication control buttons (bottom-right positioned)
|
||
*/
|
||
createAuthControls() {
|
||
// Check if controls already exist
|
||
if (document.getElementById('insertr-auth-controls')) {
|
||
return;
|
||
}
|
||
|
||
const controlsHtml = `
|
||
<div id="insertr-auth-controls" class="insertr-auth-controls">
|
||
<button id="insertr-auth-toggle" class="insertr-auth-btn">Login as Client</button>
|
||
<button id="insertr-edit-toggle" class="insertr-auth-btn" style="display: none;">Edit Mode: Off</button>
|
||
</div>
|
||
`;
|
||
|
||
// Add controls to page
|
||
document.body.insertAdjacentHTML('beforeend', controlsHtml);
|
||
|
||
// Add styles for controls
|
||
this.addControlStyles();
|
||
}
|
||
|
||
/**
|
||
* Setup event listeners for authentication controls
|
||
*/
|
||
setupAuthenticationControls() {
|
||
const authToggle = document.getElementById('insertr-auth-toggle');
|
||
const editToggle = document.getElementById('insertr-edit-toggle');
|
||
|
||
if (authToggle) {
|
||
authToggle.addEventListener('click', () => this.toggleAuthentication());
|
||
}
|
||
|
||
if (editToggle) {
|
||
editToggle.addEventListener('click', () => this.toggleEditMode());
|
||
}
|
||
|
||
|
||
}
|
||
|
||
/**
|
||
* Toggle authentication state
|
||
*/
|
||
toggleAuthentication() {
|
||
this.state.isAuthenticated = !this.state.isAuthenticated;
|
||
this.state.currentUser = this.state.isAuthenticated ? {
|
||
name: 'Demo User',
|
||
email: 'demo@example.com',
|
||
role: 'editor'
|
||
} : null;
|
||
|
||
// Reset edit mode when logging out
|
||
if (!this.state.isAuthenticated) {
|
||
this.state.editMode = false;
|
||
}
|
||
|
||
this.updateBodyClasses();
|
||
this.updateButtonStates();
|
||
this.updateStatusIndicator();
|
||
|
||
console.log(this.state.isAuthenticated
|
||
? '✅ Authenticated as Demo User'
|
||
: '❌ Logged out');
|
||
}
|
||
|
||
/**
|
||
* Toggle edit mode (only when authenticated)
|
||
*/
|
||
toggleEditMode() {
|
||
if (!this.state.isAuthenticated) {
|
||
console.warn('❌ Cannot enable edit mode - not authenticated');
|
||
return;
|
||
}
|
||
|
||
this.state.editMode = !this.state.editMode;
|
||
|
||
// Cancel any active editing when turning off edit mode
|
||
if (!this.state.editMode && this.state.activeEditor) {
|
||
// This would be handled by the main editor
|
||
this.state.activeEditor = null;
|
||
}
|
||
|
||
this.updateBodyClasses();
|
||
this.updateButtonStates();
|
||
this.updateStatusIndicator();
|
||
|
||
console.log(this.state.editMode
|
||
? '✏️ Edit mode ON - Click elements to edit'
|
||
: '👀 Edit mode OFF - Read-only view');
|
||
}
|
||
|
||
/**
|
||
* Update body CSS classes based on authentication state
|
||
*/
|
||
updateBodyClasses() {
|
||
document.body.classList.toggle('insertr-authenticated', this.state.isAuthenticated);
|
||
document.body.classList.toggle('insertr-edit-mode', this.state.editMode);
|
||
}
|
||
|
||
/**
|
||
* Update button text and visibility
|
||
*/
|
||
updateButtonStates() {
|
||
const authBtn = document.getElementById('insertr-auth-toggle');
|
||
const editBtn = document.getElementById('insertr-edit-toggle');
|
||
|
||
if (authBtn) {
|
||
authBtn.textContent = this.state.isAuthenticated ? 'Logout' : 'Login as Client';
|
||
authBtn.className = `insertr-auth-btn ${this.state.isAuthenticated ? 'insertr-authenticated' : ''}`;
|
||
}
|
||
|
||
if (editBtn) {
|
||
editBtn.style.display = this.state.isAuthenticated ? 'inline-block' : 'none';
|
||
editBtn.textContent = `Edit Mode: ${this.state.editMode ? 'On' : 'Off'}`;
|
||
editBtn.className = `insertr-auth-btn ${this.state.editMode ? 'insertr-edit-active' : ''}`;
|
||
}
|
||
|
||
// Update enhance button visibility
|
||
this.updateEnhanceButtonVisibility();
|
||
}
|
||
|
||
/**
|
||
* Create status indicator
|
||
*/
|
||
createStatusIndicator() {
|
||
// Check if already exists
|
||
if (document.getElementById('insertr-status')) {
|
||
return;
|
||
}
|
||
|
||
const statusHtml = `
|
||
<div id="insertr-status-controls" class="insertr-status-controls">
|
||
<div id="insertr-status" class="insertr-status">
|
||
<div class="insertr-status-content">
|
||
<span class="insertr-status-text">Visitor Mode</span>
|
||
<span class="insertr-status-dot"></span>
|
||
</div>
|
||
</div>
|
||
<button id="insertr-enhance-btn" class="insertr-auth-btn" style="display: none;" title="Enhance files with latest content">🔄 Enhance</button>
|
||
</div>
|
||
`;
|
||
|
||
document.body.insertAdjacentHTML('beforeend', statusHtml);
|
||
this.statusIndicator = document.getElementById('insertr-status');
|
||
this.setupEnhanceButton();
|
||
this.updateStatusIndicator();
|
||
}
|
||
|
||
/**
|
||
* Update status indicator text and style
|
||
*/
|
||
updateStatusIndicator() {
|
||
const statusText = document.querySelector('.insertr-status-text');
|
||
const statusDot = document.querySelector('.insertr-status-dot');
|
||
|
||
if (!statusText || !statusDot) return;
|
||
|
||
if (!this.state.isAuthenticated) {
|
||
statusText.textContent = 'Visitor Mode';
|
||
statusDot.className = 'insertr-status-dot insertr-status-visitor';
|
||
} else if (this.state.editMode) {
|
||
statusText.textContent = 'Editing';
|
||
statusDot.className = 'insertr-status-dot insertr-status-editing';
|
||
} else {
|
||
statusText.textContent = 'Authenticated';
|
||
statusDot.className = 'insertr-status-dot insertr-status-authenticated';
|
||
}
|
||
|
||
|
||
}
|
||
|
||
/**
|
||
* Check if user is authenticated
|
||
*/
|
||
isAuthenticated() {
|
||
return this.state.isAuthenticated;
|
||
}
|
||
|
||
/**
|
||
* Check if edit mode is enabled
|
||
*/
|
||
isEditMode() {
|
||
return this.state.editMode;
|
||
}
|
||
|
||
/**
|
||
* Get current user info
|
||
*/
|
||
getCurrentUser() {
|
||
return this.state.currentUser;
|
||
}
|
||
|
||
/**
|
||
* Add minimal styles for editor gates
|
||
*/
|
||
addGateStyles() {
|
||
const styles = `
|
||
.insertr-gate {
|
||
transition: opacity 0.2s ease;
|
||
user-select: none;
|
||
}
|
||
|
||
.insertr-gate:hover {
|
||
opacity: 0.7;
|
||
}
|
||
|
||
/* Optional: Hide gates when authenticated (only if hideGatesAfterAuth option is true) */
|
||
body.insertr-hide-gates .insertr-gate {
|
||
display: none !important;
|
||
}
|
||
`;
|
||
|
||
const styleSheet = document.createElement('style');
|
||
styleSheet.type = 'text/css';
|
||
styleSheet.innerHTML = styles;
|
||
document.head.appendChild(styleSheet);
|
||
}
|
||
|
||
/**
|
||
* Add styles for authentication controls
|
||
*/
|
||
addControlStyles() {
|
||
const styles = `
|
||
.insertr-auth-controls {
|
||
position: fixed;
|
||
bottom: 20px;
|
||
right: 20px;
|
||
z-index: 9999;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
}
|
||
|
||
.insertr-auth-btn {
|
||
background: #4f46e5;
|
||
color: white;
|
||
border: none;
|
||
padding: 8px 16px;
|
||
border-radius: 6px;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||
}
|
||
|
||
.insertr-auth-btn:hover {
|
||
background: #4338ca;
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||
}
|
||
|
||
.insertr-auth-btn.insertr-authenticated {
|
||
background: #059669;
|
||
}
|
||
|
||
.insertr-auth-btn.insertr-authenticated:hover {
|
||
background: #047857;
|
||
}
|
||
|
||
.insertr-auth-btn.insertr-edit-active {
|
||
background: #dc2626;
|
||
}
|
||
|
||
.insertr-auth-btn.insertr-edit-active:hover {
|
||
background: #b91c1c;
|
||
}
|
||
|
||
.insertr-status-controls {
|
||
position: fixed;
|
||
bottom: 20px;
|
||
left: 20px;
|
||
z-index: 9999;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
}
|
||
|
||
.insertr-status {
|
||
background: white;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 8px;
|
||
padding: 8px 12px;
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||
max-width: 200px;
|
||
}
|
||
|
||
.insertr-status-content {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.insertr-status-text {
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
color: #374151;
|
||
}
|
||
|
||
.insertr-status-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
background: #9ca3af;
|
||
}
|
||
|
||
.insertr-status-dot.insertr-status-visitor {
|
||
background: #9ca3af;
|
||
}
|
||
|
||
.insertr-status-dot.insertr-status-authenticated {
|
||
background: #059669;
|
||
}
|
||
|
||
.insertr-status-dot.insertr-status-editing {
|
||
background: #dc2626;
|
||
animation: insertr-pulse 2s infinite;
|
||
}
|
||
|
||
@keyframes insertr-pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.5; }
|
||
}
|
||
|
||
/* Hide editing interface when not in edit mode */
|
||
body:not(.insertr-edit-mode) .insertr:hover::after {
|
||
display: none !important;
|
||
}
|
||
|
||
/* Only show editing features when in edit mode */
|
||
.insertr-authenticated.insertr-edit-mode .insertr {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.insertr-authenticated.insertr-edit-mode .insertr:hover {
|
||
outline: 2px dashed #007cba !important;
|
||
outline-offset: 2px !important;
|
||
background-color: rgba(0, 124, 186, 0.05) !important;
|
||
}
|
||
`;
|
||
|
||
const styleSheet = document.createElement('style');
|
||
styleSheet.type = 'text/css';
|
||
styleSheet.innerHTML = styles;
|
||
document.head.appendChild(styleSheet);
|
||
}
|
||
|
||
/**
|
||
* OAuth integration placeholder
|
||
* In production, this would handle real OAuth flows
|
||
*/
|
||
async authenticateWithOAuth(provider = 'google') {
|
||
// Mock OAuth flow for now
|
||
console.log(`🔐 Mock OAuth login with ${provider}`);
|
||
|
||
// Simulate OAuth callback
|
||
setTimeout(() => {
|
||
this.state.isAuthenticated = true;
|
||
this.state.currentUser = {
|
||
name: 'OAuth User',
|
||
email: 'user@example.com',
|
||
provider: provider,
|
||
role: 'editor'
|
||
};
|
||
|
||
this.updateBodyClasses();
|
||
this.updateButtonStates();
|
||
this.updateStatusIndicator();
|
||
|
||
console.log('✅ OAuth authentication successful');
|
||
}, 1000);
|
||
}
|
||
|
||
/**
|
||
* Setup enhance button functionality
|
||
*/
|
||
setupEnhanceButton() {
|
||
const enhanceBtn = document.getElementById('insertr-enhance-btn');
|
||
if (!enhanceBtn) return;
|
||
|
||
enhanceBtn.addEventListener('click', async () => {
|
||
await this.enhanceFiles();
|
||
});
|
||
|
||
// Show enhance button only in development/authenticated mode
|
||
this.updateEnhanceButtonVisibility();
|
||
}
|
||
|
||
/**
|
||
* Update enhance button visibility based on authentication state
|
||
*/
|
||
updateEnhanceButtonVisibility() {
|
||
const enhanceBtn = document.getElementById('insertr-enhance-btn');
|
||
if (!enhanceBtn) return;
|
||
|
||
// Show enhance button when authenticated (indicates dev mode)
|
||
if (this.state.isAuthenticated) {
|
||
enhanceBtn.style.display = 'inline-block';
|
||
} else {
|
||
enhanceBtn.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Trigger manual file enhancement
|
||
*/
|
||
async enhanceFiles() {
|
||
const enhanceBtn = document.getElementById('insertr-enhance-btn');
|
||
if (!enhanceBtn) return;
|
||
|
||
// Get site ID from window context or configuration
|
||
const siteId = window.insertrConfig?.siteId || this.options.siteId || 'demo';
|
||
|
||
try {
|
||
// Show loading state
|
||
enhanceBtn.textContent = '⏳ Enhancing...';
|
||
enhanceBtn.disabled = true;
|
||
|
||
// Smart server detection for enhance API (same logic as ApiClient)
|
||
const isDevelopment = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||
const enhanceUrl = isDevelopment
|
||
? `http://localhost:8080/api/enhance?site_id=${siteId}` // Development: separate API server
|
||
: `/api/enhance?site_id=${siteId}`; // Production: same-origin API
|
||
|
||
// Call enhance API
|
||
const response = await fetch(enhanceUrl, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${this.state.currentUser?.token || 'mock-token'}`
|
||
}
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`Enhancement failed: ${response.status} ${response.statusText}`);
|
||
}
|
||
|
||
const result = await response.json();
|
||
console.log('✅ Files enhanced successfully:', result);
|
||
|
||
// Show success state briefly
|
||
enhanceBtn.textContent = '✅ Enhanced!';
|
||
|
||
// Reset button after success (no page reload needed)
|
||
setTimeout(() => {
|
||
enhanceBtn.textContent = '🔄 Enhance';
|
||
enhanceBtn.disabled = false;
|
||
}, 2000);
|
||
|
||
} catch (error) {
|
||
console.error('❌ Enhancement failed:', error);
|
||
enhanceBtn.textContent = '❌ Failed';
|
||
|
||
// Reset button after error
|
||
setTimeout(() => {
|
||
enhanceBtn.textContent = '🔄 Enhance';
|
||
enhanceBtn.disabled = false;
|
||
}, 2000);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* ApiClient - Handle communication with content API
|
||
*/
|
||
class ApiClient {
|
||
constructor(options = {}) {
|
||
// Smart server detection based on environment
|
||
const isDevelopment = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||
const defaultEndpoint = isDevelopment
|
||
? 'http://localhost:8080/api/content' // Development: separate API server
|
||
: '/api/content'; // Production: same-origin API
|
||
|
||
this.baseUrl = options.apiEndpoint || defaultEndpoint;
|
||
this.siteId = options.siteId || 'demo';
|
||
|
||
// Log API configuration in development
|
||
if (isDevelopment && !options.apiEndpoint) {
|
||
console.log(`🔌 API Client: Using development server at ${this.baseUrl}`);
|
||
}
|
||
}
|
||
|
||
async getContent(contentId) {
|
||
try {
|
||
const response = await fetch(`${this.baseUrl}/${contentId}?site_id=${this.siteId}`);
|
||
return response.ok ? await response.json() : null;
|
||
} catch (error) {
|
||
console.warn('Failed to fetch content:', contentId, error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
|
||
async createContent(content, type, htmlMarkup) {
|
||
try {
|
||
const payload = {
|
||
html_markup: htmlMarkup, // Always send HTML markup - server extracts ID or generates new one
|
||
value: content,
|
||
type: type,
|
||
file_path: this.getCurrentFilePath() // Always include file path for consistent ID generation
|
||
};
|
||
|
||
const response = await fetch(`${this.baseUrl}?site_id=${this.siteId}`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${this.getAuthToken()}`
|
||
},
|
||
body: JSON.stringify(payload)
|
||
});
|
||
|
||
if (response.ok) {
|
||
const result = await response.json();
|
||
console.log(`✅ Content created: ${result.id} (${result.type})`);
|
||
return result;
|
||
} else {
|
||
console.warn(`⚠️ Create failed (${response.status}): server will generate ID`);
|
||
return null;
|
||
}
|
||
} catch (error) {
|
||
if (error.name === 'TypeError' && error.message.includes('fetch')) {
|
||
console.warn(`🔌 API Server not reachable at ${this.baseUrl}`);
|
||
console.warn('💡 Start full-stack development: just dev');
|
||
} else {
|
||
console.error('Failed to create content:', error);
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async getContentVersions(contentId) {
|
||
try {
|
||
const response = await fetch(`${this.baseUrl}/${contentId}/versions?site_id=${this.siteId}`);
|
||
if (response.ok) {
|
||
const result = await response.json();
|
||
return result.versions || [];
|
||
} else {
|
||
console.warn(`⚠️ Failed to fetch versions (${response.status}): ${contentId}`);
|
||
return [];
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to fetch version history:', contentId, error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async rollbackContent(contentId, versionId) {
|
||
try {
|
||
const response = await fetch(`${this.baseUrl}/${contentId}/rollback?site_id=${this.siteId}`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${this.getAuthToken()}`
|
||
},
|
||
body: JSON.stringify({
|
||
version_id: versionId
|
||
})
|
||
});
|
||
|
||
if (response.ok) {
|
||
console.log(`✅ Content rolled back: ${contentId} to version ${versionId}`);
|
||
return await response.json();
|
||
} else {
|
||
console.warn(`⚠️ Rollback failed (${response.status}): ${contentId}`);
|
||
return false;
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to rollback content:', contentId, error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get authentication token for API requests
|
||
* @returns {string} JWT token or mock token for development
|
||
*/
|
||
getAuthToken() {
|
||
// Check if we have a real JWT token from OAuth
|
||
const realToken = this.getStoredToken();
|
||
if (realToken && !this.isTokenExpired(realToken)) {
|
||
return realToken;
|
||
}
|
||
|
||
// Development/mock token for when no real auth is present
|
||
return this.getMockToken();
|
||
}
|
||
|
||
/**
|
||
* Get current user information from token
|
||
* @returns {string} User identifier
|
||
*/
|
||
getCurrentUser() {
|
||
const token = this.getAuthToken();
|
||
|
||
// If it's a mock token, return mock user
|
||
if (token.startsWith('mock-')) {
|
||
return 'anonymous';
|
||
}
|
||
|
||
// Parse real JWT token for user info
|
||
try {
|
||
const payload = this.parseJWT(token);
|
||
return payload.sub || payload.user_id || payload.email || 'anonymous';
|
||
} catch (error) {
|
||
console.warn('Failed to parse JWT token:', error);
|
||
return 'anonymous';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get stored JWT token from localStorage/sessionStorage
|
||
* @returns {string|null} Stored JWT token
|
||
*/
|
||
getStoredToken() {
|
||
// Try localStorage first (persistent), then sessionStorage (session-only)
|
||
return localStorage.getItem('insertr_auth_token') ||
|
||
sessionStorage.getItem('insertr_auth_token') ||
|
||
null;
|
||
}
|
||
|
||
/**
|
||
* Store JWT token for future requests
|
||
* @param {string} token - JWT token from OAuth provider
|
||
* @param {boolean} persistent - Whether to use localStorage (true) or sessionStorage (false)
|
||
*/
|
||
setStoredToken(token, persistent = true) {
|
||
const storage = persistent ? localStorage : sessionStorage;
|
||
storage.setItem('insertr_auth_token', token);
|
||
|
||
// Clear the other storage to avoid conflicts
|
||
const otherStorage = persistent ? sessionStorage : localStorage;
|
||
otherStorage.removeItem('insertr_auth_token');
|
||
}
|
||
|
||
/**
|
||
* Clear stored authentication token
|
||
*/
|
||
clearStoredToken() {
|
||
localStorage.removeItem('insertr_auth_token');
|
||
sessionStorage.removeItem('insertr_auth_token');
|
||
}
|
||
|
||
/**
|
||
* Generate mock JWT token for development/testing
|
||
* @returns {string} Mock JWT token
|
||
*/
|
||
getMockToken() {
|
||
// Create a mock JWT-like token for development
|
||
// Format: mock-{user}-{timestamp}-{random}
|
||
const user = 'anonymous';
|
||
const timestamp = Date.now();
|
||
const random = Math.random().toString(36).substr(2, 9);
|
||
return `mock-${user}-${timestamp}-${random}`;
|
||
}
|
||
|
||
/**
|
||
* Parse JWT token payload
|
||
* @param {string} token - JWT token
|
||
* @returns {object} Parsed payload
|
||
*/
|
||
parseJWT(token) {
|
||
if (token.startsWith('mock-')) {
|
||
// Return mock payload for development tokens
|
||
return {
|
||
sub: 'anonymous',
|
||
user_id: 'anonymous',
|
||
email: 'anonymous@localhost',
|
||
iss: 'insertr-dev',
|
||
exp: Date.now() + 24 * 60 * 60 * 1000 // 24 hours from now
|
||
};
|
||
}
|
||
|
||
try {
|
||
// Parse real JWT token
|
||
const parts = token.split('.');
|
||
if (parts.length !== 3) {
|
||
throw new Error('Invalid JWT format');
|
||
}
|
||
|
||
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
|
||
return payload;
|
||
} catch (error) {
|
||
throw new Error(`Failed to parse JWT token: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check if JWT token is expired
|
||
* @param {string} token - JWT token
|
||
* @returns {boolean} True if token is expired
|
||
*/
|
||
isTokenExpired(token) {
|
||
try {
|
||
const payload = this.parseJWT(token);
|
||
const now = Math.floor(Date.now() / 1000);
|
||
return payload.exp && payload.exp < now;
|
||
} catch (error) {
|
||
// If we can't parse the token, consider it expired
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Initialize OAuth flow with provider (Google, GitHub, etc.)
|
||
* @param {string} provider - OAuth provider ('google', 'github', etc.)
|
||
* @returns {Promise<boolean>} Success status
|
||
*/
|
||
async initiateOAuth(provider = 'google') {
|
||
// This will be implemented when we add real OAuth integration
|
||
console.log(`🔐 OAuth flow with ${provider} not yet implemented`);
|
||
console.log('💡 For now, using mock authentication in development');
|
||
|
||
// Store a mock token for development
|
||
const mockToken = this.getMockToken();
|
||
this.setStoredToken(mockToken, true);
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Handle OAuth callback after user returns from provider
|
||
* @param {URLSearchParams} urlParams - URL parameters from OAuth callback
|
||
* @returns {Promise<boolean>} Success status
|
||
*/
|
||
async handleOAuthCallback(urlParams) {
|
||
// This will be implemented when we add real OAuth integration
|
||
const code = urlParams.get('code');
|
||
urlParams.get('state');
|
||
|
||
if (code) {
|
||
console.log('🔐 OAuth callback received, exchanging code for token...');
|
||
// TODO: Exchange authorization code for JWT token
|
||
// const token = await this.exchangeCodeForToken(code, state);
|
||
// this.setStoredToken(token, true);
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Get current file path from URL for consistent ID generation
|
||
* @returns {string} File path like "index.html", "about.html"
|
||
*/
|
||
getCurrentFilePath() {
|
||
const path = window.location.pathname;
|
||
if (path === '/' || path === '') {
|
||
return 'index.html';
|
||
}
|
||
// Remove leading slash: "/about.html" → "about.html"
|
||
return path.replace(/^\//, '');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Insertr - The Tailwind of CMS
|
||
* Main library entry point
|
||
*/
|
||
|
||
|
||
// Create global Insertr instance
|
||
window.Insertr = {
|
||
// Core functionality
|
||
core: null,
|
||
editor: null,
|
||
auth: null,
|
||
apiClient: null,
|
||
|
||
// Initialize the library
|
||
init(options = {}) {
|
||
console.log('🔧 Insertr v1.0.0 initializing... (Hot Reload Ready)');
|
||
|
||
this.core = new InsertrCore(options);
|
||
this.auth = new InsertrAuth(options);
|
||
this.apiClient = new ApiClient(options);
|
||
this.editor = new InsertrEditor(this.core, this.auth, this.apiClient, options);
|
||
|
||
// Auto-initialize if DOM is ready
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', () => this.start());
|
||
} else {
|
||
this.start();
|
||
}
|
||
|
||
return this;
|
||
},
|
||
|
||
// Start the system - only creates the minimal trigger
|
||
start() {
|
||
if (this.auth) {
|
||
this.auth.init(); // Creates footer trigger only
|
||
}
|
||
// Note: Editor is NOT started here, only when trigger is clicked
|
||
},
|
||
|
||
// Start the full editor system (called when trigger is activated)
|
||
startEditor() {
|
||
if (this.editor && !this.editor.isActive) {
|
||
this.editor.start();
|
||
}
|
||
},
|
||
|
||
// Public API methods
|
||
login() {
|
||
return this.auth ? this.auth.toggleAuthentication() : null;
|
||
},
|
||
|
||
logout() {
|
||
if (this.auth && this.auth.isAuthenticated()) {
|
||
this.auth.toggleAuthentication();
|
||
}
|
||
},
|
||
|
||
toggleEditMode() {
|
||
return this.auth ? this.auth.toggleEditMode() : null;
|
||
},
|
||
|
||
isAuthenticated() {
|
||
return this.auth ? this.auth.isAuthenticated() : false;
|
||
},
|
||
|
||
isEditMode() {
|
||
return this.auth ? this.auth.isEditMode() : false;
|
||
},
|
||
|
||
// TODO: Version info based on package.json?
|
||
};
|
||
|
||
// Auto-initialize in development mode with proper DOM ready handling
|
||
function autoInitialize() {
|
||
if (document.querySelector('.insertr')) {
|
||
window.Insertr.init();
|
||
}
|
||
}
|
||
|
||
// Run auto-initialization when DOM is ready
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', autoInitialize);
|
||
} else {
|
||
// DOM is already ready
|
||
autoInitialize();
|
||
}
|
||
|
||
var index = window.Insertr;
|
||
|
||
return index;
|
||
|
||
})();
|