// — Renders Markdown source as HTML inside Shadow DOM. // // Attributes: // source — Raw markdown string to render. // mode — "view" (default) renders HTML; "edit" shows a textarea. // // Properties: // source — Same as attribute; setting updates the rendered output. // mode — "view" | "edit". Toggling re-renders the component. // // Events: // ars-markdown:change — Emitted in edit mode when the user modifies // the textarea. detail: { source: string }. class ArsMarkdown extends HTMLElement { static get observedAttributes() { return ["source", "mode"]; } #source = ""; #mode: "view" | "edit" = "view"; constructor() { super(); this.attachShadow({ mode: "open" }); } connectedCallback() { this.#render(); } attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null) { if (name === "source" && newValue !== null) { this.source = newValue; } if (name === "mode") { this.mode = (newValue === "edit") ? "edit" : "view"; } } get source(): string { return this.#source; } set source(value: string) { this.#source = value; if (this.shadowRoot) { this.#render(); } } get mode(): "view" | "edit" { return this.#mode; } set mode(value: "view" | "edit") { if (this.#mode === value) return; this.#mode = value; if (this.shadowRoot) { this.#render(); } } #render() { if (!this.shadowRoot) return; if (this.#mode === "edit") { this.shadowRoot.innerHTML = ` `; const textarea = this.shadowRoot.querySelector("textarea") as HTMLTextAreaElement; if (textarea) { textarea.addEventListener("input", () => { this.#source = textarea.value; this.dispatchEvent( new CustomEvent("ars-markdown:change", { detail: { source: this.#source }, bubbles: true, composed: true, }) ); }); } } else { this.shadowRoot.innerHTML = `
`; const body = this.shadowRoot.querySelector(".markdown-body") as HTMLDivElement; if (body) { body.innerHTML = this.#parseMarkdown(this.#source); } } } #escapeHtml(src: string): string { return src .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } /** Minimal markdown parser covering headings, paragraphs, bold, italic, code, * links, lists, and horizontal rules. No external dependency so the * component stays self-contained. */ #parseMarkdown(src: string): string { let html = src .replace(/&/g, "&") .replace(//g, ">"); // Horizontal rules html = html.replace(/^(---+|===+|\*\*\*+)$/gm, '
'); // Headings html = html.replace(/^###### (.*$)/gim, '
$1
'); html = html.replace(/^##### (.*$)/gim, '
$1
'); html = html.replace(/^#### (.*$)/gim, '

$1

'); html = html.replace(/^### (.*$)/gim, '

$1

'); html = html.replace(/^## (.*$)/gim, '

$1

'); html = html.replace(/^# (.*$)/gim, '

$1

'); // Bold / italic html = html.replace(/\*\*\*(.*?)\*\*\*/g, '$1'); html = html.replace(/\*\*(.*?)\*\*/g, '$1'); html = html.replace(/\*(.*?)\*/g, '$1'); html = html.replace(/__(.*?)__/g, '$1'); html = html.replace(/_(.*?)_/g, '$1'); // Inline code html = html.replace(/`([^`]+)`/g, '$1'); // Code blocks html = html.replace(/```[\s\S]*?```/g, (match) => { const code = match.slice(3, -3).trim(); return `
${code}
`; }); // Links [text](url) html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); // Unordered lists html = html.replace(/^(\s*)[-*+] (.+$)/gim, (match, indent, text) => { const depth = indent.length; return `${indent}
  • ${text}
  • `; }); // Ordered lists html = html.replace(/^(\s*)\d+\. (.+$)/gim, (match, indent, text) => { return `${indent}
  • ${text}
  • `; }); // Wrap consecutive
  • elements in