diff --git a/.gitignore b/.gitignore index 3ea0fe1..d04f8a3 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,8 @@ Thumbs.db # Editor directories and files .vscode/ +# Hugo generates this for assets/js (see assets/js/chatbot) +**/jsconfig.json .idea/ *.swp *.swo diff --git a/assets/css/chatbot.css b/assets/css/chatbot.css new file mode 100644 index 0000000..5109213 --- /dev/null +++ b/assets/css/chatbot.css @@ -0,0 +1,517 @@ +/* Chatbot CSS - Animations and Dynamic Content Styling */ + +/* ===== Animations ===== */ +@keyframes dialogSlideIn { + from { + opacity: 0; + transform: scale(0.95) translateY(10px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +@keyframes messageSlideIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes bounceSingle { + 0% { + transform: translateY(0); + } + 50% { + transform: translateY(-3px); + } + 100% { + transform: translateY(0); + } +} + +@keyframes blink { + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0; } +} + + +@keyframes codeLine { + from { + opacity: 0; + transform: translateY(-2px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ===== Component States ===== */ +.chatbot-dialog.expanded { + height: 80vh; +} + +/* Unified input card focus glow */ +.chatbot-input-card:focus-within { + border-color: #8b5cf6; + box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.15); +} + +/* The card above already shows the focus state via :focus-within. The host + (Hextra) theme adds its own :focus-visible ring to every form field — including + this textarea — which renders as a redundant second box tight around the input. + Suppress it on the textarea only; the card's focus-within glow is unaffected. */ +.chatbot-input-card textarea:focus, +.chatbot-input-card textarea:focus-visible { + outline: none; + box-shadow: none; + --tw-ring-shadow: 0 0 #0000; + --tw-ring-offset-shadow: 0 0 #0000; +} + +/* The widget's textareas carry a `font-inherit` class, which is NOT a Tailwind + utility and generates nothing (it is inherited from solo-io/docs, where it is + equally inert). Without this rule the browser's default textarea font wins, + so state the intent in CSS instead. */ +.chatbot-dialog textarea { + font-family: inherit; +} + +/* User message - shrink-wrap to text content */ +.chatbot-message.user { + width: fit-content; + max-width: 100%; + word-break: break-word; + overflow-wrap: break-word; + background: linear-gradient(135deg, #7c3aed, #6d28d9); + color: #fff; + box-shadow: 0 2px 8px rgba(124, 58, 237, 0.2); +} + +.dark .chatbot-message.user { + box-shadow: 0 2px 8px rgba(124, 58, 237, 0.3); +} + +/* ===== Avatar Loading Animation ===== */ +.chatbot-avatar.loading img { + width: 75%; + height: 75%; +} + +.chatbot-avatar.loading::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 30px; + border: 2px solid transparent; + border-top-color: #7c3aed; + border-right-color: #7c3aed; + animation: spin 0.8s linear infinite; +} + +.dark .chatbot-avatar.loading::after { + border-top-color: #a78bfa; + border-right-color: #a78bfa; +} + +/* ===== Thinking Animation ===== */ +.thinking-dots { + color: #7c3aed; + font-size: 1.5rem; +} + +.dark .thinking-dots { + color: #a78bfa; +} + +.thinking-dots .dot { + display: inline-block; + transition: transform 0.3s ease-in-out; +} + +.thinking-dots .dot.bouncing { + animation: bounceSingle 0.6s ease-in-out; +} + +/* ===== Code Block Animations ===== */ +.chatbot-code-cursor { + display: inline-block; + width: 8px; + height: 1em; + background: #7c3aed; + margin-left: 2px; + vertical-align: text-bottom; + animation: blink 1s step-end infinite; +} + +.dark .chatbot-code-cursor { + background: #a78bfa; +} + +.chatbot-code-line { + display: block; + animation: codeLine 0.15s ease-out forwards; +} + +/* ===== Scrollbar Styling ===== */ +.chatbot-messages { + scrollbar-width: thin; + scrollbar-color: rgba(156, 163, 175, 0.3) transparent; +} + +.chatbot-messages::-webkit-scrollbar { + width: 6px; +} + +.chatbot-messages::-webkit-scrollbar-track { + background: transparent; +} + +.chatbot-messages::-webkit-scrollbar-thumb { + background: rgba(156, 163, 175, 0.3); + border-radius: 3px; +} + +.chatbot-messages::-webkit-scrollbar-thumb:hover { + background: rgba(156, 163, 175, 0.5); +} + +.dark .chatbot-messages { + scrollbar-color: rgba(107, 114, 128, 0.3) transparent; +} + +.dark .chatbot-messages::-webkit-scrollbar-thumb { + background: rgba(107, 114, 128, 0.3); +} + +.dark .chatbot-messages::-webkit-scrollbar-thumb:hover { + background: rgba(107, 114, 128, 0.5); +} + +.chatbot-dialog textarea { + scrollbar-width: thin; + scrollbar-color: rgba(156, 163, 175, 0.3) transparent; +} + +.chatbot-dialog textarea::-webkit-scrollbar { + width: 6px; +} + +.chatbot-dialog textarea::-webkit-scrollbar-track { + background: transparent; +} + +.chatbot-dialog textarea::-webkit-scrollbar-thumb { + background: rgba(156, 163, 175, 0.3); + border-radius: 3px; +} + +.chatbot-dialog textarea::-webkit-scrollbar-thumb:hover { + background: rgba(156, 163, 175, 0.5); +} + +.dark .chatbot-dialog textarea { + scrollbar-color: rgba(107, 114, 128, 0.3) transparent; +} + +.dark .chatbot-dialog textarea::-webkit-scrollbar-thumb { + background: rgba(107, 114, 128, 0.3); +} + +.dark .chatbot-dialog textarea::-webkit-scrollbar-thumb:hover { + background: rgba(107, 114, 128, 0.5); +} + +/* ===== Markdown Content Styling ===== */ +.chatbot-response { + font-size: 0.875rem; + line-height: 1.6; + color: #111827; +} + +.dark .chatbot-response { + color: #e5e7eb; +} + +.chatbot-response h1, +.chatbot-response h2, +.chatbot-response h3, +.chatbot-response h4 { + margin-top: 1.25rem; + margin-bottom: 0.5rem; + font-weight: 600; + line-height: 1.3; + color: #111827; +} + +.dark .chatbot-response h1, +.dark .chatbot-response h2, +.dark .chatbot-response h3, +.dark .chatbot-response h4 { + color: #f9fafb; +} + +.chatbot-response h1 { font-size: 1.375rem; } +.chatbot-response h2 { font-size: 1.25rem; } +.chatbot-response h3 { font-size: 1.125rem; } +.chatbot-response h4 { font-size: 1rem; } + +.chatbot-response h1:first-child, +.chatbot-response h2:first-child, +.chatbot-response h3:first-child, +.chatbot-response h4:first-child { + margin-top: 0; +} + +.chatbot-response p { + margin: 0.625rem 0; +} + +.chatbot-response p:first-child { + margin-top: 0; +} + +.chatbot-response p:last-child { + margin-bottom: 0; +} + +.chatbot-response ul, +.chatbot-response ol { + margin: 0.625rem 0; + padding-left: 1.5rem; +} + +.chatbot-response li { + margin: 0.25rem 0; +} + +.chatbot-response code { + background: #f3f4f6; + padding: 0.125rem 0.375rem; + border-radius: 4px; + font-size: 0.875em; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + border: none; +} + +.dark .chatbot-response code { + background: #374151; +} + +.chatbot-response pre { + background: #f6f8fa; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 1rem; + overflow-x: auto; + margin: 0.75rem 0; +} + +.dark .chatbot-response pre { + background: #0d1117; + border-color: #30363d; +} + +.chatbot-response pre code { + background: transparent; + padding: 0; + font-size: 0.8125rem; + line-height: 1.5; + color: #24292f; + border: none; + border-radius: 0; + display: block; +} + +.dark .chatbot-response pre code { + color: #e6edf3; + background: transparent; + border: none; +} + +.chatbot-response blockquote { + border-left: 3px solid #7c3aed; + padding-left: 1rem; + margin: 0.75rem 0; + color: #6b7280; +} + +.dark .chatbot-response blockquote { + color: #9ca3af; +} + +.chatbot-response a { + color: #7c3aed; + text-decoration: none; +} + +.chatbot-response a:hover { + text-decoration: underline; +} + +.chatbot-response table { + border-collapse: collapse; + width: 100%; + margin: 0.75rem 0; + font-size: 0.875rem; +} + +.chatbot-response th, +.chatbot-response td { + border: 1px solid #e5e7eb; + padding: 0.5rem 0.75rem; + text-align: left; +} + +.dark .chatbot-response th, +.dark .chatbot-response td { + border-color: #374151; +} + +.chatbot-response th { + background: #f9fafb; + font-weight: 600; +} + +.dark .chatbot-response th { + background: #1f2937; +} + +.chatbot-response hr { + border: none; + border-top: 1px solid #e5e7eb; + margin: 1rem 0; +} + +.dark .chatbot-response hr { + border-top-color: #374151; +} + +/* ===== Code Copy Button ===== */ +.chatbot-code-block { + position: relative; +} + +.chatbot-code-copy { + position: absolute; + top: 0.5rem; + right: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + background: rgba(255, 255, 255, 0.9); + border: 1px solid #e5e7eb; + border-radius: 6px; + color: #6b7280; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s, color 0.15s, background 0.15s; + z-index: 1; +} + +.chatbot-code-block:hover .chatbot-code-copy { + opacity: 1; +} + +.chatbot-code-copy:hover, +.chatbot-code-copy.copied { + color: #7c3aed; + background: #fff; +} + +.dark .chatbot-code-copy { + background: rgba(17, 24, 39, 0.9); + border-color: #374151; + color: #9ca3af; +} + +.dark .chatbot-code-block:hover .chatbot-code-copy { + opacity: 1; +} + +.dark .chatbot-code-copy:hover, +.dark .chatbot-code-copy.copied { + color: #a78bfa; + background: #111827; +} + +/* ===== @ Mention Autocomplete ===== */ +.chatbot-mention-menu { + max-height: 280px; +} + +.chatbot-mention-list { + scrollbar-width: thin; + scrollbar-color: rgba(156, 163, 175, 0.3) transparent; +} + +.chatbot-mention-list::-webkit-scrollbar { + width: 5px; +} + +.chatbot-mention-list::-webkit-scrollbar-track { + background: transparent; +} + +.chatbot-mention-list::-webkit-scrollbar-thumb { + background: rgba(156, 163, 175, 0.3); + border-radius: 3px; +} + +.chatbot-mention-list::-webkit-scrollbar-thumb:hover { + background: rgba(156, 163, 175, 0.5); +} + +.dark .chatbot-mention-list { + scrollbar-color: rgba(107, 114, 128, 0.3) transparent; +} + +.dark .chatbot-mention-list::-webkit-scrollbar-thumb { + background: rgba(107, 114, 128, 0.3); +} + +.dark .chatbot-mention-list::-webkit-scrollbar-thumb:hover { + background: rgba(107, 114, 128, 0.5); +} + +.chatbot-mention-item.active, +.chatbot-mention-item:hover { + background: #f5f3ff; +} + +.dark .chatbot-mention-item.active, +.dark .chatbot-mention-item:hover { + background: rgba(139, 92, 246, 0.1); +} + +/* ===== Responsive Overrides ===== */ +@media (max-width: 640px) { + .chatbot-dialog { + height: 60vh; + max-height: 95vh; + border-radius: 12px; + } + + .chatbot-dialog.expanded { + height: 95vh; + } + + .chatbot-avatar { + width: 28px; + height: 28px; + } +} diff --git a/assets/css/vendor/hljs-github-dark.min.css b/assets/css/vendor/hljs-github-dark.min.css new file mode 100644 index 0000000..03b6da8 --- /dev/null +++ b/assets/css/vendor/hljs-github-dark.min.css @@ -0,0 +1,10 @@ +pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*! + Theme: GitHub Dark + Description: Dark theme as seen on github.com + Author: github.com + Maintainer: @Hirse + Updated: 2021-05-15 + + Outdated base version: https://github.com/primer/github-syntax-dark + Current colors taken from GitHub's CSS +*/.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#79c0ff}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-code,.hljs-comment,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c} \ No newline at end of file diff --git a/assets/css/vendor/hljs-github.min.css b/assets/css/vendor/hljs-github.min.css new file mode 100644 index 0000000..275239a --- /dev/null +++ b/assets/css/vendor/hljs-github.min.css @@ -0,0 +1,10 @@ +pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*! + Theme: GitHub + Description: Light theme as seen on github.com + Author: github.com + Maintainer: @Hirse + Updated: 2021-05-15 + + Outdated base version: https://github.com/primer/github-syntax-light + Current colors taken from GitHub's CSS +*/.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#005cc5}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-code,.hljs-comment,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0} \ No newline at end of file diff --git a/assets/js/chatbot/README.md b/assets/js/chatbot/README.md new file mode 100644 index 0000000..32def0e --- /dev/null +++ b/assets/js/chatbot/README.md @@ -0,0 +1,103 @@ +# Chatbot module + +The agentregistry docs assistant — the "Ask AI" widget on `/docs/` pages. + +Ported from the Solo Enterprise for agentgateway docs assistant +(`solo-io/docs`, `assets/js/chatbot/`). The logic is the same; see +[Differences from solo-io/docs](#differences-from-solo-iodocs). + +## Architecture + +``` +assets/js/chatbot/ +├── index.js # Entry point — reads the page index, renders +├── app.js # The Preact component: state, persistence, all markup +├── stream.js # ChatStreamer — SSE streaming from the assistant endpoint +├── ui.js # ThinkingAnimator + MarkdownRenderer (buffered streaming render) +└── markdown.js # marked + highlight.js + DOMPurify configuration +``` + +| Module | Responsibility | +| --- | --- | +| `index.js` | Reads `#chatbot-page-index` and the mount node's `data-favicon`, then renders `` into `#chatbot-widget`. Holds `AGENT_ENDPOINT` and `PRODUCTS`. | +| `app.js` | One Preact class component. Conversation state, `sessionStorage` persistence, page context, `@` mention autocomplete, feedback, export, and every piece of markup. | +| `stream.js` | An `EventSource`-shaped handle over `fetch()` + `ReadableStream`, so HTTP status codes (429 in particular) are visible before the stream opens. No UI concerns. | +| `ui.js` | `ThinkingAnimator` (the bouncing dots) and `MarkdownRenderer`, which buffers tokens and reveals an unfinished code block line by line. | +| `markdown.js` | Configures marked for GFM, highlights code with highlight.js, and sanitizes the result with DOMPurify before it reaches `dangerouslySetInnerHTML`. | + +## Dependencies + +All of them are vendored in [`assets/js/vendor/`](../vendor/README.md) and +bundled into one file, so the widget contacts no third-party origin at runtime: +preact + htm, marked, DOMPurify, and highlight.js (core plus the YAML grammar). + +The two highlight.js theme stylesheets stay separate, at `assets/css/vendor/`, +because the enabled one has to follow the reader's theme. Both are wired up in +[`layouts/_partials/chatbot.html`](../../../layouts/_partials/chatbot.html). + +## Highlighting: YAML only + +Only YAML is highlighted; every other code block is plain escaped text. The +reason is measured: highlight.js colors keywords, strings, variables and +comments, so a command line such as `arctl apply -f agent.yaml` yields zero +token spans no matter which grammar runs. This corpus is ~228 `sh` fences, ~55 +`console` and ~10 `yaml`, and Hugo's own Chroma output for those `sh` blocks is +equally plain — so plain shell in an answer matches the page around it. + +An untagged fence is treated as YAML when its first non-empty line is `---` or +has a `key:` shape. `highlightAuto()` must never be called: with one grammar +registered it would force YAML onto shell text. To add a language, vendor its +grammar and register it — see the header of `markdown.js`. + +## Build + +Hugo Pipes (esbuild) bundles the modules from the partial: + +```go-html-template +{{- $opts := dict "targetPath" "js/chatbot.bundle.js" "minify" hugo.IsProduction "target" "es2015" "format" "iife" -}} +{{- $js := resources.Get "js/chatbot/index.js" | js.Build $opts | fingerprint -}} +``` + +htm parses its templates with its own parser — no `new Function`, no runtime +eval — so the bundle runs under a `script-src` that forbids `'unsafe-eval'`. + +## Backend + +Queries go to `GET {AGENT_ENDPOINT}/query?q=…&product=…&sessionId=…&pages=…` and +stream back as SSE `token`, `done`, and `error` events. Feedback goes to +`POST /feedback` and `POST /feedback/comment`. + +`product` selects the documentation corpus. This site sends `agentregistry` (the +open source corpus); the enterprise corpus is `solo-enterprise-for-agentregistry`. +An id must match the backend's own list, which +`GET https://assistant.docs.solo.io/products` returns — an unknown id does **not** +error: the backend falls back to its default product and answers from the wrong +corpus. The `PRODUCTS` comment in `index.js` carries the full checklist for +adding the second corpus; the in-widget selector appears on its own once there +are two. + +## Differences from solo-io/docs + +- **Eligible URLs.** `DOCS_PATH_RE` in `app.js` matches `/docs/…`. The + agentgateway docs are versioned by path segment (`/latest/…`, `/2.3.x/…`), so + there that regex matched a version. `getVersionPrefix()` returns a constant + `/docs/` here for the same reason. +- **Tailwind.** solo-io/docs ships a generated utility sheet (`chatbot.tw.css`) + because its theme's Tailwind bundle is precompiled. This site compiles + Tailwind itself, and Tailwind v4's automatic source detection scans this + directory, so all 225 utility classes the widget renders at runtime already + land in `main.css` — no generated sheet and no `@source` line needed. They + never appear in Hugo's emitted HTML or in `hugo_stats.json`, so if a future + Tailwind or Hugo change narrows that detection, the widget loses its layout + wholesale; `@source "../js/chatbot"` in `assets/css/main.css` is the fix, but + note that Hugo's `css.TailwindCSS` did not honour that path when tried. +- **Where it renders.** The docs partial gates on the agentgateway product; this + one gates on `.Section == "docs"`. + +## Customization + +Endpoint and corpus: the `AGENT_ENDPOINT` and `PRODUCTS` constants in `index.js`. + +Animation timing and keyframes live in `assets/css/chatbot.css`: +`dialogSlideIn`, `spin` (avatar), `bounceSingle` (thinking dots), `blink` (code +cursor), and `codeLine` (code line reveal). diff --git a/assets/js/chatbot/app.js b/assets/js/chatbot/app.js new file mode 100644 index 0000000..ebf1ce1 --- /dev/null +++ b/assets/js/chatbot/app.js @@ -0,0 +1,1856 @@ +/** + * Chatbot - Preact component for the agentregistry docs assistant. + * + * Ported from the Solo Enterprise for agentgateway docs assistant + * (solo-io/docs assets/js/chatbot/app.js). htm parses its templates with its + * own parser (no `new Function`, no in-browser eval), so the whole widget is + * CSP-safe: it runs under a `script-src` that lacks 'unsafe-eval'. Everything + * ships inside the single Hugo `js.Build` (esbuild) bundle served from 'self'. + * + * State is persisted to sessionStorage so conversations survive full-page + * navigations in this Hugo static site. + */ + +import { html, Component } from '../vendor/preact-htm.module.js'; +import { ChatStreamer, ErrorType } from './stream.js'; +import { ThinkingAnimator, MarkdownRenderer } from './ui.js'; +import { parseMarkdown } from './markdown.js'; + +const STORAGE_KEY = 'chatbot-state'; +const INPUT_STORAGE_KEY = 'chatbot-input'; +const MAX_CONTEXT_PAGES = 3; +const INPUT_SAVE_DEBOUNCE_MS = 300; +const HISTORY_LIMIT_NOTE_AT = 3; // show history-limit note after this many user messages +const NEW_CHAT_TOOLTIP_AT = 10; // show new-chat note after this many user messages + +/** + * A URL is only eligible as a context page if it is same-origin AND sits under + * the docs tree. This site is flat and unversioned — every docs page lives at + * `/docs/
//` — so there is no version segment to match, unlike + * the versioned agentgateway docs this widget came from. The marketing pages + * outside /docs/ are not in the corpus, so they are not eligible. + */ +const DOCS_PATH_RE = /^\/docs(\/|$)/; + +// Delegated code-block copy button icons (buttons are injected into rendered +// markdown by the MarkdownRenderer, so they live outside Preact's control). +const COPY_ICON = ``; +const CHECK_ICON = ``; + +export class App extends Component { + constructor(props) { + super(props); + + this.state = { + // ── Visible state ──────────────────────────────────────── + isOpen: false, + isExpanded: false, + isProcessing: false, + userInput: '', + messages: [], + // Holds the active doc-workflow product id (see PRODUCTS in index.js), + // sent as `product` on every query. Named `selectedModel` historically. + selectedModel: props.defaultProduct, + contextPages: [], + + // ── Internal / UI state ────────────────────────────────── + showThinking: false, + sessionId: '', + showContextMenu: false, + showModelMenu: false, + historyLimitNoteShown: false, + historyLimitNoteIndex: -1, + showNewConvBanner: false, + + // Feedback + showFeedbackModal: false, + feedbackModalIndex: -1, + feedbackComment: '', + copiedMessageIndex: -1, + + // @ Mention + showMentionMenu: false, + mentionFilter: '', + mentionSelectedIndex: 0, + mentionStartPos: -1, + filteredMentionPages: [], + }; + + // Non-reactive helpers + this.pageIndex = props.pageIndex || []; + this.streamer = new ChatStreamer(props.agentEndpoint); + this.thinkingAnimator = new ThinkingAnimator(); + this.markdownRenderer = new MarkdownRenderer(parseMarkdown); + this.currentEventSource = null; + this._saveInputTimer = null; + this._pendingStreamTokens = ''; + this._streamRenderScheduled = false; + this._streamRenderRafId = null; + this._streamRenderTimer = null; + + // Element refs (set via callback refs) + this.inputEl = null; + this.messagesContainerEl = null; + this.spacerEl = null; + this.mentionListEl = null; + this.thinkingDotsEl = null; + this.feedbackInputEl = null; + this.contextWrapEl = null; + this.modelWrapEl = null; + } + + // ─── Lifecycle ───────────────────────────────────────────── + + componentDidMount() { + // Clear persisted state on hard refresh (F5 / Ctrl+R / refresh button) + const navEntry = performance.getEntriesByType('navigation')[0]; + if (navEntry && navEntry.type === 'reload') { + try { sessionStorage.removeItem(STORAGE_KEY); } catch (_) {} + } + + // Restore persisted conversation + textarea content into one setState. + const restored = this.readRestoredState(); + const savedInput = this.readSavedInput(); + const patch = { ...restored }; + if (savedInput) patch.userInput = savedInput; + + // First visit (no restored session): pick product from the URL. + if (!restored.sessionId) { + patch.selectedModel = this.detectProductFromPath(); + } + + this.setState(patch, () => { + if (this.state.userInput) this.autoResizeInput(); + if (this.state.isOpen) { + if (!this.state.sessionId) { + this.setState({ sessionId: this.generateSessionId() }); + } + requestAnimationFrame(() => { + this.autoResizeInput(); + if (this.inputEl) this.inputEl.focus(); + }); + } + }); + + window.addEventListener('keydown', this.onWindowKeydown); + window.addEventListener('beforeunload', this.onBeforeUnload); + document.addEventListener('click', this.onDocumentClick); + } + + componentWillUnmount() { + window.removeEventListener('keydown', this.onWindowKeydown); + window.removeEventListener('beforeunload', this.onBeforeUnload); + document.removeEventListener('click', this.onDocumentClick); + } + + onWindowKeydown = (e) => { + if (e.key === 'Escape') this.close(); + }; + + onBeforeUnload = () => { + this.flushInputSave(); + this.finalizeAndSave(); + }; + + // Close context / product menus when clicking outside their wrappers. + onDocumentClick = (e) => { + if (this.state.showContextMenu && this.contextWrapEl && !this.contextWrapEl.contains(e.target)) { + this.setState({ showContextMenu: false }); + } + if (this.state.showModelMenu && this.modelWrapEl && !this.modelWrapEl.contains(e.target)) { + this.setState({ showModelMenu: false }); + } + }; + + // Delegated handler for code-block copy buttons injected by the renderer. + handleRootClick = (e) => { + const btn = e.target.closest && e.target.closest('.chatbot-code-copy'); + if (!btn) return; + const code = decodeURIComponent(btn.dataset.code || ''); + navigator.clipboard.writeText(code).then(() => { + btn.innerHTML = CHECK_ICON; + btn.classList.add('copied'); + setTimeout(() => { + btn.innerHTML = COPY_ICON; + btn.classList.remove('copied'); + }, 2000); + }).catch(() => {}); + }; + + // ─── Persistence ─────────────────────────────────────────── + + /** + * Save conversation state to sessionStorage. + * Only finalized messages are persisted (no streaming / loading flags). + */ + saveState() { + try { + const s = this.state; + const state = { + isOpen: s.isOpen, + sessionId: s.sessionId, + selectedModel: s.selectedModel, + contextPages: s.contextPages, + messages: s.messages.map((msg) => ({ + role: msg.role, + content: msg.content, + markdown: msg.markdown || '', + isError: msg.isError || false, + isRateLimited: msg.isRateLimited || false, + contextPages: msg.contextPages || [], + feedback: msg.feedback || null + })) + }; + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch (e) { + console.warn('Chatbot: could not save state', e); + } + } + + /** + * Read persisted conversation state from sessionStorage into a state patch. + * Called once during mount. + */ + readRestoredState() { + const patch = {}; + try { + const raw = sessionStorage.getItem(STORAGE_KEY); + if (!raw) return patch; + + const state = JSON.parse(raw); + + if (state.sessionId) patch.sessionId = state.sessionId; + // Only honour a persisted selection that still maps to a known product; + // otherwise keep the default. Guards against stale ids cached from an + // earlier catalog (e.g. the legacy 'standalone'/'kubernetes' values). + if (state.selectedModel && this.props.products.some((p) => p.id === state.selectedModel)) { + patch.selectedModel = state.selectedModel; + } + + if (Array.isArray(state.contextPages)) { + patch.contextPages = state.contextPages; + } + + if (Array.isArray(state.messages) && state.messages.length > 0) { + patch.messages = state.messages.map((msg) => ({ + role: msg.role, + content: msg.content, + markdown: msg.markdown || '', + isError: msg.isError || false, + isRateLimited: msg.isRateLimited || false, + isStreaming: false, + isLoading: false, + showAvatar: msg.role === 'assistant', + contextPages: msg.contextPages || [], + feedback: msg.feedback || null + })); + patch.isExpanded = true; + } + + if (state.isOpen) patch.isOpen = true; + } catch (e) { + console.warn('Chatbot: could not restore state', e); + } + return patch; + } + + /** + * Debounced save of textarea input to localStorage. + * Removes the key when the input is empty. + */ + debouncedSaveInput() { + clearTimeout(this._saveInputTimer); + this._saveInputTimer = setTimeout(() => { + try { + if (this.state.userInput) { + localStorage.setItem(INPUT_STORAGE_KEY, this.state.userInput); + } else { + localStorage.removeItem(INPUT_STORAGE_KEY); + } + } catch (e) { + console.warn('Chatbot: could not save input state', e); + } + }, INPUT_SAVE_DEBOUNCE_MS); + } + + /** + * Immediately flush any pending debounced input save. + * Called on beforeunload to avoid losing data. + */ + flushInputSave() { + clearTimeout(this._saveInputTimer); + try { + if (this.state.userInput) { + localStorage.setItem(INPUT_STORAGE_KEY, this.state.userInput); + } else { + localStorage.removeItem(INPUT_STORAGE_KEY); + } + } catch (_) { /* ignore during unload */ } + } + + /** + * Read textarea input from localStorage. Called once during mount. + */ + readSavedInput() { + try { + return localStorage.getItem(INPUT_STORAGE_KEY) || ''; + } catch (e) { + console.warn('Chatbot: could not restore input state', e); + return ''; + } + } + + /** Clear saved textarea input and cancel any pending save. */ + clearSavedInput() { + clearTimeout(this._saveInputTimer); + try { + localStorage.removeItem(INPUT_STORAGE_KEY); + } catch (_) { /* ignore */ } + } + + /** + * Finalize any in-flight streaming message and save. + * Called on beforeunload to capture partial responses. + */ + finalizeAndSave() { + if (this.currentEventSource) { + this.currentEventSource.close(); + this.currentEventSource = null; + } + this.clearStreamRenderScheduler(); + const messages = this.state.messages; + const lastMsg = messages[messages.length - 1]; + if (lastMsg && lastMsg.isStreaming) { + try { + this.flushPendingStreamTokensSync(); + const htmlOut = this.markdownRenderer.flush(); + if (htmlOut) lastMsg.content = htmlOut; + lastMsg.markdown = this.markdownRenderer.getContent(); + } catch (_) { /* ignore flush errors during unload */ } + lastMsg.isStreaming = false; + lastMsg.isLoading = false; + } + this.setState({ isProcessing: false }); + this.saveState(); + } + + // ─── Open / Close / Reset ────────────────────────────────── + + toggle() { + this.setIsOpen(!this.state.isOpen); + } + + open() { + this.setIsOpen(true); + } + + close() { + if (this.state.showFeedbackModal) { + this.closeFeedbackModal(); + return; + } + this.setIsOpen(false); + } + + /** Central handler for isOpen transitions (was the Alpine $watch('isOpen')). */ + setIsOpen(open) { + if (open) { + const patch = { isOpen: true }; + if (!this.state.sessionId) patch.sessionId = this.generateSessionId(); + this.setState(patch, () => { + requestAnimationFrame(() => { + this.autoResizeInput(); + if (this.inputEl) this.inputEl.focus(); + }); + this.saveState(); + }); + } else { + this.stopActiveStream(); + this.setState({ + isOpen: false, + showContextMenu: false, + showModelMenu: false, + showMentionMenu: false, + mentionFilter: '', + mentionSelectedIndex: 0, + mentionStartPos: -1, + filteredMentionPages: [], + }, () => this.saveState()); + } + } + + /** + * Start a new conversation: clears messages, context, input, + * and generates a fresh session ID. + */ + newChat() { + this.stopActiveStream(); + this.thinkingAnimator.stop(); + this.markdownRenderer.reset(); + this.setState({ + messages: [], + isExpanded: false, + isProcessing: false, + showThinking: false, + contextPages: [], + showContextMenu: false, + showModelMenu: false, + showFeedbackModal: false, + feedbackModalIndex: -1, + feedbackComment: '', + showMentionMenu: false, + mentionFilter: '', + mentionSelectedIndex: 0, + mentionStartPos: -1, + filteredMentionPages: [], + historyLimitNoteShown: false, + historyLimitNoteIndex: -1, + showNewConvBanner: false, + sessionId: this.generateSessionId(), + }, () => { + this.saveState(); + requestAnimationFrame(() => { + this.autoResizeInput(); + if (this.inputEl) this.inputEl.focus(); + if (this.spacerEl) this.spacerEl.style.minHeight = '0'; + }); + }); + } + + /** + * Export the current conversation as a Markdown file download. + */ + exportChat() { + const messages = this.state.messages; + if (messages.length === 0) return; + + const lines = [ + '# agentregistry Assistant Conversation', + '', + `**Date:** ${new Date().toLocaleString()}`, + `**Model:** ${this.getModelLabel()}`, + '', + '---', + '' + ]; + + for (const msg of messages) { + if (msg.role === 'user') { + lines.push('## User', '', msg.content, ''); + } else if (msg.role === 'assistant') { + lines.push('## Assistant', ''); + if (msg.isError) { + lines.push(`> **Error:** ${msg.content}`); + } else if (msg.markdown) { + lines.push(msg.markdown); + } else { + const tmp = document.createElement('div'); + tmp.innerHTML = msg.content; + lines.push(tmp.innerText); + } + lines.push(''); + } + } + + const blob = new Blob([lines.join('\n')], { type: 'text/markdown;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `chat-export-${new Date().toISOString().slice(0, 10)}.md`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + /** + * Stop any active streaming connection without clearing messages. + */ + stopActiveStream() { + if (this.currentEventSource) { + this.currentEventSource.close(); + this.currentEventSource = null; + } + this.clearStreamRenderScheduler(); + const messages = this.state.messages; + const lastMsg = messages[messages.length - 1]; + if (lastMsg && lastMsg.isStreaming) { + lastMsg.isStreaming = false; + lastMsg.isLoading = false; + } + this.thinkingAnimator.stop(); + this.setState({ isProcessing: false, showThinking: false, messages }); + } + + // ─── URL Validation ──────────────────────────────────────── + + /** + * Check whether a URL is eligible as a context page. + * Must be same-origin AND under a recognised docs version path. + */ + isValidDocsUrl(url) { + try { + const parsed = new URL(url, window.location.origin); + if (parsed.hostname !== window.location.hostname) return false; + return DOCS_PATH_RE.test(parsed.pathname); + } catch (_) { + return false; + } + } + + // ─── Page Context ────────────────────────────────────────── + + /** + * Look up a human-readable title for a URL from the page index. + * Falls back to a breadcrumb derived from the last path segments. + */ + getTitleForUrl(url) { + try { + const pathname = new URL(url, window.location.origin).pathname.replace(/\/$/, ''); + const page = this.pageIndex.find( + (p) => p.url.replace(/\/$/, '') === pathname + ); + if (page) return page.title; + } catch (_) { /* fall through to breadcrumb */ } + + try { + const segments = new URL(url, window.location.origin) + .pathname.replace(/\/$/, '') + .split('/') + .filter(Boolean); + return segments.length === 0 ? 'Home page' : segments.slice(-2).join(' / '); + } catch (_) { + return 'Page'; + } + } + + /** + * Normalise a URL to a canonical form for deduplication: + * absolute URL with trailing slash, no query or hash. + */ + normaliseUrl(url) { + const parsed = new URL( + url.startsWith('http') + ? url + : window.location.origin + (url.startsWith('/') ? '' : '/') + url + ); + let pathname = parsed.pathname; + if (!pathname.endsWith('/')) pathname += '/'; + return parsed.origin + pathname; + } + + /** + * Add the current page to contextPages. + * Silently no-ops if the page is invalid or a duplicate. + * Pages beyond MAX_CONTEXT_PAGES are kept in the UI (shown as + * overflow pills) but are NOT sent in the API request. + */ + addCurrentPage() { + const url = window.location.href; + if (!this.isValidDocsUrl(url)) return; + const normalised = this.normaliseUrl(url); + if (this.state.contextPages.some((p) => p.url === normalised)) return; + const contextPages = [...this.state.contextPages, { title: this.getTitleForUrl(normalised), url: normalised }]; + this.setState({ contextPages }, () => this.saveState()); + } + + /** + * Add a page from the page index (via @ mention) to contextPages. + */ + addContextPage(page) { + const fullUrl = page.url.startsWith('http') + ? page.url + : window.location.origin + page.url; + if (this.state.contextPages.some((p) => p.url === fullUrl)) return; + const contextPages = [...this.state.contextPages, { title: page.title, url: fullUrl }]; + this.setState({ contextPages }, () => this.saveState()); + } + + /** + * Remove a context page by index. + */ + removeContextPage(index) { + const contextPages = this.state.contextPages.slice(); + contextPages.splice(index, 1); + this.setState({ contextPages }, () => this.saveState()); + } + + // ─── Paste & Drop ────────────────────────────────────────── + + /** + * Handle pasted content – if it's a plain URL paste, add as a pill. + */ + handlePaste(event) { + const text = (event.clipboardData && event.clipboardData.getData('text')) || ''; + const urls = this.extractUrls(text); + if (urls.length > 0 && text.trim() === urls[0]) { + event.preventDefault(); + urls.forEach((url) => this.addPastedUrl(url)); + } + } + + /** + * Handle dropped content – extract URLs and add as pills. + */ + handleDrop(event) { + event.preventDefault(); + event.stopPropagation(); + + const text = (event.dataTransfer && event.dataTransfer.getData('text')) || ''; + const htmlData = (event.dataTransfer && event.dataTransfer.getData('text/html')) || ''; + + // Try HTML first (drag from browser) + const urlFromHtml = this.extractUrlFromHtml(htmlData); + if (urlFromHtml) { this.addPastedUrl(urlFromHtml); return; } + + // Plain-text URLs + const urls = this.extractUrls(text); + if (urls.length > 0) { urls.forEach((u) => this.addPastedUrl(u)); return; } + + // Fallback: insert text at cursor + const pos = (this.inputEl && this.inputEl.selectionStart) || this.state.userInput.length; + const userInput = this.state.userInput.substring(0, pos) + text + this.state.userInput.substring(pos); + this.setState({ userInput }, () => { + this.autoResizeInput(); + this.debouncedSaveInput(); + }); + } + + handleDragOver(event) { + event.preventDefault(); + event.stopPropagation(); + } + + /** + * Extract valid docs URLs from plain text. + */ + extractUrls(text) { + const urlRe = /(https?:\/\/[^\s]+|\/docs\/[^\s]+)/gi; + return (text.match(urlRe) || []) + .map((u) => u.replace(/[,;.!?)]*$/, '')) + .filter((u) => u.length > 0 && this.isValidDocsUrl(u)); + } + + /** + * Extract a valid docs URL from an HTML href attribute. + */ + extractUrlFromHtml(htmlStr) { + try { + const m = htmlStr.match(/href=["']([^"']+)["']/); + if (m && this.isValidDocsUrl(m[1])) return m[1]; + } catch (_) { /* ignore */ } + return null; + } + + /** + * Add a URL (from paste/drop) as a context page pill. + */ + addPastedUrl(url) { + if (!this.isValidDocsUrl(url)) return; + try { + const fullUrl = this.normaliseUrl(url); + if (this.state.contextPages.some((p) => p.url === fullUrl)) return; + const contextPages = [...this.state.contextPages, { title: this.getTitleForUrl(fullUrl), url: fullUrl }]; + this.setState({ contextPages }, () => this.saveState()); + } catch (_) { /* invalid URL */ } + } + + // ─── @ Mention System ────────────────────────────────────── + + /** + * Check the textarea for an active @ mention trigger. + * Updates `filteredMentionPages` so the render can bind to a stable + * array instead of calling a filter function on every render. + */ + checkForMention() { + const input = this.inputEl; + if (!input) return; + + const cursorPos = input.selectionStart; + const text = this.state.userInput; + + // Walk backward from cursor to find '@' + let atPos = -1; + for (let i = cursorPos - 1; i >= 0; i--) { + if (text[i] === '@') { atPos = i; break; } + if (text[i] === '\n') break; + } + + if (atPos >= 0) { + const charBefore = atPos > 0 ? text[atPos - 1] : ' '; + if (atPos === 0 || /\s/.test(charBefore)) { + const filter = text.substring(atPos + 1, cursorPos); + this.setState({ + mentionStartPos: atPos, + mentionFilter: filter, + mentionSelectedIndex: 0, + filteredMentionPages: this.computeFilteredPages(filter), + showMentionMenu: true, + }); + return; + } + } + + this.closeMentionMenu(); + } + + /** + * The path prefix that the mention popup's page list is scoped to. + * + * On the versioned agentgateway docs this returned the current version + * segment, so a mention could only pull in pages from the version being + * read. This site has no versions, so the whole docs tree is one scope and + * the prefix is constant. The page index the partial emits is already + * limited to /docs/, so this is belt-and-braces. + */ + getVersionPrefix() { + return '/docs/'; + } + + /** + * Compute filtered page results for the mention popup. + * + * The current page is treated specially: + * - It is marked with `_isCurrentPage: true` and hoisted to the + * top of the list so users can quickly add the page they are on. + * - It is matched by its real title/section/url AND by the alias + * "current page", so typing "@current" will surface it. + * - There is always only ONE entry per page (no duplicates). + * - If the current page is already in contextPages it is omitted. + */ + computeFilteredPages(filter = '') { + const versionPrefix = this.getVersionPrefix(); + const scoped = versionPrefix + ? this.pageIndex.filter((p) => p.url.startsWith(versionPrefix)) + : this.pageIndex; + + // Identify the current page's pathname for matching + let currentPathname = null; + const currentUrl = window.location.href; + if (this.isValidDocsUrl(currentUrl)) { + const normalised = this.normaliseUrl(currentUrl); + // Only treat as current page if not already added + if (!this.state.contextPages.some((p) => p.url === normalised)) { + currentPathname = new URL(normalised).pathname; + } + } + + const terms = filter.toLowerCase().trim(); + const termList = terms ? terms.split(/\s+/) : []; + + // Helper: does a page match the filter terms? + const matchesFilter = (haystack) => + termList.length === 0 || termList.every((t) => haystack.includes(t)); + + let currentPageResult = null; + const otherResults = []; + + for (const page of scoped) { + const pagePath = page.url.replace(/\/$/, ''); + const isCurrentPage = + currentPathname && pagePath === currentPathname.replace(/\/$/, ''); + + const haystack = `${page.title} ${page.section} ${page.url}`.toLowerCase(); + // Current page also matches the alias "current page" + const fullHaystack = isCurrentPage + ? `current page ${haystack}` + : haystack; + + if (!matchesFilter(fullHaystack)) continue; + + if (isCurrentPage && !currentPageResult) { + currentPageResult = { ...page, _isCurrentPage: true }; + } else { + otherResults.push(page); + if (otherResults.length >= 8) break; + } + } + + // Hoist the current page to the top + if (currentPageResult) { + return [currentPageResult, ...otherResults.slice(0, 7)]; + } + return otherResults.slice(0, 8); + } + + /** + * Select a page from the mention popup: remove the @filter text + * from the textarea and add the page to contextPages. + */ + selectMention(page) { + const cursorPos = (this.inputEl && this.inputEl.selectionStart) || this.state.userInput.length; + const before = this.state.userInput.substring(0, this.state.mentionStartPos); + const after = this.state.userInput.substring(cursorPos); + const userInput = before + after; + + this.setState({ userInput }, () => { + if (page._isCurrentPage) { + this.addCurrentPage(); + } else { + this.addContextPage(page); + } + this.closeMentionMenu(); + this.debouncedSaveInput(); + requestAnimationFrame(() => { if (this.inputEl) this.inputEl.focus(); }); + }); + } + + closeMentionMenu() { + this.setState({ + showMentionMenu: false, + mentionFilter: '', + mentionSelectedIndex: 0, + mentionStartPos: -1, + filteredMentionPages: [], + }); + } + + /** + * Insert '@' at the cursor and trigger the mention popup. + * Called from the context dropdown "Mention a page" button. + */ + insertMention() { + const input = this.inputEl; + if (!input) return; + + const cursorPos = input.selectionStart; + const before = this.state.userInput.substring(0, cursorPos); + const after = this.state.userInput.substring(cursorPos); + const prefix = before.length > 0 && !/\s$/.test(before) ? ' ' : ''; + const userInput = before + prefix + '@' + after; + + this.setState({ userInput }, () => { + const newPos = cursorPos + prefix.length + 1; + requestAnimationFrame(() => { + input.focus(); + input.setSelectionRange(newPos, newPos); + this.checkForMention(); + }); + this.debouncedSaveInput(); + }); + } + + // ─── Product ─────────────────────────────────────────────── + + getModelLabel() { + const p = this.props.products.find((x) => x.id === this.state.selectedModel); + return p ? p.label : this.state.selectedModel; + } + + // Choose the default product for a fresh session. There is only one product + // today, so this always returns the default. When a second product is added, + // branch on the URL here (e.g. a `/standalone/` path segment). + detectProductFromPath() { + return this.props.defaultProduct; + } + + // ─── Input ───────────────────────────────────────────────── + + handleInput(e) { + const userInput = e.target.value; + this.setState({ userInput }, () => { + this.autoResizeInput(); + this.checkForMention(); + this.debouncedSaveInput(); + }); + } + + /** + * Auto-resize the textarea to fit its content. + * Operates directly on the DOM element to avoid a reactive cycle. + */ + autoResizeInput() { + const el = this.inputEl; + if (!el) return; + const min = 68; + const max = 150; + el.style.height = '0px'; + el.style.height = Math.max(min, Math.min(el.scrollHeight, max)) + 'px'; + } + + scrollMentionIntoView() { + requestAnimationFrame(() => { + const list = this.mentionListEl; + if (!list) return; + const items = list.querySelectorAll('.chatbot-mention-item'); + const active = items[this.state.mentionSelectedIndex]; + if (active) active.scrollIntoView({ block: 'nearest' }); + }); + } + + handleKeydown(event) { + // ── Mention popup keyboard navigation ────────────────── + if (this.state.showMentionMenu) { + const filtered = this.state.filteredMentionPages; + if (event.key === 'ArrowDown') { + event.preventDefault(); + this.setState({ mentionSelectedIndex: Math.min(this.state.mentionSelectedIndex + 1, filtered.length - 1) }, () => this.scrollMentionIntoView()); + return; + } + if (event.key === 'ArrowUp') { + event.preventDefault(); + this.setState({ mentionSelectedIndex: Math.max(this.state.mentionSelectedIndex - 1, 0) }, () => this.scrollMentionIntoView()); + return; + } + if ((event.key === 'Enter' || event.key === 'Tab') && filtered.length > 0) { + event.preventDefault(); + this.selectMention(filtered[this.state.mentionSelectedIndex]); + return; + } + if (event.key === 'Escape') { + event.preventDefault(); + this.closeMentionMenu(); + return; + } + } + + // ── Send on Enter ────────────────────────────────────── + if (event.key === 'Enter' && !event.shiftKey && !this.state.isProcessing) { + event.preventDefault(); + this.sendQuery(); + } + } + + // ─── Query ───────────────────────────────────────────────── + + clearStreamRenderScheduler() { + if (this._streamRenderRafId !== null) { + window.cancelAnimationFrame(this._streamRenderRafId); + this._streamRenderRafId = null; + } + if (this._streamRenderTimer !== null) { + clearTimeout(this._streamRenderTimer); + this._streamRenderTimer = null; + } + this._streamRenderScheduled = false; + } + + scheduleStreamRender() { + if (this._streamRenderScheduled) return; + this._streamRenderScheduled = true; + + const run = () => { + this._streamRenderScheduled = false; + this._streamRenderRafId = null; + this._streamRenderTimer = null; + this.flushPendingStreamTokens(); + }; + + if (typeof window.requestAnimationFrame === 'function') { + this._streamRenderRafId = window.requestAnimationFrame(run); + } else { + this._streamRenderTimer = setTimeout(run, 16); + } + } + + // Apply pending tokens to the last streaming message and re-render. + flushPendingStreamTokens() { + const changed = this.flushPendingStreamTokensSync(); + if (changed) this.setState({ messages: this.state.messages }); + } + + // Core token flush that mutates the last message in place. + // Returns true if anything changed (caller triggers the re-render). + flushPendingStreamTokensSync() { + if (!this._pendingStreamTokens) return false; + + const messages = this.state.messages; + const idx = messages.length - 1; + const msg = messages[idx]; + if (!msg || msg.role !== 'assistant' || !msg.isStreaming) { + this._pendingStreamTokens = ''; + return false; + } + + this.markdownRenderer.addToken(this._pendingStreamTokens); + this._pendingStreamTokens = ''; + + const htmlOut = this.markdownRenderer.render(); + msg.content = htmlOut; + + if (this.markdownRenderer.getContent().length > 0 && this.state.showThinking) { + this.thinkingAnimator.stop(); + msg.isLoading = false; + // showThinking flip happens via setState in flushPendingStreamTokens + this.state.showThinking = false; + } + return true; + } + + async sendQuery() { + const query = this.state.userInput.trim(); + if (!query || this.state.isProcessing) return; + + let sessionId = this.state.sessionId; + if (!sessionId) { + sessionId = this.generateSessionId(); + } + + // Snapshot context pages (only first MAX_CONTEXT_PAGES are sent) + const capturedContextPages = this.state.contextPages.map((p) => ({ ...p })); + const pages = capturedContextPages + .slice(0, MAX_CONTEXT_PAGES) + .map((p) => p.url) + .join(','); + + // Push user message + const messages = [...this.state.messages, { + role: 'user', + content: query, + contextPages: capturedContextPages + }]; + + const isExpanded = this.state.isExpanded || messages.length === 1; + + const userMsgCount = messages.filter((m) => m.role === 'user').length; + let historyLimitNoteShown = this.state.historyLimitNoteShown; + let historyLimitNoteIndex = this.state.historyLimitNoteIndex; + let showNewConvBanner = this.state.showNewConvBanner; + if (userMsgCount === HISTORY_LIMIT_NOTE_AT) { + historyLimitNoteShown = true; + historyLimitNoteIndex = messages.length - 1; + } + if (userMsgCount === NEW_CHAT_TOOLTIP_AT) { + showNewConvBanner = true; + } + + // Streaming assistant message placeholder + messages.push({ + role: 'assistant', + content: '', + isStreaming: true, + showAvatar: true, + isLoading: true, + isError: false, + feedback: null + }); + + this.markdownRenderer.reset(); + this.clearStreamRenderScheduler(); + this._pendingStreamTokens = ''; + this.clearSavedInput(); + + this.setState({ + isProcessing: true, + sessionId, + messages, + isExpanded, + historyLimitNoteShown, + historyLimitNoteIndex, + showNewConvBanner, + userInput: '', + contextPages: [], + showThinking: true, + showMentionMenu: false, + mentionFilter: '', + mentionSelectedIndex: 0, + mentionStartPos: -1, + filteredMentionPages: [], + }, () => { + this.saveState(); + // Thinking dots animator starts via its ref callback on mount. + this.scrollUserMessageToTop(); + }); + + try { + this.currentEventSource = await this.streamer.stream(query, { + sessionId, + product: this.state.selectedModel, + pages, + + onToken: (token) => { + this._pendingStreamTokens += token; + this.scheduleStreamRender(); + }, + + onDone: () => { + this.clearStreamRenderScheduler(); + this.flushPendingStreamTokensSync(); + const htmlOut = this.markdownRenderer.flush(); + const messages2 = this.state.messages; + const idx = messages2.length - 1; + const msg = messages2[idx]; + if (msg && msg.role === 'assistant') { + msg.content = htmlOut; + msg.markdown = this.markdownRenderer.getContent(); + msg.isStreaming = false; + msg.isLoading = false; + } + this.thinkingAnimator.stop(); + this.setState({ + messages: messages2, + showThinking: false, + isProcessing: false, + }, () => this.saveState()); + this.currentEventSource = null; + requestAnimationFrame(() => { + if (this.inputEl) this.inputEl.focus(); + if (this.spacerEl) this.spacerEl.style.minHeight = '0'; + }); + }, + + onError: (errorMessage, errorType) => { + console.error('Stream error:', errorMessage, errorType); + this.clearStreamRenderScheduler(); + this._pendingStreamTokens = ''; + this.thinkingAnimator.stop(); + const messages2 = this.state.messages; + const idx = messages2.length - 1; + messages2[idx].content = errorMessage; + messages2[idx].isError = true; + messages2[idx].isRateLimited = errorType === ErrorType.RATE_LIMITED; + messages2[idx].isStreaming = false; + messages2[idx].isLoading = false; + this.setState({ + messages: messages2, + showThinking: false, + isProcessing: false, + }, () => this.saveState()); + this.currentEventSource = null; + requestAnimationFrame(() => { + if (this.spacerEl) this.spacerEl.style.minHeight = '0'; + }); + } + }); + } catch (error) { + console.error('Chat error:', error); + this.clearStreamRenderScheduler(); + this._pendingStreamTokens = ''; + this.thinkingAnimator.stop(); + const messages2 = this.state.messages; + const idx = messages2.length - 1; + messages2[idx].content = `Error: ${error.message}`; + messages2[idx].isError = true; + messages2[idx].isStreaming = false; + messages2[idx].isLoading = false; + this.setState({ + messages: messages2, + showThinking: false, + isProcessing: false, + }, () => this.saveState()); + this.currentEventSource = null; + requestAnimationFrame(() => { + if (this.spacerEl) this.spacerEl.style.minHeight = '0'; + }); + } + } + + // ─── Feedback ────────────────────────────────────────────── + + /** + * Submit a thumb-up or thumb-down for an assistant message. + * The vote is sent to the backend immediately (fire-and-forget). + * For thumb-down, a comment modal is shown afterwards; the vote + * has already been recorded even if the user dismisses the modal. + */ + async submitFeedback(index, type) { + const messages = this.state.messages; + const msg = messages[index]; + if (!msg || msg.role !== 'assistant' || msg.feedback) return; + + // Record feedback in UI immediately + msg.feedback = type; + this.setState({ messages }, () => this.saveState()); + + // Find the user query that prompted this response + const userMsg = index > 0 ? messages[index - 1] : null; + const query = userMsg && userMsg.role === 'user' ? userMsg.content : ''; + + // Send vote to backend (fire-and-forget) + try { + await fetch(`${this.props.agentEndpoint}/feedback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sessionId: this.state.sessionId, + messageIndex: index, + type, + query, + response: msg.markdown || msg.content + }) + }); + } catch (e) { + console.warn('Chatbot: could not submit feedback', e); + } + + // Show comment modal for thumb-down + if (type === 'down') { + this.setState({ + feedbackModalIndex: index, + feedbackComment: '', + showFeedbackModal: true, + }, () => { + requestAnimationFrame(() => { if (this.feedbackInputEl) this.feedbackInputEl.focus(); }); + }); + } + } + + /** + * Submit the optional comment from the thumb-down modal. + * Only sends if the user typed something. + */ + async submitFeedbackComment() { + const comment = this.state.feedbackComment.trim(); + const index = this.state.feedbackModalIndex; + this.closeFeedbackModal(); + + if (!comment) return; + + try { + await fetch(`${this.props.agentEndpoint}/feedback/comment`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sessionId: this.state.sessionId, + messageIndex: index, + comment + }) + }); + } catch (e) { + console.warn('Chatbot: could not submit feedback comment', e); + } + } + + /** + * Close the feedback comment modal without sending anything extra. + * The thumb-down vote was already sent when the button was clicked. + */ + closeFeedbackModal() { + this.setState({ + showFeedbackModal: false, + feedbackModalIndex: -1, + feedbackComment: '', + }); + } + + // ─── Utilities ───────────────────────────────────────────── + + async copyMessage(index) { + const msg = this.state.messages[index]; + if (!msg) return; + let text = msg.markdown; + if (!text) { + const tmp = document.createElement('div'); + tmp.innerHTML = msg.content; + text = tmp.innerText; + } + try { + await navigator.clipboard.writeText(text); + this.setState({ copiedMessageIndex: index }); + setTimeout(() => { this.setState({ copiedMessageIndex: -1 }); }, 2000); + } catch (_) {} + } + + generateSessionId() { + if (window.crypto && window.crypto.randomUUID) return window.crypto.randomUUID(); + const bytes = new Uint8Array(16); + window.crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); + } + + scrollUserMessageToTop() { + // Wait for the browser to complete layout after the DOM update before + // computing scroll position. A double-rAF is used so the measurement + // happens after any pending CSS height transitions (e.g. the expanded + // class change) have been picked up by layout. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const c = this.messagesContainerEl; + const spacer = this.spacerEl; + if (!c) return; + + // The spacer sits after all messages. Grow it so there is enough + // scrollable height to push the latest user message to the very top. + if (spacer) spacer.style.minHeight = c.clientHeight + 'px'; + + // children: [welcome?, ...message wrappers (one per message), spacer] + // after sendQuery pushes user msg + assistant placeholder, the user + // message is the third-to-last child (before assistant + spacer). + const children = c.children; + const userMsgEl = children[children.length - 3]; + if (!userMsgEl) return; + + c.scrollTo({ + top: c.scrollTop + (userMsgEl.getBoundingClientRect().top - c.getBoundingClientRect().top - 8), + behavior: 'smooth' + }); + }); + }); + } + + isLastMessage(index) { + return index === this.state.messages.length - 1; + } + + // ─── Render ──────────────────────────────────────────────── + + render() { + return [this.renderTrigger(), this.renderDialog()]; + } + + renderTrigger() { + return html` + + `; + } + + renderDialog() { + const s = this.state; + if (!s.isOpen) return null; + + return html` +
+ ${this.renderHeader()} + ${this.renderMessages()} + ${this.renderInput()} + ${this.renderFeedbackModal()} +
+ `; + } + + renderHeader() { + const s = this.state; + return html` +
+
+ + + + agentregistry assistant +
+
+ + ${s.messages.length > 0 && html` + + `} + +
+
+ `; + } + + renderMessages() { + const s = this.state; + return html` +
this.messagesContainerEl = el} + class="chatbot-messages flex-1 overflow-y-auto px-4 py-3 flex flex-col gap-4" + > + ${s.messages.length === 0 && html` +
+

Ask me anything about agentregistry: publishing artifacts, deployments, the arctl CLI, or the registry API.

+

Note: AI-generated content might contain errors; please verify and test all returned information.

+

Tip: one topic per conversation gives the best results. Use the + button in the chat header to start a new conversation.

+
+ `} + + ${s.messages.map((msg, index) => this.renderMessage(msg, index))} + +
this.spacerEl = el} style="flex-shrink: 0">
+
+ `; + } + + renderMessage(msg, index) { + const s = this.state; + return html` +
+
+ ${msg.role === 'user' + ? this.renderUserMessage(msg) + : this.renderAssistantMessage(msg, index)} +
+ ${s.historyLimitNoteShown && index === s.historyLimitNoteIndex && html` +
+ + + + + + The assistant keeps a rolling history of 3 exchanges. Any older messages are no longer included in the context. +
+ `} +
+ `; + } + + renderUserMessage(msg) { + const contextPages = msg.contextPages || []; + return html` +
+
${msg.content}
+ ${contextPages.length > 0 && html` +
+ ${contextPages.map((cp, cpIdx) => html` + + + + + + ${cp.title} + + `)} +
+ `} +
+ `; + } + + renderAssistantMessage(msg, index) { + const s = this.state; + const showThinkingDots = msg.isStreaming && s.showThinking && this.isLastMessage(index); + const showFeedbackRow = !msg.isError && !msg.isStreaming && msg.content; + return html` +
+
+ Agent +
+ +
+ ${showThinkingDots && html` +
+
{ this.thinkingDotsEl = el; if (el) this.thinkingAnimator.start(el); }}> + + + +
+
+ `} + + ${!msg.isError && html` +
+ `} + + ${msg.isError && (msg.isRateLimited + ? html` +
+ + + + +
+
Rate limit reached
+
${msg.content}
+
+
+ ` + : html` +
${msg.content}
+ `)} + + ${showFeedbackRow && this.renderFeedbackRow(msg, index)} +
+
+ `; + } + + renderFeedbackRow(msg, index) { + const s = this.state; + const upClass = msg.feedback === 'up' + ? 'text-green-500 dark:text-green-400' + : (!msg.feedback + ? 'text-gray-300 dark:text-gray-600 hover:text-gray-500 dark:hover:text-gray-400' + : 'text-gray-200 dark:text-gray-700'); + const downClass = msg.feedback === 'down' + ? 'text-red-500 dark:text-red-400' + : (!msg.feedback + ? 'text-gray-300 dark:text-gray-600 hover:text-gray-500 dark:hover:text-gray-400' + : 'text-gray-200 dark:text-gray-700'); + const copyClass = s.copiedMessageIndex === index + ? 'text-green-500 dark:text-green-400' + : 'text-gray-300 dark:text-gray-600 hover:text-gray-500 dark:hover:text-gray-400'; + return html` +
+ + + +
+ `; + } + + renderInput() { + const s = this.state; + return html` +
+ + ${s.showNewConvBanner && html` +
+ Switching topics? Starting a new conversation improves accuracy. + +
+ `} + +
+ + ${s.contextPages.length > 0 && html` +
+ ${s.contextPages.map((page, pageIdx) => html` + = 3 ? 'Exceeds the 3-page limit — this page will not be sent as context.' : ''} + > + + + + + ${page.title} + + + `)} +
+ `} + + ${this.renderMentionMenu()} + + + +
+ ${this.renderContextMenu()} + ${this.renderSendRow()} +
+
+
+ `; + } + + renderMentionMenu() { + const s = this.state; + if (!(s.showMentionMenu && s.filteredMentionPages.length > 0)) { + return html`
`; + } + return html` +
+
+
this.mentionListEl = el} class="chatbot-mention-list max-h-[240px] overflow-y-auto py-1"> + ${s.filteredMentionPages.map((page, mIdx) => html` + + `)} +
+
+ ↑↓ navigate + select + esc dismiss +
+
+
+ `; + } + + renderContextMenu() { + const s = this.state; + return html` +
this.contextWrapEl = el}> + + + ${s.showContextMenu && html` +
+ + +
+ `} +
+ `; + } + + renderSendRow() { + const s = this.state; + const canSend = s.userInput.trim() && !s.isProcessing; + return html` +
+ ${this.props.showProductSelector && this.renderProductSelector()} + +
+ `; + } + + /* + Product (deployment-model) selector. + + HIDDEN while there is only one product: `showProductSelector` is derived in + assets/js/chatbot/index.js from `PRODUCTS.length > 1`. The dropdown below + renders one row per entry in `products`, so when a second product is added + to the PRODUCTS array (e.g. a standalone enterprise corpus) the selector + AUTOMATICALLY reappears with both options — no markup changes needed here. + See the PRODUCTS block in index.js for the full checklist. + */ + renderProductSelector() { + const s = this.state; + return html` +
this.modelWrapEl = el}> + + + ${s.showModelMenu && html` +
+ ${this.props.products.map((p) => html` + + `)} +
+ `} +
+ `; + } + + renderFeedbackModal() { + const s = this.state; + if (!s.showFeedbackModal) return null; + return html` +
this.closeFeedbackModal()} + class="absolute inset-0 z-50 flex items-center justify-center bg-black/30 dark:bg-black/50 rounded-2xl" + > +
e.stopPropagation()} + style="animation: dialogSlideIn 0.15s ease-out" + class="w-[min(400px,calc(100%-2rem))] bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl p-5" + > +
+

What could be improved?

+ +
+

+ Your feedback helps us improve assistant answers and identify docs gaps we should fix. +

+ +
+

+ Need more help? Join us on Discord: + https://discord.gg/Af8bX99dbX +

+

+ Want to use your own agent? Add the Solo MCP server to query our docs directly. Get started here: + https://search.solo.io/. +

+
+
+ + +
+
+
+ `; + } +} diff --git a/assets/js/chatbot/index.js b/assets/js/chatbot/index.js new file mode 100644 index 0000000..1a90a14 --- /dev/null +++ b/assets/js/chatbot/index.js @@ -0,0 +1,104 @@ +/** + * Chatbot entry point. + * + * Reads the build-time page index + favicon from the mount node, then renders + * the Preact into #chatbot-widget. Everything is bundled by Hugo's + * `js.Build` (esbuild) and served from 'self' — no CDN scripts, no runtime + * eval — so it stays CSP-safe (a `script-src` without 'unsafe-eval' still runs + * it). + * + * Ported from the Solo Enterprise for agentgateway docs assistant + * (solo-io/docs assets/js/chatbot). + */ + +import { html, render } from '../vendor/preact-htm.module.js'; +import { App } from './app.js'; + +const AGENT_ENDPOINT = 'https://assistant.docs.solo.io'; + +/** + * Backend products (doc-workflow). + * + * Every query is sent to the doc-workflow backend tagged with a `product`, which + * selects the documentation corpus to answer from. Each `id` MUST exactly match a + * product key configured in the backend's `doc-workflow.config.yaml` (the live + * list is served by `GET https://assistant.docs.solo.io/products`). An unknown id + * does NOT error — the backend silently falls back to its default product (the + * wrong corpus) — so keep these ids in sync with that file. + * + * This site is the open source agentregistry documentation, so there is one + * product and the in-widget selector is hidden automatically + * (see `SHOW_PRODUCT_SELECTOR`). + * + * WHEN A SECOND CORPUS IS ADDED — the enterprise distribution is already indexed + * as its own backend product, `solo-enterprise-for-agentregistry`: + * 1. Add an entry to PRODUCTS below — `{ id: '', + * label: '', description: '' }`. + * 2. That is all the UI needs: `SHOW_PRODUCT_SELECTOR` flips to true once PRODUCTS + * has more than one entry, and the dropdown in app.js renders one row per + * entry straight from this array — so the selector reappears with both + * options, no markup changes required. + * 3. Revisit `DEFAULT_PRODUCT`, and consider having `detectProductFromPath()` + * (in app.js) pick the default from the URL instead of always returning + * DEFAULT_PRODUCT. + * A persisted selection is only restored if it still matches a known product id + * (see `readRestoredState` in app.js), so stale selections after a catalog change + * self-heal. + */ +const PRODUCTS = [ + { + id: 'agentregistry', + label: 'Open source', + description: 'agentregistry docs', + }, + // Enterprise corpus — uncomment to offer both. The `id` below already matches + // the backend product key: + // { + // id: 'solo-enterprise-for-agentregistry', + // label: 'Enterprise', + // description: 'Solo Enterprise for agentregistry docs', + // }, +]; +const DEFAULT_PRODUCT = 'agentregistry'; +// The selector is shown only when there is more than one product to choose from. +const SHOW_PRODUCT_SELECTOR = PRODUCTS.length > 1; + +/** + * Load the page index embedded by Hugo at build time. + * Returns an array of { title, url, section } objects. + */ +function loadPageIndex() { + try { + const el = document.getElementById('chatbot-page-index'); + if (el) return JSON.parse(el.textContent); + } catch (e) { + console.warn('Chatbot: could not load page index', e); + } + return []; +} + +function mount() { + const mountEl = document.getElementById('chatbot-widget'); + if (!mountEl) return; + + const favicon = mountEl.dataset.favicon || ''; + const pageIndex = loadPageIndex(); + + render( + html`<${App} + agentEndpoint=${AGENT_ENDPOINT} + products=${PRODUCTS} + defaultProduct=${DEFAULT_PRODUCT} + showProductSelector=${SHOW_PRODUCT_SELECTOR} + pageIndex=${pageIndex} + favicon=${favicon} + />`, + mountEl + ); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', mount); +} else { + mount(); +} diff --git a/assets/js/chatbot/markdown.js b/assets/js/chatbot/markdown.js new file mode 100644 index 0000000..63255e9 --- /dev/null +++ b/assets/js/chatbot/markdown.js @@ -0,0 +1,164 @@ +/** + * Markdown Configuration - Setup for marked + highlight.js + DOMPurify + * + * Everything is bundled from the vendored ESM copies in assets/js/vendor, so + * nothing is pulled from a CDN at runtime — which also keeps the widget inside + * a CSP that forbids 'unsafe-eval' and allows only a short script-src list. + * + * `DOMPurify` sanitizes the HTML that `marked` emits before it reaches the + * `dangerouslySetInnerHTML` sink in the message list. Model responses are + * untrusted input, and prod's CSP allows `script-src 'unsafe-inline'`, so an + * unsanitized `` (or similar) in a response would otherwise + * execute. + * + * ── Highlighting: YAML ONLY ───────────────────────────────────────────────── + * This site registers exactly one highlight.js grammar, YAML, and every other + * code block renders as plain escaped text. + * + * The reason is measured, not arbitrary. highlight.js colors keywords, strings, + * variables, and comments; a command line contains none of those, so a `sh` + * block such as `arctl apply -f agent.yaml` produces ZERO token spans however + * it is highlighted. The docs corpus is ~228 `sh` fences, ~55 `console`, and + * ~10 `yaml`, so shell is nearly all of it and none of it can gain color. + * Hugo's own Chroma rendering of those same `sh` blocks is equally plain, so + * plain shell in an answer matches the surrounding page. + * + * Consequences to keep in mind when editing this file: + * - NEVER call `hljs.highlightAuto()`. With one grammar registered it would + * force YAML onto shell text and color it wrongly. + * - To support another language, vendor its grammar from + * https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/es/languages/.min.js + * and register it below. The full 36-language build was dropped on purpose: + * it cost 120 KB and an extra request to color nothing. + */ + +import { marked } from '../vendor/marked.esm.js'; +import DOMPurify from '../vendor/purify.es.mjs'; +import hljs from '../vendor/hljs-core.min.js'; +import yamlGrammar from '../vendor/hljs-yaml.min.js'; + +hljs.registerLanguage('yaml', yamlGrammar); + +// `yml` is an alias the grammar declares itself; list both for the lookup below. +const YAML_LANGS = new Set(['yaml', 'yml']); + +let markedConfigured = false; + +function escapeHtml(text) { + return String(text) + .replace(/&/g, '&') + .replace(//g, '>'); +} + +/** + * Does an untagged code block look like YAML? + * + * Answers are not always fenced with a language, and highlight.js' auto + * detection is unavailable here (see the header). This checks the first + * non-empty line only, and requires a `key:` or `key: value` shape or a `---` + * document marker — deliberately tight, so a command line like + * `docker run -e FOO=bar` is never mistaken for YAML. + */ +function looksLikeYaml(code) { + const firstLine = String(code).split('\n').find((l) => l.trim().length > 0); + if (!firstLine) return false; + const line = firstLine.trim(); + if (line === '---') return true; + return /^[A-Za-z_][A-Za-z0-9_.-]*:(\s|$)/.test(line); +} + +/** + * Highlight one fenced code block, or escape it when it is not YAML. + * Returns { html, language }, where `language` is '' for a plain block. + */ +function renderCode(code, lang) { + const requested = String(lang || '').trim().split(/\s+/)[0].toLowerCase(); + + const isYaml = YAML_LANGS.has(requested) || (!requested && looksLikeYaml(code)); + if (isYaml) { + try { + return { + html: hljs.highlight(code, { language: 'yaml' }).value, + language: 'yaml', + }; + } catch (err) { + console.error('Highlight error:', err); + } + } + + return { html: escapeHtml(code), language: requested }; +} + +/** + * Configure marked. + * + * NOTE: highlighting goes through a custom `code` RENDERER, not the `highlight` + * option. marked removed that option in v5, so on the vendored v11.1.1 the + * option is accepted and then silently ignored — solo-io/docs still passes it, + * which is why in-chat code blocks render there as plain monospace text with + * hljs theme stylesheets loaded for nothing. + * + * The emitted markup stays `
` on one line, because + * MarkdownRenderer.flush() matches exactly that shape when it injects the + * per-block copy buttons (see ui.js). + */ +export function configureMarked() { + if (!markedConfigured) { + marked.setOptions({ + breaks: true, + gfm: true, + }); + marked.use({ + renderer: { + code(code, infostring) { + // marked v11 hands the renderer an object in some call paths and + // positional arguments in others; accept both. + const isObj = typeof code === 'object' && code !== null; + const text = String(isObj ? code.text : code); + const lang = isObj ? code.lang : infostring; + + const { html, language } = renderCode(text, lang); + const classes = ['hljs']; + if (language) classes.push(`language-${language}`); + return `
${html}
\n`; + }, + }, + }); + markedConfigured = true; + } + + return marked; +} + +/** + * Parse markdown to HTML + * @param {string} content - Markdown content + * @returns {string} HTML string + */ +export function parseMarkdown(content) { + const md = configureMarked(); + + if (md && typeof md.parse === 'function') { + return sanitize(md.parse(content)); + } + + // Fallback to plain text with basic escaping + return escapeHtml(content).replace(/\n/g, '
'); +} + +/** + * Sanitize marked's HTML output before it is injected as raw HTML. + * Strips scripts, inline event handlers, and javascript: URLs while keeping + * the safe subset markdown produces (links, code/pre, tables, hljs spans, …). + * If DOMPurify is somehow unavailable, fall back to escaping the whole string + * rather than trusting it. + * @param {string} htmlOut - HTML from marked.parse + * @returns {string} Sanitized HTML + */ +function sanitize(htmlOut) { + if (DOMPurify && typeof DOMPurify.sanitize === 'function') { + return DOMPurify.sanitize(htmlOut); + } + return escapeHtml(htmlOut); +} diff --git a/assets/js/chatbot/stream.js b/assets/js/chatbot/stream.js new file mode 100644 index 0000000..5ff89ad --- /dev/null +++ b/assets/js/chatbot/stream.js @@ -0,0 +1,179 @@ +/** + * Error types for specific error handling + */ +export const ErrorType = { + RATE_LIMITED: 'rate_limited', + CONNECTION: 'connection', + UNKNOWN: 'unknown' +}; + +/** + * ChatStreamer - Handles Server-Sent Events (SSE) streaming from the agent endpoint + * + * Uses fetch() + ReadableStream instead of EventSource so that HTTP status codes + * (e.g. 429 Too Many Requests) are visible before the stream is opened. + */ +export class ChatStreamer { + constructor(endpoint) { + this.endpoint = endpoint; + } + + /** + * Stream a query to the agent endpoint + * @param {string} query - The user's question + * @param {Object} callbacks - Event callbacks + * @param {Function} callbacks.onToken - Called when a token is received + * @param {Function} callbacks.onDone - Called when streaming completes + * @param {Function} callbacks.onError - Called on error with (message, errorType) + * @param {string} callbacks.product - The doc-workflow product id selecting the docs corpus + * @param {string} [callbacks.pages] - Optional comma-separated page URLs sent as context + * @returns {Promise<{close: Function}|null>} A handle with a close() method for cleanup + */ + async stream(query, { sessionId, product = 'solo-enterprise-for-agentgateway', pages = '', onToken, onDone, onError }) { + const queryParams = new URLSearchParams({ + q: query, + product: product, + sessionId: sessionId + }); + if (pages) { + queryParams.set('pages', pages); + } + const url = `${this.endpoint}/query?${queryParams.toString()}`; + + let reader = null; + let cancelled = false; + + /** Returned to the caller so it can abort the stream (same interface as EventSource). */ + const handle = { + close() { + cancelled = true; + if (reader) { + reader.cancel().catch(() => {}); + } + } + }; + + try { + const response = await fetch(url, { + headers: { 'Accept': 'text/event-stream' } + }); + + // ── Handle HTTP-level errors (429, 5xx, etc.) ────────────── + if (response.status === 429) { + if (onError) { + onError( + 'You\u2019ve sent too many messages. Please wait a minute and try again.', + ErrorType.RATE_LIMITED + ); + } + return handle; + } + + if (!response.ok) { + let errorMessage = 'Connection error. Please try again.'; + let errorType = ErrorType.CONNECTION; + + // Try to read a JSON body from the error response + try { + const body = await response.text(); + const data = JSON.parse(body); + if (data.message) errorMessage = data.message; + } catch (_) { /* use default */ } + + if (onError) onError(errorMessage, errorType); + return handle; + } + + // ── Stream SSE from the response body ────────────────────── + reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let currentEvent = ''; + + const processStream = async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done || cancelled) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop(); // keep incomplete last line in buffer + + for (const line of lines) { + if (line.startsWith('event:')) { + currentEvent = line.slice(6).trim(); + } else if (line.startsWith('data:')) { + const data = line.slice(5).trimStart(); + this._handleSSEMessage(currentEvent, data, { onToken, onDone, onError }); + + // Stop reading after terminal events + if (currentEvent === 'done' || currentEvent === 'error') return; + } else if (line.trim() === '') { + currentEvent = ''; + } + } + } + + // The server closed the stream without a terminal `done`/`error` + // event (e.g. an upstream timeout or a crashed generation). Without + // this the UI would wait for tokens forever. + if (!cancelled && onError) { + onError('The connection was interrupted before the answer completed. Please try again.', ErrorType.CONNECTION); + } + } catch (err) { + if (!cancelled && onError) { + onError('Connection error. Please try again.', ErrorType.CONNECTION); + } + } + }; + + // Fire-and-forget: the async loop runs in the background + processStream(); + + return handle; + } catch (err) { + // Network failure (offline, DNS, CORS, etc.) + if (onError) { + onError('Connection error. Please try again.', ErrorType.CONNECTION); + } + return handle; + } + } + + /** + * Dispatch a single SSE message to the appropriate callback. + * @private + */ + _handleSSEMessage(event, rawData, { onToken, onDone, onError }) { + if (event === 'token') { + try { + const data = JSON.parse(rawData); + if (onToken) onToken(data.token); + } catch (err) { + console.error('Token parsing error:', err); + } + } else if (event === 'done') { + if (onDone) onDone(); + } else if (event === 'error') { + let errorMessage = 'An error occurred. Please try again.'; + let errorType = ErrorType.UNKNOWN; + + try { + const errorData = JSON.parse(rawData); + errorMessage = errorData.message || errorMessage; + + if ( + errorData.status === 429 || + errorData.code === 'rate_limited' || + (errorData.message && errorData.message.toLowerCase().includes('rate limit')) + ) { + errorMessage = 'You\u2019ve sent too many messages. Please wait a minute and try again.'; + errorType = ErrorType.RATE_LIMITED; + } + } catch (_) { /* use default */ } + + if (onError) onError(errorMessage, errorType); + } + } +} diff --git a/assets/js/chatbot/ui.js b/assets/js/chatbot/ui.js new file mode 100644 index 0000000..f5a2d48 --- /dev/null +++ b/assets/js/chatbot/ui.js @@ -0,0 +1,240 @@ +/** + * UI Utilities - Animation helpers and DOM manipulation utilities + */ + +/** + * ThinkingAnimator - Manages the bouncing character animation in thinking states + */ +export class ThinkingAnimator { + constructor() { + this.animationInterval = null; + } + + start(element) { + const chars = element.querySelectorAll('span'); + if (chars.length === 0) return; + + // Stop any existing animation + this.stop(); + + const bounceRandomChar = () => { + const randomIndex = Math.floor(Math.random() * chars.length); + const char = chars[randomIndex]; + char.classList.add('bouncing'); + setTimeout(() => char.classList.remove('bouncing'), 600); + }; + + // Bounce immediately, then every 700ms + bounceRandomChar(); + this.animationInterval = setInterval(bounceRandomChar, 700); + } + + stop() { + if (this.animationInterval) { + clearInterval(this.animationInterval); + this.animationInterval = null; + } + } +} + +const COPY_ICON_SVG = ``; + +function injectCodeCopyButtons(html) { + return html.replace(/
]*)>([\s\S]*?)<\/code><\/pre>/g, (match, attrs, escapedCode) => {
+    const rawCode = escapedCode
+      .replace(/<[^>]+>/g, '')
+      .replace(/&/g, '&')
+      .replace(/</g, '<')
+      .replace(/>/g, '>')
+      .replace(/'/g, "'")
+      .replace(/"/g, '"');
+    const encodedCode = encodeURIComponent(rawCode);
+    return `
${escapedCode}
`; + }); +} + +/** + * MarkdownRenderer - Handles buffered markdown rendering with code block streaming + */ +export class MarkdownRenderer { + constructor(renderFn) { + this.renderFn = renderFn; // marked.parse or fallback + this.tokenBuffer = ''; + this.renderTimeout = null; + this.content = ''; + this.lastRenderedHTML = ''; + this.lastRenderedCodeBlockCount = 0; + this.lastCodeBlockLineCount = 0; + } + + /** + * Add tokens to the buffer + */ + addToken(token) { + this.tokenBuffer += token; + + // Render if buffer contains newline or is too large + const hasNewline = this.tokenBuffer.includes('\n'); + const bufferTooLarge = this.tokenBuffer.length > 50; + + if (hasNewline || bufferTooLarge) { + if (this.renderTimeout) { + clearTimeout(this.renderTimeout); + this.renderTimeout = null; + } + this.render(); + } else { + // Set timeout to render if no newline comes soon + if (this.renderTimeout) { + clearTimeout(this.renderTimeout); + } + this.renderTimeout = setTimeout(() => { + this.render(); + this.renderTimeout = null; + }, 100); + } + } + + /** + * Render buffered content with line-by-line code block reveal + */ + render() { + if (this.renderTimeout) { + clearTimeout(this.renderTimeout); + this.renderTimeout = null; + } + + if (this.tokenBuffer.length === 0) { + // Return cached HTML if buffer is empty + return this.lastRenderedHTML; + } + + this.content += this.tokenBuffer; + this.tokenBuffer = ''; + + // Check for incomplete code blocks + const codeBlockMatches = this.content.match(/```/g); + const codeBlockCount = codeBlockMatches ? codeBlockMatches.length : 0; + const hasIncompleteCodeBlock = codeBlockCount % 2 === 1; + + let contentToRender = this.content; + let incompleteCodeBlockHTML = ''; + + if (hasIncompleteCodeBlock) { + const lastCodeBlockStart = this.content.lastIndexOf('```'); + const beforeCodeBlock = this.content.substring(0, lastCodeBlockStart); + const codeBlockContent = this.content.substring(lastCodeBlockStart + 3); + + // Extract language + const firstLineBreak = codeBlockContent.indexOf('\n'); + const codeText = firstLineBreak >= 0 + ? codeBlockContent.substring(firstLineBreak + 1) + : codeBlockContent; + + const lines = codeText.split('\n'); + const completeLines = lines.slice(0, -1); + const incompleteLine = lines[lines.length - 1]; + const newLineCount = completeLines.length; + + incompleteCodeBlockHTML = '
';
+      completeLines.forEach((line, index) => {
+        const escapedLine = this._escapeHtml(line);
+        if (index >= this.lastCodeBlockLineCount) {
+          incompleteCodeBlockHTML += `${escapedLine}\n`;
+        } else {
+          incompleteCodeBlockHTML += escapedLine + '\n';
+        }
+      });
+
+      this.lastCodeBlockLineCount = newLineCount;
+
+      if (incompleteLine) {
+        incompleteCodeBlockHTML += this._escapeHtml(incompleteLine);
+      }
+      incompleteCodeBlockHTML += '
'; + + contentToRender = beforeCodeBlock; + } + + // Render markdown for complete parts + let html = this.renderFn(contentToRender) || ''; + + // Append incomplete code block (no copy button while still streaming) + if (hasIncompleteCodeBlock) { + html += incompleteCodeBlockHTML; + } + + // Track completed code blocks + if (!hasIncompleteCodeBlock && codeBlockCount > this.lastRenderedCodeBlockCount) { + this.lastRenderedCodeBlockCount = codeBlockCount; + this.lastCodeBlockLineCount = 0; + } + + // Cache the rendered HTML + this.lastRenderedHTML = html; + + return html; + } + + /** + * Flush any remaining buffered content and inject code copy buttons. + * Called once when streaming completes. + */ + flush() { + if (this.renderTimeout) { + clearTimeout(this.renderTimeout); + this.renderTimeout = null; + } + this.render(); + this.lastRenderedHTML = injectCodeCopyButtons(this.lastRenderedHTML); + return this.lastRenderedHTML; + } + + /** + * Reset the renderer state + */ + reset() { + this.tokenBuffer = ''; + this.content = ''; + this.lastRenderedHTML = ''; + this.lastRenderedCodeBlockCount = 0; + this.lastCodeBlockLineCount = 0; + if (this.renderTimeout) { + clearTimeout(this.renderTimeout); + this.renderTimeout = null; + } + } + + /** + * Get the current rendered content + */ + getContent() { + return this.content; + } + + _escapeHtml(text) { + return text.replace(//g, '>'); + } +} + +/** + * Resize button SVG icons + */ +export const RESIZE_ICONS = { + expand: ` + + + + + + + `, + collapse: ` + + + + + + + ` +}; diff --git a/assets/js/vendor/README.md b/assets/js/vendor/README.md new file mode 100644 index 0000000..d138cd1 --- /dev/null +++ b/assets/js/vendor/README.md @@ -0,0 +1,48 @@ +# Vendored JS libraries for the chatbot widget + +These files are vendored (committed to the repo) so the chatbot has **zero +external CDN script dependencies**: no third-party origin is contacted when a +docs page loads, a `script-src 'self'` needs no CDN entry, and the framework +tests' console-error spec does not depend on network access. Every file here +goes through Hugo's `js.Build` bundle, which also sidesteps a CSP that forbids `'unsafe-eval'`, and +keeps the widget portable to other Hugo doc sites (copy this directory along +with `assets/js/chatbot/` and `layouts/_partials/chatbot.html`). + +Upstream (`solo-io/docs`) vendors only the three markdown/sanitizer modules and +loads the full 36-language highlight.js build from cdnjs. Here it is the core +plus one grammar, bundled — ~22 KB against 120 KB and one fewer request. + +| File | Library | Version | Source | +|------|---------|---------|--------| +| `preact-htm.module.js` | htm/preact standalone (preact core + hooks + htm's `html` tagged template, bundled, no external imports) | htm 3.1.1 (bundles preact 10.x) | https://unpkg.com/htm@3.1.1/preact/standalone.module.js | +| `marked.esm.js` | marked (markdown renderer), dependency-free ESM | 11.1.1 | https://unpkg.com/marked@11.1.1/lib/marked.esm.js | +| `purify.es.mjs` | DOMPurify (HTML sanitizer), dependency-free ESM. Sanitizes `marked` output before it is injected via `dangerouslySetInnerHTML`, since model responses are untrusted and prod's CSP allows `script-src 'unsafe-inline'`. | 3.4.11 | https://unpkg.com/dompurify@3.4.11/dist/purify.es.mjs | +| `hljs-core.min.js` | highlight.js core (ESM), no bundled grammars | 11.9.0 | https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/es/core.min.js | +| `hljs-yaml.min.js` | highlight.js YAML grammar (ESM). The ONLY grammar registered — see the header of `assets/js/chatbot/markdown.js` for why, and for how to add another. | 11.9.0 | https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/es/languages/yaml.min.js | + +Exports of `preact-htm.module.js`: `html, h, render, Component, createContext, +useState, useReducer, useEffect, useLayoutEffect, useRef, useImperativeHandle, +useMemo, useCallback, useContext, useDebugValue, useErrorBoundary`. + +## Updating + +1. Re-download from unpkg with a new pinned version (same URLs, bump the version). +2. Verify the file is still self-contained (no `import ... from "bare-specifier"`) + and valid ESM: + + ```sh + node --input-type=module -e "console.log(Object.keys(await import('./assets/js/vendor/preact-htm.module.js')).join(','))" + node --input-type=module -e "console.log(typeof (await import('./assets/js/vendor/marked.esm.js')).marked.parse)" + node --input-type=module -e "console.log(typeof (await import('./assets/js/vendor/purify.es.mjs')).default)" + ``` + + (DOMPurify's `.sanitize` only materializes when a DOM/`window` is present, so + in Node the default export is the `createDOMPurify` factory — `function`. In + the browser it is a ready instance with `.sanitize`.) +3. Rebuild the site and re-test the chatbot. + +Note: the highlight.js *script* is intentionally NOT vendored — it loads from +cdnjs.cloudflare.com, which prod's CSP `script-src` allowlists, and the widget +degrades gracefully if it's absent. Its theme *stylesheets* ARE vendored (in +`assets/css/vendor/hljs-github{,-dark}.min.css`) because prod's `style-src` +does not include cdnjs, so CDN-hosted CSS gets blocked. diff --git a/assets/js/vendor/hljs-core.min.js b/assets/js/vendor/hljs-core.min.js new file mode 100644 index 0000000..b85b097 --- /dev/null +++ b/assets/js/vendor/hljs-core.min.js @@ -0,0 +1,307 @@ +/*! + Highlight.js v11.9.0 (git: f47103d4f1) + (c) 2006-2023 undefined and other contributors + License: BSD-3-Clause + */ +function e(t){return t instanceof Map?t.clear=t.delete=t.set=()=>{ +throw Error("map is read-only")}:t instanceof Set&&(t.add=t.clear=t.delete=()=>{ +throw Error("set is read-only") +}),Object.freeze(t),Object.getOwnPropertyNames(t).forEach((n=>{ +const i=t[n],s=typeof i;"object"!==s&&"function"!==s||Object.isFrozen(i)||e(i) +})),t}class t{constructor(e){ +void 0===e.data&&(e.data={}),this.data=e.data,this.isMatchIgnored=!1} +ignoreMatch(){this.isMatchIgnored=!0}}function n(e){ +return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'") +}function i(e,...t){const n=Object.create(null);for(const t in e)n[t]=e[t] +;return t.forEach((e=>{for(const t in e)n[t]=e[t]})),n}const s=e=>!!e.scope +;class r{constructor(e,t){ +this.buffer="",this.classPrefix=t.classPrefix,e.walk(this)}addText(e){ +this.buffer+=n(e)}openNode(e){if(!s(e))return;const t=((e,{prefix:t})=>{ +if(e.startsWith("language:"))return e.replace("language:","language-") +;if(e.includes(".")){const n=e.split(".") +;return[`${t}${n.shift()}`,...n.map(((e,t)=>`${e}${"_".repeat(t+1)}`))].join(" ") +}return`${t}${e}`})(e.scope,{prefix:this.classPrefix});this.span(t)} +closeNode(e){s(e)&&(this.buffer+="")}value(){return this.buffer}span(e){ +this.buffer+=``}}const o=(e={})=>{const t={children:[]} +;return Object.assign(t,e),t};class a{constructor(){ +this.rootNode=o(),this.stack=[this.rootNode]}get top(){ +return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(e){ +this.top.children.push(e)}openNode(e){const t=o({scope:e}) +;this.add(t),this.stack.push(t)}closeNode(){ +if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){ +for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)} +walk(e){return this.constructor._walk(e,this.rootNode)}static _walk(e,t){ +return"string"==typeof t?e.addText(t):t.children&&(e.openNode(t), +t.children.forEach((t=>this._walk(e,t))),e.closeNode(t)),e}static _collapse(e){ +"string"!=typeof e&&e.children&&(e.children.every((e=>"string"==typeof e))?e.children=[e.children.join("")]:e.children.forEach((e=>{ +a._collapse(e)})))}}class c extends a{constructor(e){super(),this.options=e} +addText(e){""!==e&&this.add(e)}startScope(e){this.openNode(e)}endScope(){ +this.closeNode()}__addSublanguage(e,t){const n=e.root +;t&&(n.scope="language:"+t),this.add(n)}toHTML(){ +return new r(this,this.options).value()}finalize(){ +return this.closeAllNodes(),!0}}function l(e){ +return e?"string"==typeof e?e:e.source:null}function g(e){return h("(?=",e,")")} +function u(e){return h("(?:",e,")*")}function d(e){return h("(?:",e,")?")} +function h(...e){return e.map((e=>l(e))).join("")}function f(...e){const t=(e=>{ +const t=e[e.length-1] +;return"object"==typeof t&&t.constructor===Object?(e.splice(e.length-1,1),t):{} +})(e);return"("+(t.capture?"":"?:")+e.map((e=>l(e))).join("|")+")"} +function p(e){return RegExp(e.toString()+"|").exec("").length-1} +const b=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./ +;function m(e,{joinWith:t}){let n=0;return e.map((e=>{n+=1;const t=n +;let i=l(e),s="";for(;i.length>0;){const e=b.exec(i);if(!e){s+=i;break} +s+=i.substring(0,e.index), +i=i.substring(e.index+e[0].length),"\\"===e[0][0]&&e[1]?s+="\\"+(Number(e[1])+t):(s+=e[0], +"("===e[0]&&n++)}return s})).map((e=>`(${e})`)).join(t)} +const E="[a-zA-Z]\\w*",x="[a-zA-Z_]\\w*",w="\\b\\d+(\\.\\d+)?",y="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",_="\\b(0b[01]+)",O={ +begin:"\\\\[\\s\\S]",relevance:0},k={scope:"string",begin:"'",end:"'", +illegal:"\\n",contains:[O]},v={scope:"string",begin:'"',end:'"',illegal:"\\n", +contains:[O]},N=(e,t,n={})=>{const s=i({scope:"comment",begin:e,end:t, +contains:[]},n);s.contains.push({scope:"doctag", +begin:"[ ]*(?=(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):)", +end:/(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):/,excludeBegin:!0,relevance:0}) +;const r=f("I","a","is","so","us","to","at","if","in","it","on",/[A-Za-z]+['](d|ve|re|ll|t|s|n)/,/[A-Za-z]+[-][a-z]+/,/[A-Za-z][a-z]{2,}/) +;return s.contains.push({begin:h(/[ ]+/,"(",r,/[.]?[:]?([.][ ]|[ ])/,"){3}")}),s +},S=N("//","$"),M=N("/\\*","\\*/"),R=N("#","$");var A=Object.freeze({ +__proto__:null,APOS_STRING_MODE:k,BACKSLASH_ESCAPE:O,BINARY_NUMBER_MODE:{ +scope:"number",begin:_,relevance:0},BINARY_NUMBER_RE:_,COMMENT:N, +C_BLOCK_COMMENT_MODE:M,C_LINE_COMMENT_MODE:S,C_NUMBER_MODE:{scope:"number", +begin:y,relevance:0},C_NUMBER_RE:y,END_SAME_AS_BEGIN:e=>Object.assign(e,{ +"on:begin":(e,t)=>{t.data._beginMatch=e[1]},"on:end":(e,t)=>{ +t.data._beginMatch!==e[1]&&t.ignoreMatch()}}),HASH_COMMENT_MODE:R,IDENT_RE:E, +MATCH_NOTHING_RE:/\b\B/,METHOD_GUARD:{begin:"\\.\\s*"+x,relevance:0}, +NUMBER_MODE:{scope:"number",begin:w,relevance:0},NUMBER_RE:w, +PHRASAL_WORDS_MODE:{ +begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/ +},QUOTE_STRING_MODE:v,REGEXP_MODE:{scope:"regexp",begin:/\/(?=[^/\n]*\/)/, +end:/\/[gimuy]*/,contains:[O,{begin:/\[/,end:/\]/,relevance:0,contains:[O]}]}, +RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~", +SHEBANG:(e={})=>{const t=/^#![ ]*\// +;return e.binary&&(e.begin=h(t,/.*\b/,e.binary,/\b.*/)),i({scope:"meta",begin:t, +end:/$/,relevance:0,"on:begin":(e,t)=>{0!==e.index&&t.ignoreMatch()}},e)}, +TITLE_MODE:{scope:"title",begin:E,relevance:0},UNDERSCORE_IDENT_RE:x, +UNDERSCORE_TITLE_MODE:{scope:"title",begin:x,relevance:0}});function j(e,t){ +"."===e.input[e.index-1]&&t.ignoreMatch()}function I(e,t){ +void 0!==e.className&&(e.scope=e.className,delete e.className)}function T(e,t){ +t&&e.beginKeywords&&(e.begin="\\b("+e.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)", +e.__beforeBegin=j,e.keywords=e.keywords||e.beginKeywords,delete e.beginKeywords, +void 0===e.relevance&&(e.relevance=0))}function L(e,t){ +Array.isArray(e.illegal)&&(e.illegal=f(...e.illegal))}function B(e,t){ +if(e.match){ +if(e.begin||e.end)throw Error("begin & end are not supported with match") +;e.begin=e.match,delete e.match}}function P(e,t){ +void 0===e.relevance&&(e.relevance=1)}const D=(e,t)=>{if(!e.beforeMatch)return +;if(e.starts)throw Error("beforeMatch cannot be used with starts") +;const n=Object.assign({},e);Object.keys(e).forEach((t=>{delete e[t] +})),e.keywords=n.keywords,e.begin=h(n.beforeMatch,g(n.begin)),e.starts={ +relevance:0,contains:[Object.assign(n,{endsParent:!0})] +},e.relevance=0,delete n.beforeMatch +},H=["of","and","for","in","not","or","if","then","parent","list","value"],C="keyword" +;function $(e,t,n=C){const i=Object.create(null) +;return"string"==typeof e?s(n,e.split(" ")):Array.isArray(e)?s(n,e):Object.keys(e).forEach((n=>{ +Object.assign(i,$(e[n],t,n))})),i;function s(e,n){ +t&&(n=n.map((e=>e.toLowerCase()))),n.forEach((t=>{const n=t.split("|") +;i[n[0]]=[e,U(n[0],n[1])]}))}}function U(e,t){ +return t?Number(t):(e=>H.includes(e.toLowerCase()))(e)?0:1}const z={},W=e=>{ +console.error(e)},X=(e,...t)=>{console.log("WARN: "+e,...t)},G=(e,t)=>{ +z[`${e}/${t}`]||(console.log(`Deprecated as of ${e}. ${t}`),z[`${e}/${t}`]=!0) +},K=Error();function F(e,t,{key:n}){let i=0;const s=e[n],r={},o={} +;for(let e=1;e<=t.length;e++)o[e+i]=s[e],r[e+i]=!0,i+=p(t[e-1]) +;e[n]=o,e[n]._emit=r,e[n]._multi=!0}function Z(e){(e=>{ +e.scope&&"object"==typeof e.scope&&null!==e.scope&&(e.beginScope=e.scope, +delete e.scope)})(e),"string"==typeof e.beginScope&&(e.beginScope={ +_wrap:e.beginScope}),"string"==typeof e.endScope&&(e.endScope={_wrap:e.endScope +}),(e=>{if(Array.isArray(e.begin)){ +if(e.skip||e.excludeBegin||e.returnBegin)throw W("skip, excludeBegin, returnBegin not compatible with beginScope: {}"), +K +;if("object"!=typeof e.beginScope||null===e.beginScope)throw W("beginScope must be object"), +K;F(e,e.begin,{key:"beginScope"}),e.begin=m(e.begin,{joinWith:""})}})(e),(e=>{ +if(Array.isArray(e.end)){ +if(e.skip||e.excludeEnd||e.returnEnd)throw W("skip, excludeEnd, returnEnd not compatible with endScope: {}"), +K +;if("object"!=typeof e.endScope||null===e.endScope)throw W("endScope must be object"), +K;F(e,e.end,{key:"endScope"}),e.end=m(e.end,{joinWith:""})}})(e)}function V(e){ +function t(t,n){ +return RegExp(l(t),"m"+(e.case_insensitive?"i":"")+(e.unicodeRegex?"u":"")+(n?"g":"")) +}class n{constructor(){ +this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0} +addRule(e,t){ +t.position=this.position++,this.matchIndexes[this.matchAt]=t,this.regexes.push([t,e]), +this.matchAt+=p(e)+1}compile(){0===this.regexes.length&&(this.exec=()=>null) +;const e=this.regexes.map((e=>e[1]));this.matcherRe=t(m(e,{joinWith:"|" +}),!0),this.lastIndex=0}exec(e){this.matcherRe.lastIndex=this.lastIndex +;const t=this.matcherRe.exec(e);if(!t)return null +;const n=t.findIndex(((e,t)=>t>0&&void 0!==e)),i=this.matchIndexes[n] +;return t.splice(0,n),Object.assign(t,i)}}class s{constructor(){ +this.rules=[],this.multiRegexes=[], +this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(e){ +if(this.multiRegexes[e])return this.multiRegexes[e];const t=new n +;return this.rules.slice(e).forEach((([e,n])=>t.addRule(e,n))), +t.compile(),this.multiRegexes[e]=t,t}resumingScanAtSamePosition(){ +return 0!==this.regexIndex}considerAll(){this.regexIndex=0}addRule(e,t){ +this.rules.push([e,t]),"begin"===t.type&&this.count++}exec(e){ +const t=this.getMatcher(this.regexIndex);t.lastIndex=this.lastIndex +;let n=t.exec(e) +;if(this.resumingScanAtSamePosition())if(n&&n.index===this.lastIndex);else{ +const t=this.getMatcher(0);t.lastIndex=this.lastIndex+1,n=t.exec(e)} +return n&&(this.regexIndex+=n.position+1, +this.regexIndex===this.count&&this.considerAll()),n}} +if(e.compilerExtensions||(e.compilerExtensions=[]), +e.contains&&e.contains.includes("self"))throw Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.") +;return e.classNameAliases=i(e.classNameAliases||{}),function n(r,o){const a=r +;if(r.isCompiled)return a +;[I,B,Z,D].forEach((e=>e(r,o))),e.compilerExtensions.forEach((e=>e(r,o))), +r.__beforeBegin=null,[T,L,P].forEach((e=>e(r,o))),r.isCompiled=!0;let c=null +;return"object"==typeof r.keywords&&r.keywords.$pattern&&(r.keywords=Object.assign({},r.keywords), +c=r.keywords.$pattern, +delete r.keywords.$pattern),c=c||/\w+/,r.keywords&&(r.keywords=$(r.keywords,e.case_insensitive)), +a.keywordPatternRe=t(c,!0), +o&&(r.begin||(r.begin=/\B|\b/),a.beginRe=t(a.begin),r.end||r.endsWithParent||(r.end=/\B|\b/), +r.end&&(a.endRe=t(a.end)), +a.terminatorEnd=l(a.end)||"",r.endsWithParent&&o.terminatorEnd&&(a.terminatorEnd+=(r.end?"|":"")+o.terminatorEnd)), +r.illegal&&(a.illegalRe=t(r.illegal)), +r.contains||(r.contains=[]),r.contains=[].concat(...r.contains.map((e=>(e=>(e.variants&&!e.cachedVariants&&(e.cachedVariants=e.variants.map((t=>i(e,{ +variants:null},t)))),e.cachedVariants?e.cachedVariants:q(e)?i(e,{ +starts:e.starts?i(e.starts):null +}):Object.isFrozen(e)?i(e):e))("self"===e?r:e)))),r.contains.forEach((e=>{n(e,a) +})),r.starts&&n(r.starts,o),a.matcher=(e=>{const t=new s +;return e.contains.forEach((e=>t.addRule(e.begin,{rule:e,type:"begin" +}))),e.terminatorEnd&&t.addRule(e.terminatorEnd,{type:"end" +}),e.illegal&&t.addRule(e.illegal,{type:"illegal"}),t})(a),a}(e)}function q(e){ +return!!e&&(e.endsWithParent||q(e.starts))}class J extends Error{ +constructor(e,t){super(e),this.name="HTMLInjectionError",this.html=t}} +const Y=n,Q=i,ee=Symbol("nomatch"),te=n=>{ +const i=Object.create(null),s=Object.create(null),r=[];let o=!0 +;const a="Could not find the language '{}', did you forget to load/include a language module?",l={ +disableAutodetect:!0,name:"Plain text",contains:[]};let p={ +ignoreUnescapedHTML:!1,throwUnescapedHTML:!1,noHighlightRe:/^(no-?highlight)$/i, +languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-", +cssSelector:"pre code",languages:null,__emitter:c};function b(e){ +return p.noHighlightRe.test(e)}function m(e,t,n){let i="",s="" +;"object"==typeof t?(i=e, +n=t.ignoreIllegals,s=t.language):(G("10.7.0","highlight(lang, code, ...args) has been deprecated."), +G("10.7.0","Please use highlight(code, options) instead.\nhttps://github.com/highlightjs/highlight.js/issues/2277"), +s=e,i=t),void 0===n&&(n=!0);const r={code:i,language:s};N("before:highlight",r) +;const o=r.result?r.result:E(r.language,r.code,n) +;return o.code=r.code,N("after:highlight",o),o}function E(e,n,s,r){ +const c=Object.create(null);function l(){if(!N.keywords)return void M.addText(R) +;let e=0;N.keywordPatternRe.lastIndex=0;let t=N.keywordPatternRe.exec(R),n="" +;for(;t;){n+=R.substring(e,t.index) +;const s=_.case_insensitive?t[0].toLowerCase():t[0],r=(i=s,N.keywords[i]);if(r){ +const[e,i]=r +;if(M.addText(n),n="",c[s]=(c[s]||0)+1,c[s]<=7&&(A+=i),e.startsWith("_"))n+=t[0];else{ +const n=_.classNameAliases[e]||e;u(t[0],n)}}else n+=t[0] +;e=N.keywordPatternRe.lastIndex,t=N.keywordPatternRe.exec(R)}var i +;n+=R.substring(e),M.addText(n)}function g(){null!=N.subLanguage?(()=>{ +if(""===R)return;let e=null;if("string"==typeof N.subLanguage){ +if(!i[N.subLanguage])return void M.addText(R) +;e=E(N.subLanguage,R,!0,S[N.subLanguage]),S[N.subLanguage]=e._top +}else e=x(R,N.subLanguage.length?N.subLanguage:null) +;N.relevance>0&&(A+=e.relevance),M.__addSublanguage(e._emitter,e.language) +})():l(),R=""}function u(e,t){ +""!==e&&(M.startScope(t),M.addText(e),M.endScope())}function d(e,t){let n=1 +;const i=t.length-1;for(;n<=i;){if(!e._emit[n]){n++;continue} +const i=_.classNameAliases[e[n]]||e[n],s=t[n];i?u(s,i):(R=s,l(),R=""),n++}} +function h(e,t){ +return e.scope&&"string"==typeof e.scope&&M.openNode(_.classNameAliases[e.scope]||e.scope), +e.beginScope&&(e.beginScope._wrap?(u(R,_.classNameAliases[e.beginScope._wrap]||e.beginScope._wrap), +R=""):e.beginScope._multi&&(d(e.beginScope,t),R="")),N=Object.create(e,{parent:{ +value:N}}),N}function f(e,n,i){let s=((e,t)=>{const n=e&&e.exec(t) +;return n&&0===n.index})(e.endRe,i);if(s){if(e["on:end"]){const i=new t(e) +;e["on:end"](n,i),i.isMatchIgnored&&(s=!1)}if(s){ +for(;e.endsParent&&e.parent;)e=e.parent;return e}} +if(e.endsWithParent)return f(e.parent,n,i)}function b(e){ +return 0===N.matcher.regexIndex?(R+=e[0],1):(T=!0,0)}function m(e){ +const t=e[0],i=n.substring(e.index),s=f(N,e,i);if(!s)return ee;const r=N +;N.endScope&&N.endScope._wrap?(g(), +u(t,N.endScope._wrap)):N.endScope&&N.endScope._multi?(g(), +d(N.endScope,e)):r.skip?R+=t:(r.returnEnd||r.excludeEnd||(R+=t), +g(),r.excludeEnd&&(R=t));do{ +N.scope&&M.closeNode(),N.skip||N.subLanguage||(A+=N.relevance),N=N.parent +}while(N!==s.parent);return s.starts&&h(s.starts,e),r.returnEnd?0:t.length} +let w={};function y(i,r){const a=r&&r[0];if(R+=i,null==a)return g(),0 +;if("begin"===w.type&&"end"===r.type&&w.index===r.index&&""===a){ +if(R+=n.slice(r.index,r.index+1),!o){const t=Error(`0 width match regex (${e})`) +;throw t.languageName=e,t.badRule=w.rule,t}return 1} +if(w=r,"begin"===r.type)return(e=>{ +const n=e[0],i=e.rule,s=new t(i),r=[i.__beforeBegin,i["on:begin"]] +;for(const t of r)if(t&&(t(e,s),s.isMatchIgnored))return b(n) +;return i.skip?R+=n:(i.excludeBegin&&(R+=n), +g(),i.returnBegin||i.excludeBegin||(R=n)),h(i,e),i.returnBegin?0:n.length})(r) +;if("illegal"===r.type&&!s){ +const e=Error('Illegal lexeme "'+a+'" for mode "'+(N.scope||"")+'"') +;throw e.mode=N,e}if("end"===r.type){const e=m(r);if(e!==ee)return e} +if("illegal"===r.type&&""===a)return 1 +;if(I>1e5&&I>3*r.index)throw Error("potential infinite loop, way more iterations than matches") +;return R+=a,a.length}const _=O(e) +;if(!_)throw W(a.replace("{}",e)),Error('Unknown language: "'+e+'"') +;const k=V(_);let v="",N=r||k;const S={},M=new p.__emitter(p);(()=>{const e=[] +;for(let t=N;t!==_;t=t.parent)t.scope&&e.unshift(t.scope) +;e.forEach((e=>M.openNode(e)))})();let R="",A=0,j=0,I=0,T=!1;try{ +if(_.__emitTokens)_.__emitTokens(n,M);else{for(N.matcher.considerAll();;){ +I++,T?T=!1:N.matcher.considerAll(),N.matcher.lastIndex=j +;const e=N.matcher.exec(n);if(!e)break;const t=y(n.substring(j,e.index),e) +;j=e.index+t}y(n.substring(j))}return M.finalize(),v=M.toHTML(),{language:e, +value:v,relevance:A,illegal:!1,_emitter:M,_top:N}}catch(t){ +if(t.message&&t.message.includes("Illegal"))return{language:e,value:Y(n), +illegal:!0,relevance:0,_illegalBy:{message:t.message,index:j, +context:n.slice(j-100,j+100),mode:t.mode,resultSoFar:v},_emitter:M};if(o)return{ +language:e,value:Y(n),illegal:!1,relevance:0,errorRaised:t,_emitter:M,_top:N} +;throw t}}function x(e,t){t=t||p.languages||Object.keys(i);const n=(e=>{ +const t={value:Y(e),illegal:!1,relevance:0,_top:l,_emitter:new p.__emitter(p)} +;return t._emitter.addText(e),t})(e),s=t.filter(O).filter(v).map((t=>E(t,e,!1))) +;s.unshift(n);const r=s.sort(((e,t)=>{ +if(e.relevance!==t.relevance)return t.relevance-e.relevance +;if(e.language&&t.language){if(O(e.language).supersetOf===t.language)return 1 +;if(O(t.language).supersetOf===e.language)return-1}return 0})),[o,a]=r,c=o +;return c.secondBest=a,c}function w(e){let t=null;const n=(e=>{ +let t=e.className+" ";t+=e.parentNode?e.parentNode.className:"" +;const n=p.languageDetectRe.exec(t);if(n){const t=O(n[1]) +;return t||(X(a.replace("{}",n[1])), +X("Falling back to no-highlight mode for this block.",e)),t?n[1]:"no-highlight"} +return t.split(/\s+/).find((e=>b(e)||O(e)))})(e);if(b(n))return +;if(N("before:highlightElement",{el:e,language:n +}),e.dataset.highlighted)return void console.log("Element previously highlighted. To highlight again, first unset `dataset.highlighted`.",e) +;if(e.children.length>0&&(p.ignoreUnescapedHTML||(console.warn("One of your code blocks includes unescaped HTML. This is a potentially serious security risk."), +console.warn("https://github.com/highlightjs/highlight.js/wiki/security"), +console.warn("The element with unescaped HTML:"), +console.warn(e)),p.throwUnescapedHTML))throw new J("One of your code blocks includes unescaped HTML.",e.innerHTML) +;t=e;const i=t.textContent,r=n?m(i,{language:n,ignoreIllegals:!0}):x(i) +;e.innerHTML=r.value,e.dataset.highlighted="yes",((e,t,n)=>{const i=t&&s[t]||n +;e.classList.add("hljs"),e.classList.add("language-"+i) +})(e,n,r.language),e.result={language:r.language,re:r.relevance, +relevance:r.relevance},r.secondBest&&(e.secondBest={ +language:r.secondBest.language,relevance:r.secondBest.relevance +}),N("after:highlightElement",{el:e,result:r,text:i})}let y=!1;function _(){ +"loading"!==document.readyState?document.querySelectorAll(p.cssSelector).forEach(w):y=!0 +}function O(e){return e=(e||"").toLowerCase(),i[e]||i[s[e]]} +function k(e,{languageName:t}){"string"==typeof e&&(e=[e]),e.forEach((e=>{ +s[e.toLowerCase()]=t}))}function v(e){const t=O(e) +;return t&&!t.disableAutodetect}function N(e,t){const n=e;r.forEach((e=>{ +e[n]&&e[n](t)}))} +"undefined"!=typeof window&&window.addEventListener&&window.addEventListener("DOMContentLoaded",(()=>{ +y&&_()}),!1),Object.assign(n,{highlight:m,highlightAuto:x,highlightAll:_, +highlightElement:w, +highlightBlock:e=>(G("10.7.0","highlightBlock will be removed entirely in v12.0"), +G("10.7.0","Please use highlightElement now."),w(e)),configure:e=>{p=Q(p,e)}, +initHighlighting:()=>{ +_(),G("10.6.0","initHighlighting() deprecated. Use highlightAll() now.")}, +initHighlightingOnLoad:()=>{ +_(),G("10.6.0","initHighlightingOnLoad() deprecated. Use highlightAll() now.") +},registerLanguage:(e,t)=>{let s=null;try{s=t(n)}catch(t){ +if(W("Language definition for '{}' could not be registered.".replace("{}",e)), +!o)throw t;W(t),s=l} +s.name||(s.name=e),i[e]=s,s.rawDefinition=t.bind(null,n),s.aliases&&k(s.aliases,{ +languageName:e})},unregisterLanguage:e=>{delete i[e] +;for(const t of Object.keys(s))s[t]===e&&delete s[t]}, +listLanguages:()=>Object.keys(i),getLanguage:O,registerAliases:k, +autoDetection:v,inherit:Q,addPlugin:e=>{(e=>{ +e["before:highlightBlock"]&&!e["before:highlightElement"]&&(e["before:highlightElement"]=t=>{ +e["before:highlightBlock"](Object.assign({block:t.el},t)) +}),e["after:highlightBlock"]&&!e["after:highlightElement"]&&(e["after:highlightElement"]=t=>{ +e["after:highlightBlock"](Object.assign({block:t.el},t))})})(e),r.push(e)}, +removePlugin:e=>{const t=r.indexOf(e);-1!==t&&r.splice(t,1)}}),n.debugMode=()=>{ +o=!1},n.safeMode=()=>{o=!0},n.versionString="11.9.0",n.regex={concat:h, +lookahead:g,either:f,optional:d,anyNumberOfTimes:u} +;for(const t in A)"object"==typeof A[t]&&e(A[t]);return Object.assign(n,A),n +},ne=te({});ne.newInstance=()=>te({});export{ne as default}; \ No newline at end of file diff --git a/assets/js/vendor/hljs-yaml.min.js b/assets/js/vendor/hljs-yaml.min.js new file mode 100644 index 0000000..b9ed1ca --- /dev/null +++ b/assets/js/vendor/hljs-yaml.min.js @@ -0,0 +1,25 @@ +/*! `yaml` grammar compiled for Highlight.js 11.9.0 */ +var hljsGrammar=(()=>{"use strict";return e=>{ +const n="true false yes no null",a="[\\w#;/?:@&=+$,.~*'()[\\]]+",s={ +className:"string",relevance:0,variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/ +},{begin:/\S+/}],contains:[e.BACKSLASH_ESCAPE,{className:"template-variable", +variants:[{begin:/\{\{/,end:/\}\}/},{begin:/%\{/,end:/\}/}]}]},i=e.inherit(s,{ +variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/[^\s,{}[\]]+/}]}),l={ +end:",",endsWithParent:!0,excludeEnd:!0,keywords:n,relevance:0},t={begin:/\{/, +end:/\}/,contains:[l],illegal:"\\n",relevance:0},r={begin:"\\[",end:"\\]", +contains:[l],illegal:"\\n",relevance:0},b=[{className:"attr",variants:[{ +begin:"\\w[\\w :\\/.-]*:(?=[ \t]|$)"},{begin:'"\\w[\\w :\\/.-]*":(?=[ \t]|$)'},{ +begin:"'\\w[\\w :\\/.-]*':(?=[ \t]|$)"}]},{className:"meta",begin:"^---\\s*$", +relevance:10},{className:"string", +begin:"[\\|>]([1-9]?[+-])?[ ]*\\n( +)[^ ][^\\n]*\\n(\\2[^\\n]+\\n?)*"},{ +begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0, +relevance:0},{className:"type",begin:"!\\w+!"+a},{className:"type", +begin:"!<"+a+">"},{className:"type",begin:"!"+a},{className:"type",begin:"!!"+a +},{className:"meta",begin:"&"+e.UNDERSCORE_IDENT_RE+"$"},{className:"meta", +begin:"\\*"+e.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"-(?=[ ]|$)", +relevance:0},e.HASH_COMMENT_MODE,{beginKeywords:n,keywords:{literal:n}},{ +className:"number", +begin:"\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\.[0-9]*)?([ \\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\b" +},{className:"number",begin:e.C_NUMBER_RE+"\\b",relevance:0},t,r,s],c=[...b] +;return c.pop(),c.push(i),l.contains=c,{name:"YAML",case_insensitive:!0, +aliases:["yml"],contains:b}}})();export default hljsGrammar; \ No newline at end of file diff --git a/assets/js/vendor/marked.esm.js b/assets/js/vendor/marked.esm.js new file mode 100644 index 0000000..603ff05 --- /dev/null +++ b/assets/js/vendor/marked.esm.js @@ -0,0 +1,2424 @@ +/** + * marked v11.1.1 - a markdown parser + * Copyright (c) 2011-2023, 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/ + */ + +/** + * Gets the original marked default options. + */ +function _getDefaults() { + return { + async: false, + breaks: false, + extensions: null, + gfm: true, + hooks: null, + pedantic: false, + renderer: null, + silent: false, + tokenizer: null, + walkTokens: null + }; +} +let _defaults = _getDefaults(); +function changeDefaults(newDefaults) { + _defaults = newDefaults; +} + +/** + * Helpers + */ +const escapeTest = /[&<>"']/; +const escapeReplace = new RegExp(escapeTest.source, 'g'); +const escapeTestNoEncode = /[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/; +const escapeReplaceNoEncode = new RegExp(escapeTestNoEncode.source, 'g'); +const escapeReplacements = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' +}; +const getEscapeReplacement = (ch) => escapeReplacements[ch]; +function escape$1(html, encode) { + if (encode) { + if (escapeTest.test(html)) { + return html.replace(escapeReplace, getEscapeReplacement); + } + } + else { + if (escapeTestNoEncode.test(html)) { + return html.replace(escapeReplaceNoEncode, getEscapeReplacement); + } + } + return html; +} +const unescapeTest = /&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig; +function unescape(html) { + // explicitly match decimal, hex, and named HTML entities + return html.replace(unescapeTest, (_, n) => { + n = n.toLowerCase(); + if (n === 'colon') + return ':'; + if (n.charAt(0) === '#') { + return n.charAt(1) === 'x' + ? String.fromCharCode(parseInt(n.substring(2), 16)) + : String.fromCharCode(+n.substring(1)); + } + return ''; + }); +} +const caret = /(^|[^\[])\^/g; +function edit(regex, opt) { + let source = typeof regex === 'string' ? regex : regex.source; + opt = opt || ''; + const obj = { + replace: (name, val) => { + let valSource = typeof val === 'string' ? val : val.source; + valSource = valSource.replace(caret, '$1'); + source = source.replace(name, valSource); + return obj; + }, + getRegex: () => { + return new RegExp(source, opt); + } + }; + return obj; +} +function cleanUrl(href) { + try { + href = encodeURI(href).replace(/%25/g, '%'); + } + catch (e) { + return null; + } + return href; +} +const noopTest = { exec: () => null }; +function splitCells(tableRow, count) { + // ensure that every cell-delimiting pipe has a space + // before it to distinguish it from an escaped pipe + const row = tableRow.replace(/\|/g, (match, offset, str) => { + let escaped = false; + let curr = offset; + while (--curr >= 0 && str[curr] === '\\') + escaped = !escaped; + if (escaped) { + // odd number of slashes means | is escaped + // so we leave it alone + return '|'; + } + else { + // add space before unescaped | + return ' |'; + } + }), cells = row.split(/ \|/); + let i = 0; + // First/last cell in a row cannot be empty if it has no leading/trailing pipe + if (!cells[0].trim()) { + cells.shift(); + } + if (cells.length > 0 && !cells[cells.length - 1].trim()) { + cells.pop(); + } + if (count) { + if (cells.length > count) { + cells.splice(count); + } + else { + while (cells.length < count) + cells.push(''); + } + } + for (; i < cells.length; i++) { + // leading or trailing whitespace is ignored per the gfm spec + cells[i] = cells[i].trim().replace(/\\\|/g, '|'); + } + return cells; +} +/** + * Remove trailing 'c's. Equivalent to str.replace(/c*$/, ''). + * /c*$/ is vulnerable to REDOS. + * + * @param str + * @param c + * @param invert Remove suffix of non-c chars instead. Default falsey. + */ +function rtrim(str, c, invert) { + const l = str.length; + if (l === 0) { + return ''; + } + // Length of suffix matching the invert condition. + let suffLen = 0; + // Step left until we fail to match the invert condition. + while (suffLen < l) { + const currChar = str.charAt(l - suffLen - 1); + if (currChar === c && !invert) { + suffLen++; + } + else if (currChar !== c && invert) { + suffLen++; + } + else { + break; + } + } + return str.slice(0, l - suffLen); +} +function findClosingBracket(str, b) { + if (str.indexOf(b[1]) === -1) { + return -1; + } + let level = 0; + for (let i = 0; i < str.length; i++) { + if (str[i] === '\\') { + i++; + } + else if (str[i] === b[0]) { + level++; + } + else if (str[i] === b[1]) { + level--; + if (level < 0) { + return i; + } + } + } + return -1; +} + +function outputLink(cap, link, raw, lexer) { + const href = link.href; + const title = link.title ? escape$1(link.title) : null; + const text = cap[1].replace(/\\([\[\]])/g, '$1'); + if (cap[0].charAt(0) !== '!') { + lexer.state.inLink = true; + const token = { + type: 'link', + raw, + href, + title, + text, + tokens: lexer.inlineTokens(text) + }; + lexer.state.inLink = false; + return token; + } + return { + type: 'image', + raw, + href, + title, + text: escape$1(text) + }; +} +function indentCodeCompensation(raw, text) { + const matchIndentToCode = raw.match(/^(\s+)(?:```)/); + if (matchIndentToCode === null) { + return text; + } + const indentToCode = matchIndentToCode[1]; + return text + .split('\n') + .map(node => { + const matchIndentInNode = node.match(/^\s+/); + if (matchIndentInNode === null) { + return node; + } + const [indentInNode] = matchIndentInNode; + if (indentInNode.length >= indentToCode.length) { + return node.slice(indentToCode.length); + } + return node; + }) + .join('\n'); +} +/** + * Tokenizer + */ +class _Tokenizer { + options; + rules; // set by the lexer + lexer; // set by the lexer + constructor(options) { + this.options = options || _defaults; + } + space(src) { + const cap = this.rules.block.newline.exec(src); + if (cap && cap[0].length > 0) { + return { + type: 'space', + raw: cap[0] + }; + } + } + code(src) { + const cap = this.rules.block.code.exec(src); + if (cap) { + const text = cap[0].replace(/^ {1,4}/gm, ''); + return { + type: 'code', + raw: cap[0], + codeBlockStyle: 'indented', + text: !this.options.pedantic + ? rtrim(text, '\n') + : text + }; + } + } + fences(src) { + const cap = this.rules.block.fences.exec(src); + if (cap) { + const raw = cap[0]; + const text = indentCodeCompensation(raw, cap[3] || ''); + return { + type: 'code', + raw, + lang: cap[2] ? cap[2].trim().replace(this.rules.inline.anyPunctuation, '$1') : cap[2], + text + }; + } + } + heading(src) { + const cap = this.rules.block.heading.exec(src); + if (cap) { + let text = cap[2].trim(); + // remove trailing #s + if (/#$/.test(text)) { + const trimmed = rtrim(text, '#'); + if (this.options.pedantic) { + text = trimmed.trim(); + } + else if (!trimmed || / $/.test(trimmed)) { + // CommonMark requires space before trailing #s + text = trimmed.trim(); + } + } + return { + type: 'heading', + raw: cap[0], + depth: cap[1].length, + text, + tokens: this.lexer.inline(text) + }; + } + } + hr(src) { + const cap = this.rules.block.hr.exec(src); + if (cap) { + return { + type: 'hr', + raw: cap[0] + }; + } + } + blockquote(src) { + const cap = this.rules.block.blockquote.exec(src); + if (cap) { + const text = rtrim(cap[0].replace(/^ *>[ \t]?/gm, ''), '\n'); + const top = this.lexer.state.top; + this.lexer.state.top = true; + const tokens = this.lexer.blockTokens(text); + this.lexer.state.top = top; + return { + type: 'blockquote', + raw: cap[0], + tokens, + text + }; + } + } + list(src) { + let cap = this.rules.block.list.exec(src); + if (cap) { + let bull = cap[1].trim(); + const isordered = bull.length > 1; + const list = { + type: 'list', + raw: '', + ordered: isordered, + start: isordered ? +bull.slice(0, -1) : '', + loose: false, + items: [] + }; + bull = isordered ? `\\d{1,9}\\${bull.slice(-1)}` : `\\${bull}`; + if (this.options.pedantic) { + bull = isordered ? bull : '[*+-]'; + } + // Get next list item + const itemRegex = new RegExp(`^( {0,3}${bull})((?:[\t ][^\\n]*)?(?:\\n|$))`); + let raw = ''; + let itemContents = ''; + let endsWithBlankLine = false; + // Check if current bullet point can start a new List Item + while (src) { + let endEarly = false; + if (!(cap = itemRegex.exec(src))) { + break; + } + if (this.rules.block.hr.test(src)) { // End list if bullet was actually HR (possibly move into itemRegex?) + break; + } + raw = cap[0]; + src = src.substring(raw.length); + let line = cap[2].split('\n', 1)[0].replace(/^\t+/, (t) => ' '.repeat(3 * t.length)); + let nextLine = src.split('\n', 1)[0]; + let indent = 0; + if (this.options.pedantic) { + indent = 2; + itemContents = line.trimStart(); + } + else { + indent = cap[2].search(/[^ ]/); // Find first non-space char + indent = indent > 4 ? 1 : indent; // Treat indented code blocks (> 4 spaces) as having only 1 indent + itemContents = line.slice(indent); + indent += cap[1].length; + } + let blankLine = false; + if (!line && /^ *$/.test(nextLine)) { // Items begin with at most one blank line + raw += nextLine + '\n'; + src = src.substring(nextLine.length + 1); + endEarly = true; + } + if (!endEarly) { + const nextBulletRegex = new RegExp(`^ {0,${Math.min(3, indent - 1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`); + const hrRegex = new RegExp(`^ {0,${Math.min(3, indent - 1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`); + const fencesBeginRegex = new RegExp(`^ {0,${Math.min(3, indent - 1)}}(?:\`\`\`|~~~)`); + const headingBeginRegex = new RegExp(`^ {0,${Math.min(3, indent - 1)}}#`); + // Check if following lines should be included in List Item + while (src) { + const rawLine = src.split('\n', 1)[0]; + nextLine = rawLine; + // Re-align to follow commonmark nesting rules + if (this.options.pedantic) { + nextLine = nextLine.replace(/^ {1,4}(?=( {4})*[^ ])/g, ' '); + } + // End list item if found code fences + if (fencesBeginRegex.test(nextLine)) { + break; + } + // End list item if found start of new heading + if (headingBeginRegex.test(nextLine)) { + break; + } + // End list item if found start of new bullet + if (nextBulletRegex.test(nextLine)) { + break; + } + // Horizontal rule found + if (hrRegex.test(src)) { + break; + } + if (nextLine.search(/[^ ]/) >= indent || !nextLine.trim()) { // Dedent if possible + itemContents += '\n' + nextLine.slice(indent); + } + else { + // not enough indentation + if (blankLine) { + break; + } + // paragraph continuation unless last line was a different block level element + if (line.search(/[^ ]/) >= 4) { // indented code block + break; + } + if (fencesBeginRegex.test(line)) { + break; + } + if (headingBeginRegex.test(line)) { + break; + } + if (hrRegex.test(line)) { + break; + } + itemContents += '\n' + nextLine; + } + if (!blankLine && !nextLine.trim()) { // Check if current line is blank + blankLine = true; + } + raw += rawLine + '\n'; + src = src.substring(rawLine.length + 1); + line = nextLine.slice(indent); + } + } + if (!list.loose) { + // If the previous item ended with a blank line, the list is loose + if (endsWithBlankLine) { + list.loose = true; + } + else if (/\n *\n *$/.test(raw)) { + endsWithBlankLine = true; + } + } + let istask = null; + let ischecked; + // Check for task list items + if (this.options.gfm) { + istask = /^\[[ xX]\] /.exec(itemContents); + if (istask) { + ischecked = istask[0] !== '[ ] '; + itemContents = itemContents.replace(/^\[[ xX]\] +/, ''); + } + } + list.items.push({ + type: 'list_item', + raw, + task: !!istask, + checked: ischecked, + loose: false, + text: itemContents, + tokens: [] + }); + list.raw += raw; + } + // Do not consume newlines at end of final item. Alternatively, make itemRegex *start* with any newlines to simplify/speed up endsWithBlankLine logic + list.items[list.items.length - 1].raw = raw.trimEnd(); + (list.items[list.items.length - 1]).text = itemContents.trimEnd(); + list.raw = list.raw.trimEnd(); + // Item child tokens handled here at end because we needed to have the final item to trim it first + for (let i = 0; i < list.items.length; i++) { + this.lexer.state.top = false; + list.items[i].tokens = this.lexer.blockTokens(list.items[i].text, []); + if (!list.loose) { + // Check if list should be loose + const spacers = list.items[i].tokens.filter(t => t.type === 'space'); + const hasMultipleLineBreaks = spacers.length > 0 && spacers.some(t => /\n.*\n/.test(t.raw)); + list.loose = hasMultipleLineBreaks; + } + } + // Set all items to loose if list is loose + if (list.loose) { + for (let i = 0; i < list.items.length; i++) { + list.items[i].loose = true; + } + } + return list; + } + } + html(src) { + const cap = this.rules.block.html.exec(src); + if (cap) { + const token = { + type: 'html', + block: true, + raw: cap[0], + pre: cap[1] === 'pre' || cap[1] === 'script' || cap[1] === 'style', + text: cap[0] + }; + return token; + } + } + def(src) { + const cap = this.rules.block.def.exec(src); + if (cap) { + const tag = cap[1].toLowerCase().replace(/\s+/g, ' '); + const href = cap[2] ? cap[2].replace(/^<(.*)>$/, '$1').replace(this.rules.inline.anyPunctuation, '$1') : ''; + const title = cap[3] ? cap[3].substring(1, cap[3].length - 1).replace(this.rules.inline.anyPunctuation, '$1') : cap[3]; + return { + type: 'def', + tag, + raw: cap[0], + href, + title + }; + } + } + table(src) { + const cap = this.rules.block.table.exec(src); + if (!cap) { + return; + } + if (!/[:|]/.test(cap[2])) { + // delimiter row must have a pipe (|) or colon (:) otherwise it is a setext heading + return; + } + const headers = splitCells(cap[1]); + const aligns = cap[2].replace(/^\||\| *$/g, '').split('|'); + const rows = cap[3] && cap[3].trim() ? cap[3].replace(/\n[ \t]*$/, '').split('\n') : []; + const item = { + type: 'table', + raw: cap[0], + header: [], + align: [], + rows: [] + }; + if (headers.length !== aligns.length) { + // header and align columns must be equal, rows can be different. + return; + } + for (const align of aligns) { + if (/^ *-+: *$/.test(align)) { + item.align.push('right'); + } + else if (/^ *:-+: *$/.test(align)) { + item.align.push('center'); + } + else if (/^ *:-+ *$/.test(align)) { + item.align.push('left'); + } + else { + item.align.push(null); + } + } + for (const header of headers) { + item.header.push({ + text: header, + tokens: this.lexer.inline(header) + }); + } + for (const row of rows) { + item.rows.push(splitCells(row, item.header.length).map(cell => { + return { + text: cell, + tokens: this.lexer.inline(cell) + }; + })); + } + return item; + } + lheading(src) { + const cap = this.rules.block.lheading.exec(src); + if (cap) { + return { + type: 'heading', + raw: cap[0], + depth: cap[2].charAt(0) === '=' ? 1 : 2, + text: cap[1], + tokens: this.lexer.inline(cap[1]) + }; + } + } + paragraph(src) { + const cap = this.rules.block.paragraph.exec(src); + if (cap) { + const text = cap[1].charAt(cap[1].length - 1) === '\n' + ? cap[1].slice(0, -1) + : cap[1]; + return { + type: 'paragraph', + raw: cap[0], + text, + tokens: this.lexer.inline(text) + }; + } + } + text(src) { + const cap = this.rules.block.text.exec(src); + if (cap) { + return { + type: 'text', + raw: cap[0], + text: cap[0], + tokens: this.lexer.inline(cap[0]) + }; + } + } + escape(src) { + const cap = this.rules.inline.escape.exec(src); + if (cap) { + return { + type: 'escape', + raw: cap[0], + text: escape$1(cap[1]) + }; + } + } + tag(src) { + const cap = this.rules.inline.tag.exec(src); + if (cap) { + if (!this.lexer.state.inLink && /^/i.test(cap[0])) { + this.lexer.state.inLink = false; + } + if (!this.lexer.state.inRawBlock && /^<(pre|code|kbd|script)(\s|>)/i.test(cap[0])) { + this.lexer.state.inRawBlock = true; + } + else if (this.lexer.state.inRawBlock && /^<\/(pre|code|kbd|script)(\s|>)/i.test(cap[0])) { + this.lexer.state.inRawBlock = false; + } + return { + type: 'html', + raw: cap[0], + inLink: this.lexer.state.inLink, + inRawBlock: this.lexer.state.inRawBlock, + block: false, + text: cap[0] + }; + } + } + link(src) { + const cap = this.rules.inline.link.exec(src); + if (cap) { + const trimmedUrl = cap[2].trim(); + if (!this.options.pedantic && /^$/.test(trimmedUrl))) { + return; + } + // ending angle bracket cannot be escaped + const rtrimSlash = rtrim(trimmedUrl.slice(0, -1), '\\'); + if ((trimmedUrl.length - rtrimSlash.length) % 2 === 0) { + return; + } + } + else { + // find closing parenthesis + const lastParenIndex = findClosingBracket(cap[2], '()'); + if (lastParenIndex > -1) { + const start = cap[0].indexOf('!') === 0 ? 5 : 4; + const linkLen = start + cap[1].length + lastParenIndex; + cap[2] = cap[2].substring(0, lastParenIndex); + cap[0] = cap[0].substring(0, linkLen).trim(); + cap[3] = ''; + } + } + let href = cap[2]; + let title = ''; + if (this.options.pedantic) { + // split pedantic href and title + const link = /^([^'"]*[^\s])\s+(['"])(.*)\2/.exec(href); + if (link) { + href = link[1]; + title = link[3]; + } + } + else { + title = cap[3] ? cap[3].slice(1, -1) : ''; + } + href = href.trim(); + if (/^$/.test(trimmedUrl))) { + // pedantic allows starting angle bracket without ending angle bracket + href = href.slice(1); + } + else { + href = href.slice(1, -1); + } + } + return outputLink(cap, { + href: href ? href.replace(this.rules.inline.anyPunctuation, '$1') : href, + title: title ? title.replace(this.rules.inline.anyPunctuation, '$1') : title + }, cap[0], this.lexer); + } + } + reflink(src, links) { + let cap; + if ((cap = this.rules.inline.reflink.exec(src)) + || (cap = this.rules.inline.nolink.exec(src))) { + const linkString = (cap[2] || cap[1]).replace(/\s+/g, ' '); + const link = links[linkString.toLowerCase()]; + if (!link) { + const text = cap[0].charAt(0); + return { + type: 'text', + raw: text, + text + }; + } + return outputLink(cap, link, cap[0], this.lexer); + } + } + emStrong(src, maskedSrc, prevChar = '') { + let match = this.rules.inline.emStrongLDelim.exec(src); + if (!match) + return; + // _ can't be between two alphanumerics. \p{L}\p{N} includes non-english alphabet/numbers as well + if (match[3] && prevChar.match(/[\p{L}\p{N}]/u)) + return; + const nextChar = match[1] || match[2] || ''; + if (!nextChar || !prevChar || this.rules.inline.punctuation.exec(prevChar)) { + // unicode Regex counts emoji as 1 char; spread into array for proper count (used multiple times below) + const lLength = [...match[0]].length - 1; + let rDelim, rLength, delimTotal = lLength, midDelimTotal = 0; + const endReg = match[0][0] === '*' ? this.rules.inline.emStrongRDelimAst : this.rules.inline.emStrongRDelimUnd; + endReg.lastIndex = 0; + // Clip maskedSrc to same section of string as src (move to lexer?) + maskedSrc = maskedSrc.slice(-1 * src.length + lLength); + while ((match = endReg.exec(maskedSrc)) != null) { + rDelim = match[1] || match[2] || match[3] || match[4] || match[5] || match[6]; + if (!rDelim) + continue; // skip single * in __abc*abc__ + rLength = [...rDelim].length; + if (match[3] || match[4]) { // found another Left Delim + delimTotal += rLength; + continue; + } + else if (match[5] || match[6]) { // either Left or Right Delim + if (lLength % 3 && !((lLength + rLength) % 3)) { + midDelimTotal += rLength; + continue; // CommonMark Emphasis Rules 9-10 + } + } + delimTotal -= rLength; + if (delimTotal > 0) + continue; // Haven't found enough closing delimiters + // Remove extra characters. *a*** -> *a* + rLength = Math.min(rLength, rLength + delimTotal + midDelimTotal); + // char length can be >1 for unicode characters; + const lastCharLength = [...match[0]][0].length; + const raw = src.slice(0, lLength + match.index + lastCharLength + rLength); + // Create `em` if smallest delimiter has odd char count. *a*** + if (Math.min(lLength, rLength) % 2) { + const text = raw.slice(1, -1); + return { + type: 'em', + raw, + text, + tokens: this.lexer.inlineTokens(text) + }; + } + // Create 'strong' if smallest delimiter has even char count. **a*** + const text = raw.slice(2, -2); + return { + type: 'strong', + raw, + text, + tokens: this.lexer.inlineTokens(text) + }; + } + } + } + codespan(src) { + const cap = this.rules.inline.code.exec(src); + if (cap) { + let text = cap[2].replace(/\n/g, ' '); + const hasNonSpaceChars = /[^ ]/.test(text); + const hasSpaceCharsOnBothEnds = /^ /.test(text) && / $/.test(text); + if (hasNonSpaceChars && hasSpaceCharsOnBothEnds) { + text = text.substring(1, text.length - 1); + } + text = escape$1(text, true); + return { + type: 'codespan', + raw: cap[0], + text + }; + } + } + br(src) { + const cap = this.rules.inline.br.exec(src); + if (cap) { + return { + type: 'br', + raw: cap[0] + }; + } + } + del(src) { + const cap = this.rules.inline.del.exec(src); + if (cap) { + return { + type: 'del', + raw: cap[0], + text: cap[2], + tokens: this.lexer.inlineTokens(cap[2]) + }; + } + } + autolink(src) { + const cap = this.rules.inline.autolink.exec(src); + if (cap) { + let text, href; + if (cap[2] === '@') { + text = escape$1(cap[1]); + href = 'mailto:' + text; + } + else { + text = escape$1(cap[1]); + href = text; + } + return { + type: 'link', + raw: cap[0], + text, + href, + tokens: [ + { + type: 'text', + raw: text, + text + } + ] + }; + } + } + url(src) { + let cap; + if (cap = this.rules.inline.url.exec(src)) { + let text, href; + if (cap[2] === '@') { + text = escape$1(cap[0]); + href = 'mailto:' + text; + } + else { + // do extended autolink path validation + let prevCapZero; + do { + prevCapZero = cap[0]; + cap[0] = this.rules.inline._backpedal.exec(cap[0])?.[0] ?? ''; + } while (prevCapZero !== cap[0]); + text = escape$1(cap[0]); + if (cap[1] === 'www.') { + href = 'http://' + cap[0]; + } + else { + href = cap[0]; + } + } + return { + type: 'link', + raw: cap[0], + text, + href, + tokens: [ + { + type: 'text', + raw: text, + text + } + ] + }; + } + } + inlineText(src) { + const cap = this.rules.inline.text.exec(src); + if (cap) { + let text; + if (this.lexer.state.inRawBlock) { + text = cap[0]; + } + else { + text = escape$1(cap[0]); + } + return { + type: 'text', + raw: cap[0], + text + }; + } + } +} + +/** + * Block-Level Grammar + */ +const newline = /^(?: *(?:\n|$))+/; +const blockCode = /^( {4}[^\n]+(?:\n(?: *(?:\n|$))*)?)+/; +const fences = /^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/; +const hr = /^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/; +const heading = /^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/; +const bullet = /(?:[*+-]|\d{1,9}[.)])/; +const lheading = edit(/^(?!bull )((?:.|\n(?!\s*?\n|bull ))+?)\n {0,3}(=+|-+) *(?:\n+|$)/) + .replace(/bull/g, bullet) // lists can interrupt + .getRegex(); +const _paragraph = /^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/; +const blockText = /^[^\n]+/; +const _blockLabel = /(?!\s*\])(?:\\.|[^\[\]\\])+/; +const def = edit(/^ {0,3}\[(label)\]: *(?:\n *)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n *)?| *\n *)(title))? *(?:\n+|$)/) + .replace('label', _blockLabel) + .replace('title', /(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/) + .getRegex(); +const list = edit(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/) + .replace(/bull/g, bullet) + .getRegex(); +const _tag = '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|section|source|summary|table|tbody|td|tfoot|th|thead|title|tr' + + '|track|ul'; +const _comment = /|$)/; +const html = edit('^ {0,3}(?:' // optional indentation + + '<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)' // (1) + + '|comment[^\\n]*(\\n+|$)' // (2) + + '|<\\?[\\s\\S]*?(?:\\?>\\n*|$)' // (3) + + '|\\n*|$)' // (4) + + '|\\n*|$)' // (5) + + '|)[\\s\\S]*?(?:(?:\\n *)+\\n|$)' // (6) + + '|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)' // (7) open tag + + '|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)' // (7) closing tag + + ')', 'i') + .replace('comment', _comment) + .replace('tag', _tag) + .replace('attribute', / +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/) + .getRegex(); +const paragraph = edit(_paragraph) + .replace('hr', hr) + .replace('heading', ' {0,3}#{1,6}(?:\\s|$)') + .replace('|lheading', '') // setex headings don't interrupt commonmark paragraphs + .replace('|table', '') + .replace('blockquote', ' {0,3}>') + .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n') + .replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt + .replace('html', ')|<(?:script|pre|style|textarea|!--)') + .replace('tag', _tag) // pars can be interrupted by type (6) html blocks + .getRegex(); +const blockquote = edit(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/) + .replace('paragraph', paragraph) + .getRegex(); +/** + * Normal Block Grammar + */ +const blockNormal = { + blockquote, + code: blockCode, + def, + fences, + heading, + hr, + html, + lheading, + list, + newline, + paragraph, + table: noopTest, + text: blockText +}; +/** + * GFM Block Grammar + */ +const gfmTable = edit('^ *([^\\n ].*)\\n' // Header + + ' {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)' // Align + + '(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)') // Cells + .replace('hr', hr) + .replace('heading', ' {0,3}#{1,6}(?:\\s|$)') + .replace('blockquote', ' {0,3}>') + .replace('code', ' {4}[^\\n]') + .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n') + .replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt + .replace('html', ')|<(?:script|pre|style|textarea|!--)') + .replace('tag', _tag) // tables can be interrupted by type (6) html blocks + .getRegex(); +const blockGfm = { + ...blockNormal, + table: gfmTable, + paragraph: edit(_paragraph) + .replace('hr', hr) + .replace('heading', ' {0,3}#{1,6}(?:\\s|$)') + .replace('|lheading', '') // setex headings don't interrupt commonmark paragraphs + .replace('table', gfmTable) // interrupt paragraphs with table + .replace('blockquote', ' {0,3}>') + .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n') + .replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt + .replace('html', ')|<(?:script|pre|style|textarea|!--)') + .replace('tag', _tag) // pars can be interrupted by type (6) html blocks + .getRegex() +}; +/** + * Pedantic grammar (original John Gruber's loose markdown specification) + */ +const blockPedantic = { + ...blockNormal, + html: edit('^ *(?:comment *(?:\\n|\\s*$)' + + '|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)' // closed tag + + '|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))') + .replace('comment', _comment) + .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: /^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/, + heading: /^(#{1,6})(.*)(?:\n+|$)/, + fences: noopTest, // fences not supported + lheading: /^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/, + paragraph: edit(_paragraph) + .replace('hr', hr) + .replace('heading', ' *#{1,6} *[^\n]') + .replace('lheading', lheading) + .replace('|table', '') + .replace('blockquote', ' {0,3}>') + .replace('|fences', '') + .replace('|list', '') + .replace('|html', '') + .replace('|tag', '') + .getRegex() +}; +/** + * Inline-Level Grammar + */ +const escape = /^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/; +const inlineCode = /^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/; +const br = /^( {2,}|\\)\n(?!\s*$)/; +const inlineText = /^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\`^|~'; +const punctuation = edit(/^((?![*_])[\spunctuation])/, 'u') + .replace(/punctuation/g, _punctuation).getRegex(); +// sequences em should skip over [title](link), `code`, +const blockSkip = /\[[^[\]]*?\]\([^\(\)]*?\)|`[^`]*?`|<[^<>]*?>/g; +const emStrongLDelim = edit(/^(?:\*+(?:((?!\*)[punct])|[^\s*]))|^_+(?:((?!_)[punct])|([^\s_]))/, 'u') + .replace(/punct/g, _punctuation) + .getRegex(); +const emStrongRDelimAst = edit('^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)' // Skip orphan inside strong + + '|[^*]+(?=[^*])' // Consume to delim + + '|(?!\\*)[punct](\\*+)(?=[\\s]|$)' // (1) #*** can only be a Right Delimiter + + '|[^punct\\s](\\*+)(?!\\*)(?=[punct\\s]|$)' // (2) a***#, a*** can only be a Right Delimiter + + '|(?!\\*)[punct\\s](\\*+)(?=[^punct\\s])' // (3) #***a, ***a can only be Left Delimiter + + '|[\\s](\\*+)(?!\\*)(?=[punct])' // (4) ***# can only be Left Delimiter + + '|(?!\\*)[punct](\\*+)(?!\\*)(?=[punct])' // (5) #***# can be either Left or Right Delimiter + + '|[^punct\\s](\\*+)(?=[^punct\\s])', 'gu') // (6) a***a can be either Left or Right Delimiter + .replace(/punct/g, _punctuation) + .getRegex(); +// (6) Not allowed for _ +const emStrongRDelimUnd = edit('^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)' // Skip orphan inside strong + + '|[^_]+(?=[^_])' // Consume to delim + + '|(?!_)[punct](_+)(?=[\\s]|$)' // (1) #___ can only be a Right Delimiter + + '|[^punct\\s](_+)(?!_)(?=[punct\\s]|$)' // (2) a___#, a___ can only be a Right Delimiter + + '|(?!_)[punct\\s](_+)(?=[^punct\\s])' // (3) #___a, ___a can only be Left Delimiter + + '|[\\s](_+)(?!_)(?=[punct])' // (4) ___# can only be Left Delimiter + + '|(?!_)[punct](_+)(?!_)(?=[punct])', 'gu') // (5) #___# can be either Left or Right Delimiter + .replace(/punct/g, _punctuation) + .getRegex(); +const anyPunctuation = edit(/\\([punct])/, 'gu') + .replace(/punct/g, _punctuation) + .getRegex(); +const autolink = edit(/^<(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(); +const _inlineComment = edit(_comment).replace('(?:-->|$)', '-->').getRegex(); +const tag = edit('^comment' + + '|^' // self-closing tag + + '|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>' // open tag + + '|^<\\?[\\s\\S]*?\\?>' // processing instruction, e.g. + + '|^' // declaration, e.g. + + '|^') // CDATA section + .replace('comment', _inlineComment) + .replace('attribute', /\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/) + .getRegex(); +const _inlineLabel = /(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/; +const link = edit(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/) + .replace('label', _inlineLabel) + .replace('href', /<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/) + .replace('title', /"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/) + .getRegex(); +const reflink = edit(/^!?\[(label)\]\[(ref)\]/) + .replace('label', _inlineLabel) + .replace('ref', _blockLabel) + .getRegex(); +const nolink = edit(/^!?\[(ref)\](?:\[\])?/) + .replace('ref', _blockLabel) + .getRegex(); +const reflinkSearch = edit('reflink|nolink(?!\\()', 'g') + .replace('reflink', reflink) + .replace('nolink', nolink) + .getRegex(); +/** + * Normal Inline Grammar + */ +const inlineNormal = { + _backpedal: noopTest, // only used for GFM url + anyPunctuation, + autolink, + blockSkip, + br, + code: inlineCode, + del: noopTest, + emStrongLDelim, + emStrongRDelimAst, + emStrongRDelimUnd, + escape, + link, + nolink, + punctuation, + reflink, + reflinkSearch, + tag, + text: inlineText, + url: noopTest +}; +/** + * Pedantic Inline Grammar + */ +const inlinePedantic = { + ...inlineNormal, + link: edit(/^!?\[(label)\]\((.*?)\)/) + .replace('label', _inlineLabel) + .getRegex(), + reflink: edit(/^!?\[(label)\]\s*\[([^\]]*)\]/) + .replace('label', _inlineLabel) + .getRegex() +}; +/** + * GFM Inline Grammar + */ +const inlineGfm = { + ...inlineNormal, + escape: edit(escape).replace('])', '~|])').getRegex(), + url: edit(/^((?: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~])\1(?=[^~]|$)/, + text: /^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\ { + return leading + ' '.repeat(tabs.length); + }); + } + let token; + let lastToken; + let cutSrc; + let lastParagraphClipped; + while (src) { + if (this.options.extensions + && this.options.extensions.block + && this.options.extensions.block.some((extTokenizer) => { + if (token = extTokenizer.call({ lexer: this }, src, tokens)) { + src = src.substring(token.raw.length); + tokens.push(token); + return true; + } + return false; + })) { + continue; + } + // newline + if (token = this.tokenizer.space(src)) { + src = src.substring(token.raw.length); + if (token.raw.length === 1 && tokens.length > 0) { + // if there's a single \n as a spacer, it's terminating the last line, + // so move it there so that we don't get unnecessary paragraph tags + tokens[tokens.length - 1].raw += '\n'; + } + else { + tokens.push(token); + } + continue; + } + // code + if (token = this.tokenizer.code(src)) { + src = src.substring(token.raw.length); + lastToken = tokens[tokens.length - 1]; + // An indented code block cannot interrupt a paragraph. + if (lastToken && (lastToken.type === 'paragraph' || lastToken.type === 'text')) { + lastToken.raw += '\n' + token.raw; + lastToken.text += '\n' + token.text; + this.inlineQueue[this.inlineQueue.length - 1].src = lastToken.text; + } + else { + tokens.push(token); + } + continue; + } + // fences + if (token = this.tokenizer.fences(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // heading + if (token = this.tokenizer.heading(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // hr + if (token = this.tokenizer.hr(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // blockquote + if (token = this.tokenizer.blockquote(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // list + if (token = this.tokenizer.list(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // html + if (token = this.tokenizer.html(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // def + if (token = this.tokenizer.def(src)) { + src = src.substring(token.raw.length); + lastToken = tokens[tokens.length - 1]; + if (lastToken && (lastToken.type === 'paragraph' || lastToken.type === 'text')) { + lastToken.raw += '\n' + token.raw; + lastToken.text += '\n' + token.raw; + this.inlineQueue[this.inlineQueue.length - 1].src = lastToken.text; + } + else if (!this.tokens.links[token.tag]) { + this.tokens.links[token.tag] = { + href: token.href, + title: token.title + }; + } + continue; + } + // table (gfm) + if (token = this.tokenizer.table(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // lheading + if (token = this.tokenizer.lheading(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // top-level paragraph + // prevent paragraph consuming extensions by clipping 'src' to extension start + cutSrc = src; + if (this.options.extensions && this.options.extensions.startBlock) { + let startIndex = Infinity; + const tempSrc = src.slice(1); + let tempStart; + this.options.extensions.startBlock.forEach((getStartIndex) => { + tempStart = getStartIndex.call({ lexer: this }, tempSrc); + if (typeof tempStart === 'number' && tempStart >= 0) { + startIndex = Math.min(startIndex, tempStart); + } + }); + if (startIndex < Infinity && startIndex >= 0) { + cutSrc = src.substring(0, startIndex + 1); + } + } + if (this.state.top && (token = this.tokenizer.paragraph(cutSrc))) { + lastToken = tokens[tokens.length - 1]; + if (lastParagraphClipped && lastToken.type === 'paragraph') { + lastToken.raw += '\n' + token.raw; + lastToken.text += '\n' + token.text; + this.inlineQueue.pop(); + this.inlineQueue[this.inlineQueue.length - 1].src = lastToken.text; + } + else { + tokens.push(token); + } + lastParagraphClipped = (cutSrc.length !== src.length); + src = src.substring(token.raw.length); + continue; + } + // text + if (token = this.tokenizer.text(src)) { + src = src.substring(token.raw.length); + lastToken = tokens[tokens.length - 1]; + if (lastToken && lastToken.type === 'text') { + lastToken.raw += '\n' + token.raw; + lastToken.text += '\n' + token.text; + this.inlineQueue.pop(); + this.inlineQueue[this.inlineQueue.length - 1].src = lastToken.text; + } + else { + tokens.push(token); + } + continue; + } + if (src) { + const errMsg = 'Infinite loop on byte: ' + src.charCodeAt(0); + if (this.options.silent) { + console.error(errMsg); + break; + } + else { + throw new Error(errMsg); + } + } + } + this.state.top = true; + return tokens; + } + inline(src, tokens = []) { + this.inlineQueue.push({ src, tokens }); + return tokens; + } + /** + * Lexing/Compiling + */ + inlineTokens(src, tokens = []) { + let token, lastToken, cutSrc; + // String with links masked to avoid interference with em and strong + let maskedSrc = src; + let match; + let keepPrevChar, prevChar; + // Mask out reflinks + if (this.tokens.links) { + const links = Object.keys(this.tokens.links); + if (links.length > 0) { + while ((match = this.tokenizer.rules.inline.reflinkSearch.exec(maskedSrc)) != null) { + if (links.includes(match[0].slice(match[0].lastIndexOf('[') + 1, -1))) { + maskedSrc = maskedSrc.slice(0, match.index) + '[' + 'a'.repeat(match[0].length - 2) + ']' + maskedSrc.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex); + } + } + } + } + // Mask out other blocks + while ((match = this.tokenizer.rules.inline.blockSkip.exec(maskedSrc)) != null) { + maskedSrc = maskedSrc.slice(0, match.index) + '[' + 'a'.repeat(match[0].length - 2) + ']' + maskedSrc.slice(this.tokenizer.rules.inline.blockSkip.lastIndex); + } + // Mask out escaped characters + while ((match = this.tokenizer.rules.inline.anyPunctuation.exec(maskedSrc)) != null) { + maskedSrc = maskedSrc.slice(0, match.index) + '++' + maskedSrc.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex); + } + while (src) { + if (!keepPrevChar) { + prevChar = ''; + } + keepPrevChar = false; + // extensions + if (this.options.extensions + && this.options.extensions.inline + && this.options.extensions.inline.some((extTokenizer) => { + if (token = extTokenizer.call({ lexer: this }, src, tokens)) { + src = src.substring(token.raw.length); + tokens.push(token); + return true; + } + return false; + })) { + continue; + } + // escape + if (token = this.tokenizer.escape(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // tag + if (token = this.tokenizer.tag(src)) { + src = src.substring(token.raw.length); + lastToken = tokens[tokens.length - 1]; + if (lastToken && token.type === 'text' && lastToken.type === 'text') { + lastToken.raw += token.raw; + lastToken.text += token.text; + } + else { + tokens.push(token); + } + continue; + } + // link + if (token = this.tokenizer.link(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // reflink, nolink + if (token = this.tokenizer.reflink(src, this.tokens.links)) { + src = src.substring(token.raw.length); + lastToken = tokens[tokens.length - 1]; + if (lastToken && token.type === 'text' && lastToken.type === 'text') { + lastToken.raw += token.raw; + lastToken.text += token.text; + } + else { + tokens.push(token); + } + continue; + } + // em & strong + if (token = this.tokenizer.emStrong(src, maskedSrc, prevChar)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // code + if (token = this.tokenizer.codespan(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // br + if (token = this.tokenizer.br(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // del (gfm) + if (token = this.tokenizer.del(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // autolink + if (token = this.tokenizer.autolink(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // url (gfm) + if (!this.state.inLink && (token = this.tokenizer.url(src))) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // text + // prevent inlineText consuming extensions by clipping 'src' to extension start + cutSrc = src; + if (this.options.extensions && this.options.extensions.startInline) { + let startIndex = Infinity; + const tempSrc = src.slice(1); + let tempStart; + this.options.extensions.startInline.forEach((getStartIndex) => { + tempStart = getStartIndex.call({ lexer: this }, tempSrc); + if (typeof tempStart === 'number' && tempStart >= 0) { + startIndex = Math.min(startIndex, tempStart); + } + }); + if (startIndex < Infinity && startIndex >= 0) { + cutSrc = src.substring(0, startIndex + 1); + } + } + if (token = this.tokenizer.inlineText(cutSrc)) { + src = src.substring(token.raw.length); + if (token.raw.slice(-1) !== '_') { // Track prevChar before string of ____ started + prevChar = token.raw.slice(-1); + } + keepPrevChar = true; + lastToken = tokens[tokens.length - 1]; + if (lastToken && lastToken.type === 'text') { + lastToken.raw += token.raw; + lastToken.text += token.text; + } + else { + tokens.push(token); + } + continue; + } + if (src) { + const errMsg = 'Infinite loop on byte: ' + src.charCodeAt(0); + if (this.options.silent) { + console.error(errMsg); + break; + } + else { + throw new Error(errMsg); + } + } + } + return tokens; + } +} + +/** + * Renderer + */ +class _Renderer { + options; + constructor(options) { + this.options = options || _defaults; + } + code(code, infostring, escaped) { + const lang = (infostring || '').match(/^\S*/)?.[0]; + code = code.replace(/\n$/, '') + '\n'; + if (!lang) { + return '
'
+                + (escaped ? code : escape$1(code, true))
+                + '
\n'; + } + return '
'
+            + (escaped ? code : escape$1(code, true))
+            + '
\n'; + } + blockquote(quote) { + return `
\n${quote}
\n`; + } + html(html, block) { + return html; + } + heading(text, level, raw) { + // ignore IDs + return `${text}\n`; + } + hr() { + return '
\n'; + } + list(body, ordered, start) { + const type = ordered ? 'ol' : 'ul'; + const startatt = (ordered && start !== 1) ? (' start="' + start + '"') : ''; + return '<' + type + startatt + '>\n' + body + '\n'; + } + listitem(text, task, checked) { + return `
  • ${text}
  • \n`; + } + checkbox(checked) { + return ''; + } + paragraph(text) { + return `

    ${text}

    \n`; + } + table(header, body) { + if (body) + body = `${body}`; + return '\n' + + '\n' + + header + + '\n' + + body + + '
    \n'; + } + tablerow(content) { + return `\n${content}\n`; + } + tablecell(content, flags) { + const type = flags.header ? 'th' : 'td'; + const tag = flags.align + ? `<${type} align="${flags.align}">` + : `<${type}>`; + return tag + content + `\n`; + } + /** + * span level renderer + */ + strong(text) { + return `${text}`; + } + em(text) { + return `${text}`; + } + codespan(text) { + return `${text}`; + } + br() { + return '
    '; + } + del(text) { + return `${text}`; + } + link(href, title, text) { + const cleanHref = cleanUrl(href); + if (cleanHref === null) { + return text; + } + href = cleanHref; + let out = '
    '; + return out; + } + image(href, title, text) { + const cleanHref = cleanUrl(href); + if (cleanHref === null) { + return text; + } + href = cleanHref; + let out = `${text} 0 && item.tokens[0].type === 'paragraph') { + item.tokens[0].text = checkbox + ' ' + item.tokens[0].text; + if (item.tokens[0].tokens && item.tokens[0].tokens.length > 0 && item.tokens[0].tokens[0].type === 'text') { + item.tokens[0].tokens[0].text = checkbox + ' ' + item.tokens[0].tokens[0].text; + } + } + else { + item.tokens.unshift({ + type: 'text', + text: checkbox + ' ' + }); + } + } + else { + itemBody += checkbox + ' '; + } + } + itemBody += this.parse(item.tokens, loose); + body += this.renderer.listitem(itemBody, task, !!checked); + } + out += this.renderer.list(body, ordered, start); + continue; + } + case 'html': { + const htmlToken = token; + out += this.renderer.html(htmlToken.text, htmlToken.block); + continue; + } + case 'paragraph': { + const paragraphToken = token; + out += this.renderer.paragraph(this.parseInline(paragraphToken.tokens)); + continue; + } + case 'text': { + let textToken = token; + let body = textToken.tokens ? this.parseInline(textToken.tokens) : textToken.text; + while (i + 1 < tokens.length && tokens[i + 1].type === 'text') { + textToken = tokens[++i]; + body += '\n' + (textToken.tokens ? this.parseInline(textToken.tokens) : textToken.text); + } + out += top ? this.renderer.paragraph(body) : body; + continue; + } + default: { + const errMsg = 'Token with "' + token.type + '" type was not found.'; + if (this.options.silent) { + console.error(errMsg); + return ''; + } + else { + throw new Error(errMsg); + } + } + } + } + return out; + } + /** + * Parse Inline Tokens + */ + parseInline(tokens, renderer) { + renderer = renderer || this.renderer; + let out = ''; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + // Run any renderer extensions + if (this.options.extensions && this.options.extensions.renderers && this.options.extensions.renderers[token.type]) { + const ret = this.options.extensions.renderers[token.type].call({ parser: this }, token); + if (ret !== false || !['escape', 'html', 'link', 'image', 'strong', 'em', 'codespan', 'br', 'del', 'text'].includes(token.type)) { + out += ret || ''; + continue; + } + } + switch (token.type) { + case 'escape': { + const escapeToken = token; + out += renderer.text(escapeToken.text); + break; + } + case 'html': { + const tagToken = token; + out += renderer.html(tagToken.text); + break; + } + case 'link': { + const linkToken = token; + out += renderer.link(linkToken.href, linkToken.title, this.parseInline(linkToken.tokens, renderer)); + break; + } + case 'image': { + const imageToken = token; + out += renderer.image(imageToken.href, imageToken.title, imageToken.text); + break; + } + case 'strong': { + const strongToken = token; + out += renderer.strong(this.parseInline(strongToken.tokens, renderer)); + break; + } + case 'em': { + const emToken = token; + out += renderer.em(this.parseInline(emToken.tokens, renderer)); + break; + } + case 'codespan': { + const codespanToken = token; + out += renderer.codespan(codespanToken.text); + break; + } + case 'br': { + out += renderer.br(); + break; + } + case 'del': { + const delToken = token; + out += renderer.del(this.parseInline(delToken.tokens, renderer)); + break; + } + case 'text': { + const textToken = token; + out += renderer.text(textToken.text); + break; + } + default: { + const errMsg = 'Token with "' + token.type + '" type was not found.'; + if (this.options.silent) { + console.error(errMsg); + return ''; + } + else { + throw new Error(errMsg); + } + } + } + } + return out; + } +} + +class _Hooks { + options; + constructor(options) { + this.options = options || _defaults; + } + static passThroughHooks = new Set([ + 'preprocess', + 'postprocess', + 'processAllTokens' + ]); + /** + * Process markdown before marked + */ + preprocess(markdown) { + return markdown; + } + /** + * Process HTML after marked is finished + */ + postprocess(html) { + return html; + } + /** + * Process all tokens before walk tokens + */ + processAllTokens(tokens) { + return tokens; + } +} + +class Marked { + defaults = _getDefaults(); + options = this.setOptions; + parse = this.#parseMarkdown(_Lexer.lex, _Parser.parse); + parseInline = this.#parseMarkdown(_Lexer.lexInline, _Parser.parseInline); + Parser = _Parser; + Renderer = _Renderer; + TextRenderer = _TextRenderer; + Lexer = _Lexer; + Tokenizer = _Tokenizer; + Hooks = _Hooks; + constructor(...args) { + this.use(...args); + } + /** + * Run callback for every token + */ + walkTokens(tokens, callback) { + let values = []; + for (const token of tokens) { + values = values.concat(callback.call(this, token)); + switch (token.type) { + case 'table': { + const tableToken = token; + for (const cell of tableToken.header) { + values = values.concat(this.walkTokens(cell.tokens, callback)); + } + for (const row of tableToken.rows) { + for (const cell of row) { + values = values.concat(this.walkTokens(cell.tokens, callback)); + } + } + break; + } + case 'list': { + const listToken = token; + values = values.concat(this.walkTokens(listToken.items, callback)); + break; + } + default: { + const genericToken = token; + if (this.defaults.extensions?.childTokens?.[genericToken.type]) { + this.defaults.extensions.childTokens[genericToken.type].forEach((childTokens) => { + values = values.concat(this.walkTokens(genericToken[childTokens], callback)); + }); + } + else if (genericToken.tokens) { + values = values.concat(this.walkTokens(genericToken.tokens, callback)); + } + } + } + } + return values; + } + use(...args) { + const extensions = this.defaults.extensions || { renderers: {}, childTokens: {} }; + args.forEach((pack) => { + // copy options to new object + const opts = { ...pack }; + // set async to true if it was set to true before + opts.async = this.defaults.async || opts.async || false; + // ==-- Parse "addon" extensions --== // + if (pack.extensions) { + pack.extensions.forEach((ext) => { + if (!ext.name) { + throw new Error('extension name required'); + } + if ('renderer' in ext) { // Renderer extensions + const prevRenderer = extensions.renderers[ext.name]; + if (prevRenderer) { + // Replace extension with func to run new extension but fall back if false + extensions.renderers[ext.name] = function (...args) { + let ret = ext.renderer.apply(this, args); + if (ret === false) { + ret = prevRenderer.apply(this, args); + } + return ret; + }; + } + else { + extensions.renderers[ext.name] = ext.renderer; + } + } + if ('tokenizer' in ext) { // Tokenizer Extensions + if (!ext.level || (ext.level !== 'block' && ext.level !== 'inline')) { + throw new Error("extension level must be 'block' or 'inline'"); + } + const extLevel = extensions[ext.level]; + if (extLevel) { + extLevel.unshift(ext.tokenizer); + } + else { + extensions[ext.level] = [ext.tokenizer]; + } + if (ext.start) { // Function to check for start of token + if (ext.level === 'block') { + if (extensions.startBlock) { + extensions.startBlock.push(ext.start); + } + else { + extensions.startBlock = [ext.start]; + } + } + else if (ext.level === 'inline') { + if (extensions.startInline) { + extensions.startInline.push(ext.start); + } + else { + extensions.startInline = [ext.start]; + } + } + } + } + if ('childTokens' in ext && ext.childTokens) { // Child tokens to be visited by walkTokens + extensions.childTokens[ext.name] = ext.childTokens; + } + }); + opts.extensions = extensions; + } + // ==-- Parse "overwrite" extensions --== // + if (pack.renderer) { + const renderer = this.defaults.renderer || new _Renderer(this.defaults); + for (const prop in pack.renderer) { + if (!(prop in renderer)) { + throw new Error(`renderer '${prop}' does not exist`); + } + if (prop === 'options') { + // ignore options property + continue; + } + const rendererProp = prop; + const rendererFunc = pack.renderer[rendererProp]; + const prevRenderer = renderer[rendererProp]; + // Replace renderer with func to run extension, but fall back if false + renderer[rendererProp] = (...args) => { + let ret = rendererFunc.apply(renderer, args); + if (ret === false) { + ret = prevRenderer.apply(renderer, args); + } + return ret || ''; + }; + } + opts.renderer = renderer; + } + if (pack.tokenizer) { + const tokenizer = this.defaults.tokenizer || new _Tokenizer(this.defaults); + for (const prop in pack.tokenizer) { + if (!(prop in tokenizer)) { + throw new Error(`tokenizer '${prop}' does not exist`); + } + if (['options', 'rules', 'lexer'].includes(prop)) { + // ignore options, rules, and lexer properties + continue; + } + const tokenizerProp = prop; + const tokenizerFunc = pack.tokenizer[tokenizerProp]; + const prevTokenizer = tokenizer[tokenizerProp]; + // Replace tokenizer with func to run extension, but fall back if false + // @ts-expect-error cannot type tokenizer function dynamically + tokenizer[tokenizerProp] = (...args) => { + let ret = tokenizerFunc.apply(tokenizer, args); + if (ret === false) { + ret = prevTokenizer.apply(tokenizer, args); + } + return ret; + }; + } + opts.tokenizer = tokenizer; + } + // ==-- Parse Hooks extensions --== // + if (pack.hooks) { + const hooks = this.defaults.hooks || new _Hooks(); + for (const prop in pack.hooks) { + if (!(prop in hooks)) { + throw new Error(`hook '${prop}' does not exist`); + } + if (prop === 'options') { + // ignore options property + continue; + } + const hooksProp = prop; + const hooksFunc = pack.hooks[hooksProp]; + const prevHook = hooks[hooksProp]; + if (_Hooks.passThroughHooks.has(prop)) { + // @ts-expect-error cannot type hook function dynamically + hooks[hooksProp] = (arg) => { + if (this.defaults.async) { + return Promise.resolve(hooksFunc.call(hooks, arg)).then(ret => { + return prevHook.call(hooks, ret); + }); + } + const ret = hooksFunc.call(hooks, arg); + return prevHook.call(hooks, ret); + }; + } + else { + // @ts-expect-error cannot type hook function dynamically + hooks[hooksProp] = (...args) => { + let ret = hooksFunc.apply(hooks, args); + if (ret === false) { + ret = prevHook.apply(hooks, args); + } + return ret; + }; + } + } + opts.hooks = hooks; + } + // ==-- Parse WalkTokens extensions --== // + if (pack.walkTokens) { + const walkTokens = this.defaults.walkTokens; + const packWalktokens = pack.walkTokens; + opts.walkTokens = function (token) { + let values = []; + values.push(packWalktokens.call(this, token)); + if (walkTokens) { + values = values.concat(walkTokens.call(this, token)); + } + return values; + }; + } + this.defaults = { ...this.defaults, ...opts }; + }); + return this; + } + setOptions(opt) { + this.defaults = { ...this.defaults, ...opt }; + return this; + } + lexer(src, options) { + return _Lexer.lex(src, options ?? this.defaults); + } + parser(tokens, options) { + return _Parser.parse(tokens, options ?? this.defaults); + } + #parseMarkdown(lexer, parser) { + return (src, options) => { + const origOpt = { ...options }; + const opt = { ...this.defaults, ...origOpt }; + // Show warning if an extension set async to true but the parse was called with async: false + if (this.defaults.async === true && origOpt.async === false) { + if (!opt.silent) { + console.warn('marked(): The async option was set to true by an extension. The async: false option sent to parse will be ignored.'); + } + opt.async = true; + } + const throwError = this.#onError(!!opt.silent, !!opt.async); + // throw error in case of non string input + if (typeof src === 'undefined' || src === null) { + return throwError(new Error('marked(): input parameter is undefined or null')); + } + if (typeof src !== 'string') { + return throwError(new Error('marked(): input parameter is of type ' + + Object.prototype.toString.call(src) + ', string expected')); + } + if (opt.hooks) { + opt.hooks.options = opt; + } + if (opt.async) { + return Promise.resolve(opt.hooks ? opt.hooks.preprocess(src) : src) + .then(src => lexer(src, opt)) + .then(tokens => opt.hooks ? opt.hooks.processAllTokens(tokens) : tokens) + .then(tokens => opt.walkTokens ? Promise.all(this.walkTokens(tokens, opt.walkTokens)).then(() => tokens) : tokens) + .then(tokens => parser(tokens, opt)) + .then(html => opt.hooks ? opt.hooks.postprocess(html) : html) + .catch(throwError); + } + try { + if (opt.hooks) { + src = opt.hooks.preprocess(src); + } + let tokens = lexer(src, opt); + if (opt.hooks) { + tokens = opt.hooks.processAllTokens(tokens); + } + if (opt.walkTokens) { + this.walkTokens(tokens, opt.walkTokens); + } + let html = parser(tokens, opt); + if (opt.hooks) { + html = opt.hooks.postprocess(html); + } + return html; + } + catch (e) { + return throwError(e); + } + }; + } + #onError(silent, async) { + return (e) => { + e.message += '\nPlease report this to https://github.com/markedjs/marked.'; + if (silent) { + const msg = '

    An error occurred:

    '
    +                    + escape$1(e.message + '', true)
    +                    + '
    '; + if (async) { + return Promise.resolve(msg); + } + return msg; + } + if (async) { + return Promise.reject(e); + } + throw e; + }; + } +} + +const markedInstance = new Marked(); +function marked(src, opt) { + return markedInstance.parse(src, opt); +} +/** + * Sets the default options. + * + * @param options Hash of options + */ +marked.options = + marked.setOptions = function (options) { + markedInstance.setOptions(options); + marked.defaults = markedInstance.defaults; + changeDefaults(marked.defaults); + return marked; + }; +/** + * Gets the original marked default options. + */ +marked.getDefaults = _getDefaults; +marked.defaults = _defaults; +/** + * Use Extension + */ +marked.use = function (...args) { + markedInstance.use(...args); + marked.defaults = markedInstance.defaults; + changeDefaults(marked.defaults); + return marked; +}; +/** + * Run callback for every token + */ +marked.walkTokens = function (tokens, callback) { + return markedInstance.walkTokens(tokens, callback); +}; +/** + * Compiles markdown to HTML without enclosing `p` tag. + * + * @param src String of markdown source to be compiled + * @param options Hash of options + * @return String of compiled HTML + */ +marked.parseInline = markedInstance.parseInline; +/** + * Expose + */ +marked.Parser = _Parser; +marked.parser = _Parser.parse; +marked.Renderer = _Renderer; +marked.TextRenderer = _TextRenderer; +marked.Lexer = _Lexer; +marked.lexer = _Lexer.lex; +marked.Tokenizer = _Tokenizer; +marked.Hooks = _Hooks; +marked.parse = marked; +const options = marked.options; +const setOptions = marked.setOptions; +const use = marked.use; +const walkTokens = marked.walkTokens; +const parseInline = marked.parseInline; +const parse = marked; +const parser = _Parser.parse; +const lexer = _Lexer.lex; + +export { _Hooks as Hooks, _Lexer as Lexer, Marked, _Parser as Parser, _Renderer as Renderer, _TextRenderer as TextRenderer, _Tokenizer as Tokenizer, _defaults as defaults, _getDefaults as getDefaults, lexer, marked, options, parse, parseInline, parser, setOptions, use, walkTokens }; +//# sourceMappingURL=marked.esm.js.map diff --git a/assets/js/vendor/preact-htm.module.js b/assets/js/vendor/preact-htm.module.js new file mode 100644 index 0000000..e24f87b --- /dev/null +++ b/assets/js/vendor/preact-htm.module.js @@ -0,0 +1 @@ +var e,n,_,t,o,r,u,l={},i=[],c=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i;function s(e,n){for(var _ in n)e[_]=n[_];return e}function f(e){var n=e.parentNode;n&&n.removeChild(e)}function a(n,_,t){var o,r,u,l={};for(u in _)"key"==u?o=_[u]:"ref"==u?r=_[u]:l[u]=_[u];if(arguments.length>2&&(l.children=arguments.length>3?e.call(arguments,2):t),"function"==typeof n&&null!=n.defaultProps)for(u in n.defaultProps)void 0===l[u]&&(l[u]=n.defaultProps[u]);return p(n,l,o,r,null)}function p(e,t,o,r,u){var l={type:e,props:t,key:o,ref:r,__k:null,__:null,__b:0,__e:null,__d:void 0,__c:null,__h:null,constructor:void 0,__v:null==u?++_:u};return null!=n.vnode&&n.vnode(l),l}function h(e){return e.children}function d(e,n){this.props=e,this.context=n}function v(e,n){if(null==n)return e.__?v(e.__,e.__.__k.indexOf(e)+1):null;for(var _;n0?p(m.type,m.props,m.key,null,m.__v):m)){if(m.__=_,m.__b=_.__b+1,null===(y=H[a])||y&&m.key==y.key&&m.type===y.type)H[a]=void 0;else for(d=0;d=t.__.length&&t.__.push({}),t.__[e]}function G(e){return R=1,z(ie,e)}function z(e,n,_){var t=j(L++,2);return t.t=e,t.__c||(t.__=[_?_(n):ie(void 0,n),function(e){var n=t.t(t.__[0],e);t.__[0]!==n&&(t.__=[n,t.__[1]],t.__c.setState({}))}],t.__c=N),t.__}function J(e,_){var t=j(L++,3);!n.__s&&le(t.__H,_)&&(t.__=e,t.__H=_,N.__H.__h.push(t))}function K(e,_){var t=j(L++,4);!n.__s&&le(t.__H,_)&&(t.__=e,t.__H=_,N.__h.push(t))}function Q(e){return R=5,Y(function(){return{current:e}},[])}function X(e,n,_){R=6,K(function(){"function"==typeof e?e(n()):e&&(e.current=n())},null==_?_:_.concat(e))}function Y(e,n){var _=j(L++,7);return le(_.__H,n)&&(_.__=e(),_.__H=n,_.__h=e),_.__}function Z(e,n){return R=8,Y(function(){return e},n)}function ee(e){var n=N.context[e.__c],_=j(L++,9);return _.c=e,n?(null==_.__&&(_.__=!0,n.sub(N)),n.props.value):e.__}function ne(e,_){n.useDebugValue&&n.useDebugValue(_?_(e):e)}function _e(e){var n=j(L++,10),_=G();return n.__=e,N.componentDidCatch||(N.componentDidCatch=function(e){n.__&&n.__(e),_[1](e)}),[_[0],function(){_[1](void 0)}]}function te(){I.forEach(function(e){if(e.__P)try{e.__H.__h.forEach(re),e.__H.__h.forEach(ue),e.__H.__h=[]}catch(_){e.__H.__h=[],n.__e(_,e.__v)}}),I=[]}n.__b=function(e){N=null,O&&O(e)},n.__r=function(e){V&&V(e),L=0;var n=(N=e.__c).__H;n&&(n.__h.forEach(re),n.__h.forEach(ue),n.__h=[])},n.diffed=function(e){q&&q(e);var _=e.__c;_&&_.__H&&_.__H.__h.length&&(1!==I.push(_)&&W===n.requestAnimationFrame||((W=n.requestAnimationFrame)||function(e){var n,_=function(){clearTimeout(t),oe&&cancelAnimationFrame(n),setTimeout(e)},t=setTimeout(_,100);oe&&(n=requestAnimationFrame(_))})(te)),N=void 0},n.__c=function(e,_){_.some(function(e){try{e.__h.forEach(re),e.__h=e.__h.filter(function(e){return!e.__||ue(e)})}catch(t){_.some(function(e){e.__h&&(e.__h=[])}),_=[],n.__e(t,e.__v)}}),B&&B(e,_)},n.unmount=function(e){$&&$(e);var _=e.__c;if(_&&_.__H)try{_.__H.__.forEach(re)}catch(e){n.__e(e,_.__v)}};var oe="function"==typeof requestAnimationFrame;function re(e){var n=N;"function"==typeof e.__c&&e.__c(),N=n}function ue(e){var n=N;e.__c=e.__(),N=n}function le(e,n){return!e||e.length!==n.length||n.some(function(n,_){return n!==e[_]})}function ie(e,n){return"function"==typeof n?n(e):n}var ce=function(e,n,_,t){var o;n[0]=0;for(var r=1;r=5&&((o||!e&&5===t)&&(u.push(t,0,o,_),t=6),e&&(u.push(t,e,0,_),t=6)),o=""},i=0;i"===n?(t=1,o=""):o=n+o[0]:r?n===r?r="":o+=n:'"'===n||"'"===n?r=n:">"===n?(l(),t=1):t&&("="===n?(t=5,_=o,o=""):"/"===n&&(t<5||">"===e[i][c+1])?(l(),3===t&&(u=u[0]),t=u,(u=u[0]).push(2,0,t),t=0):" "===n||"\t"===n||"\n"===n||"\r"===n?(l(),t=2):o+=n),3===t&&"!--"===o&&(t=4,u=u[0])}return l(),u}(e)),n),arguments,[])).length>1?n:n[0]}.bind(a);export{a as h,fe as html,M as render,d as Component,F as createContext,G as useState,z as useReducer,J as useEffect,K as useLayoutEffect,Q as useRef,X as useImperativeHandle,Y as useMemo,Z as useCallback,ee as useContext,ne as useDebugValue,_e as useErrorBoundary}; diff --git a/assets/js/vendor/purify.es.mjs b/assets/js/vendor/purify.es.mjs new file mode 100644 index 0000000..5b12eee --- /dev/null +++ b/assets/js/vendor/purify.es.mjs @@ -0,0 +1,2249 @@ +/*! @license DOMPurify 3.4.11 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.11/LICENSE */ + +function _arrayLikeToArray(r, a) { + (null == a || a > r.length) && (a = r.length); + for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e]; + return n; +} +function _arrayWithHoles(r) { + if (Array.isArray(r)) return r; +} +function _iterableToArrayLimit(r, l) { + var t = null == r ? null : "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; + if (null != t) { + var e, + n, + i, + u, + a = [], + f = true, + o = false; + try { + if (i = (t = t.call(r)).next, 0 === l) ; else for (; !(f = (e = i.call(t)).done) && (a.push(e.value), a.length !== l); f = !0); + } catch (r) { + o = true, n = r; + } finally { + try { + if (!f && null != t.return && (u = t.return(), Object(u) !== u)) return; + } finally { + if (o) throw n; + } + } + return a; + } +} +function _nonIterableRest() { + throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); +} +function _slicedToArray(r, e) { + return _arrayWithHoles(r) || _iterableToArrayLimit(r, e) || _unsupportedIterableToArray(r, e) || _nonIterableRest(); +} +function _unsupportedIterableToArray(r, a) { + if (r) { + if ("string" == typeof r) return _arrayLikeToArray(r, a); + var t = {}.toString.call(r).slice(8, -1); + return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0; + } +} + +const entries = Object.entries, + setPrototypeOf = Object.setPrototypeOf, + isFrozen = Object.isFrozen, + getPrototypeOf = Object.getPrototypeOf, + getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +let freeze = Object.freeze, + seal = Object.seal, + create = Object.create; // eslint-disable-line import/no-mutable-exports +let _ref = typeof Reflect !== 'undefined' && Reflect, + apply = _ref.apply, + construct = _ref.construct; +if (!freeze) { + freeze = function freeze(x) { + return x; + }; +} +if (!seal) { + seal = function seal(x) { + return x; + }; +} +if (!apply) { + apply = function apply(func, thisArg) { + for (var _len = arguments.length, args = new Array(_len > 2 ? _len - 2 : 0), _key = 2; _key < _len; _key++) { + args[_key - 2] = arguments[_key]; + } + return func.apply(thisArg, args); + }; +} +if (!construct) { + construct = function construct(Func) { + for (var _len2 = arguments.length, args = new Array(_len2 > 1 ? _len2 - 1 : 0), _key2 = 1; _key2 < _len2; _key2++) { + args[_key2 - 1] = arguments[_key2]; + } + return new Func(...args); + }; +} +const arrayForEach = unapply(Array.prototype.forEach); +const arrayLastIndexOf = unapply(Array.prototype.lastIndexOf); +const arrayPop = unapply(Array.prototype.pop); +const arrayPush = unapply(Array.prototype.push); +const arraySplice = unapply(Array.prototype.splice); +const arrayIsArray = Array.isArray; +const stringToLowerCase = unapply(String.prototype.toLowerCase); +const stringToString = unapply(String.prototype.toString); +const stringMatch = unapply(String.prototype.match); +const stringReplace = unapply(String.prototype.replace); +const stringIndexOf = unapply(String.prototype.indexOf); +const stringTrim = unapply(String.prototype.trim); +const numberToString = unapply(Number.prototype.toString); +const booleanToString = unapply(Boolean.prototype.toString); +const bigintToString = typeof BigInt === 'undefined' ? null : unapply(BigInt.prototype.toString); +const symbolToString = typeof Symbol === 'undefined' ? null : unapply(Symbol.prototype.toString); +const objectHasOwnProperty = unapply(Object.prototype.hasOwnProperty); +const objectToString = unapply(Object.prototype.toString); +const regExpTest = unapply(RegExp.prototype.test); +const typeErrorCreate = unconstruct(TypeError); +/** + * Creates a new function that calls the given function with a specified thisArg and arguments. + * + * @param func - The function to be wrapped and called. + * @returns A new function that calls the given function with a specified thisArg and arguments. + */ +function unapply(func) { + return function (thisArg) { + if (thisArg instanceof RegExp) { + thisArg.lastIndex = 0; + } + for (var _len3 = arguments.length, args = new Array(_len3 > 1 ? _len3 - 1 : 0), _key3 = 1; _key3 < _len3; _key3++) { + args[_key3 - 1] = arguments[_key3]; + } + return apply(func, thisArg, args); + }; +} +/** + * Creates a new function that constructs an instance of the given constructor function with the provided arguments. + * + * @param func - The constructor function to be wrapped and called. + * @returns A new function that constructs an instance of the given constructor function with the provided arguments. + */ +function unconstruct(Func) { + return function () { + for (var _len4 = arguments.length, args = new Array(_len4), _key4 = 0; _key4 < _len4; _key4++) { + args[_key4] = arguments[_key4]; + } + return construct(Func, args); + }; +} +/** + * Add properties to a lookup table + * + * @param set - The set to which elements will be added. + * @param array - The array containing elements to be added to the set. + * @param transformCaseFunc - An optional function to transform the case of each element before adding to the set. + * @returns The modified set with added elements. + */ +function addToSet(set, array) { + let transformCaseFunc = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : stringToLowerCase; + if (setPrototypeOf) { + // Make 'in' and truthy checks like Boolean(set.constructor) + // independent of any properties defined on Object.prototype. + // Prevent prototype setters from intercepting set as a this value. + setPrototypeOf(set, null); + } + if (!arrayIsArray(array)) { + return set; + } + let l = array.length; + while (l--) { + let element = array[l]; + if (typeof element === 'string') { + const lcElement = transformCaseFunc(element); + if (lcElement !== element) { + // Config presets (e.g. tags.js, attrs.js) are immutable. + if (!isFrozen(array)) { + array[l] = lcElement; + } + element = lcElement; + } + } + set[element] = true; + } + return set; +} +/** + * Clean up an array to harden against CSPP + * + * @param array - The array to be cleaned. + * @returns The cleaned version of the array + */ +function cleanArray(array) { + for (let index = 0; index < array.length; index++) { + const isPropertyExist = objectHasOwnProperty(array, index); + if (!isPropertyExist) { + array[index] = null; + } + } + return array; +} +/** + * Shallow clone an object + * + * @param object - The object to be cloned. + * @returns A new object that copies the original. + */ +function clone(object) { + const newObject = create(null); + for (const _ref2 of entries(object)) { + var _ref3 = _slicedToArray(_ref2, 2); + const property = _ref3[0]; + const value = _ref3[1]; + const isPropertyExist = objectHasOwnProperty(object, property); + if (isPropertyExist) { + if (arrayIsArray(value)) { + newObject[property] = cleanArray(value); + } else if (value && typeof value === 'object' && value.constructor === Object) { + newObject[property] = clone(value); + } else { + newObject[property] = value; + } + } + } + return newObject; +} +/** + * Convert non-node values into strings without depending on direct property access. + * + * @param value - The value to stringify. + * @returns A string representation of the provided value. + */ +function stringifyValue(value) { + switch (typeof value) { + case 'string': + { + return value; + } + case 'number': + { + return numberToString(value); + } + case 'boolean': + { + return booleanToString(value); + } + case 'bigint': + { + return bigintToString ? bigintToString(value) : '0'; + } + case 'symbol': + { + return symbolToString ? symbolToString(value) : 'Symbol()'; + } + case 'undefined': + { + return objectToString(value); + } + case 'function': + case 'object': + { + if (value === null) { + return objectToString(value); + } + const valueAsRecord = value; + const valueToString = lookupGetter(valueAsRecord, 'toString'); + if (typeof valueToString === 'function') { + const stringified = valueToString(valueAsRecord); + return typeof stringified === 'string' ? stringified : objectToString(stringified); + } + return objectToString(value); + } + default: + { + return objectToString(value); + } + } +} +/** + * This method automatically checks if the prop is function or getter and behaves accordingly. + * + * @param object - The object to look up the getter function in its prototype chain. + * @param prop - The property name for which to find the getter function. + * @returns The getter function found in the prototype chain or a fallback function. + */ +function lookupGetter(object, prop) { + while (object !== null) { + const desc = getOwnPropertyDescriptor(object, prop); + if (desc) { + if (desc.get) { + return unapply(desc.get); + } + if (typeof desc.value === 'function') { + return unapply(desc.value); + } + } + object = getPrototypeOf(object); + } + function fallbackValue() { + return null; + } + return fallbackValue; +} +function isRegex(value) { + try { + regExpTest(value, ''); + return true; + } catch (_unused) { + return false; + } +} + +const html$1 = freeze(['a', 'abbr', 'acronym', 'address', 'area', 'article', 'aside', 'audio', 'b', 'bdi', 'bdo', 'big', 'blink', 'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', 'content', 'data', 'datalist', 'dd', 'decorator', 'del', 'details', 'dfn', 'dialog', 'dir', 'div', 'dl', 'dt', 'element', 'em', 'fieldset', 'figcaption', 'figure', 'font', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html', 'i', 'img', 'input', 'ins', 'kbd', 'label', 'legend', 'li', 'main', 'map', 'mark', 'marquee', 'menu', 'menuitem', 'meter', 'nav', 'nobr', 'ol', 'optgroup', 'option', 'output', 'p', 'picture', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'search', 'section', 'select', 'shadow', 'slot', 'small', 'source', 'spacer', 'span', 'strike', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'tr', 'track', 'tt', 'u', 'ul', 'var', 'video', 'wbr']); +const svg$1 = freeze(['svg', 'a', 'altglyph', 'altglyphdef', 'altglyphitem', 'animatecolor', 'animatemotion', 'animatetransform', 'circle', 'clippath', 'defs', 'desc', 'ellipse', 'enterkeyhint', 'exportparts', 'filter', 'font', 'g', 'glyph', 'glyphref', 'hkern', 'image', 'inputmode', 'line', 'lineargradient', 'marker', 'mask', 'metadata', 'mpath', 'part', 'path', 'pattern', 'polygon', 'polyline', 'radialgradient', 'rect', 'stop', 'style', 'switch', 'symbol', 'text', 'textpath', 'title', 'tref', 'tspan', 'view', 'vkern']); +const svgFilters = freeze(['feBlend', 'feColorMatrix', 'feComponentTransfer', 'feComposite', 'feConvolveMatrix', 'feDiffuseLighting', 'feDisplacementMap', 'feDistantLight', 'feDropShadow', 'feFlood', 'feFuncA', 'feFuncB', 'feFuncG', 'feFuncR', 'feGaussianBlur', 'feImage', 'feMerge', 'feMergeNode', 'feMorphology', 'feOffset', 'fePointLight', 'feSpecularLighting', 'feSpotLight', 'feTile', 'feTurbulence']); +// List of SVG elements that are disallowed by default. +// We still need to know them so that we can do namespace +// checks properly in case one wants to add them to +// allow-list. +const svgDisallowed = freeze(['animate', 'color-profile', 'cursor', 'discard', 'font-face', 'font-face-format', 'font-face-name', 'font-face-src', 'font-face-uri', 'foreignobject', 'hatch', 'hatchpath', 'mesh', 'meshgradient', 'meshpatch', 'meshrow', 'missing-glyph', 'script', 'set', 'solidcolor', 'unknown', 'use']); +const mathMl$1 = freeze(['math', 'menclose', 'merror', 'mfenced', 'mfrac', 'mglyph', 'mi', 'mlabeledtr', 'mmultiscripts', 'mn', 'mo', 'mover', 'mpadded', 'mphantom', 'mroot', 'mrow', 'ms', 'mspace', 'msqrt', 'mstyle', 'msub', 'msup', 'msubsup', 'mtable', 'mtd', 'mtext', 'mtr', 'munder', 'munderover', 'mprescripts']); +// Similarly to SVG, we want to know all MathML elements, +// even those that we disallow by default. +const mathMlDisallowed = freeze(['maction', 'maligngroup', 'malignmark', 'mlongdiv', 'mscarries', 'mscarry', 'msgroup', 'mstack', 'msline', 'msrow', 'semantics', 'annotation', 'annotation-xml', 'mprescripts', 'none']); +const text = freeze(['#text']); + +const html = freeze(['accept', 'action', 'align', 'alt', 'autocapitalize', 'autocomplete', 'autopictureinpicture', 'autoplay', 'background', 'bgcolor', 'border', 'capture', 'cellpadding', 'cellspacing', 'checked', 'cite', 'class', 'clear', 'color', 'cols', 'colspan', 'command', 'commandfor', 'controls', 'controlslist', 'coords', 'crossorigin', 'datetime', 'decoding', 'default', 'dir', 'disabled', 'disablepictureinpicture', 'disableremoteplayback', 'download', 'draggable', 'enctype', 'enterkeyhint', 'exportparts', 'face', 'for', 'headers', 'height', 'hidden', 'high', 'href', 'hreflang', 'id', 'inert', 'inputmode', 'integrity', 'ismap', 'kind', 'label', 'lang', 'list', 'loading', 'loop', 'low', 'max', 'maxlength', 'media', 'method', 'min', 'minlength', 'multiple', 'muted', 'name', 'nonce', 'noshade', 'novalidate', 'nowrap', 'open', 'optimum', 'part', 'pattern', 'placeholder', 'playsinline', 'popover', 'popovertarget', 'popovertargetaction', 'poster', 'preload', 'pubdate', 'radiogroup', 'readonly', 'rel', 'required', 'rev', 'reversed', 'role', 'rows', 'rowspan', 'spellcheck', 'scope', 'selected', 'shape', 'size', 'sizes', 'slot', 'span', 'srclang', 'start', 'src', 'srcset', 'step', 'style', 'summary', 'tabindex', 'title', 'translate', 'type', 'usemap', 'valign', 'value', 'width', 'wrap', 'xmlns']); +const svg = freeze(['accent-height', 'accumulate', 'additive', 'alignment-baseline', 'amplitude', 'ascent', 'attributename', 'attributetype', 'azimuth', 'basefrequency', 'baseline-shift', 'begin', 'bias', 'by', 'class', 'clip', 'clippathunits', 'clip-path', 'clip-rule', 'color', 'color-interpolation', 'color-interpolation-filters', 'color-profile', 'color-rendering', 'cx', 'cy', 'd', 'dx', 'dy', 'diffuseconstant', 'direction', 'display', 'divisor', 'dur', 'edgemode', 'elevation', 'end', 'exponent', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'filterunits', 'flood-color', 'flood-opacity', 'font-family', 'font-size', 'font-size-adjust', 'font-stretch', 'font-style', 'font-variant', 'font-weight', 'fx', 'fy', 'g1', 'g2', 'glyph-name', 'glyphref', 'gradientunits', 'gradienttransform', 'height', 'href', 'id', 'image-rendering', 'in', 'in2', 'intercept', 'k', 'k1', 'k2', 'k3', 'k4', 'kerning', 'keypoints', 'keysplines', 'keytimes', 'lang', 'lengthadjust', 'letter-spacing', 'kernelmatrix', 'kernelunitlength', 'lighting-color', 'local', 'marker-end', 'marker-mid', 'marker-start', 'markerheight', 'markerunits', 'markerwidth', 'maskcontentunits', 'maskunits', 'max', 'mask', 'mask-type', 'media', 'method', 'mode', 'min', 'name', 'numoctaves', 'offset', 'operator', 'opacity', 'order', 'orient', 'orientation', 'origin', 'overflow', 'paint-order', 'path', 'pathlength', 'patterncontentunits', 'patterntransform', 'patternunits', 'points', 'preservealpha', 'preserveaspectratio', 'primitiveunits', 'r', 'rx', 'ry', 'radius', 'refx', 'refy', 'repeatcount', 'repeatdur', 'restart', 'result', 'rotate', 'scale', 'seed', 'shape-rendering', 'slope', 'specularconstant', 'specularexponent', 'spreadmethod', 'startoffset', 'stddeviation', 'stitchtiles', 'stop-color', 'stop-opacity', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke', 'stroke-width', 'style', 'surfacescale', 'systemlanguage', 'tabindex', 'tablevalues', 'targetx', 'targety', 'transform', 'transform-origin', 'text-anchor', 'text-decoration', 'text-rendering', 'textlength', 'type', 'u1', 'u2', 'unicode', 'values', 'viewbox', 'visibility', 'version', 'vert-adv-y', 'vert-origin-x', 'vert-origin-y', 'width', 'word-spacing', 'wrap', 'writing-mode', 'xchannelselector', 'ychannelselector', 'x', 'x1', 'x2', 'xmlns', 'y', 'y1', 'y2', 'z', 'zoomandpan']); +const mathMl = freeze(['accent', 'accentunder', 'align', 'bevelled', 'close', 'columnalign', 'columnlines', 'columnspacing', 'columnspan', 'denomalign', 'depth', 'dir', 'display', 'displaystyle', 'encoding', 'fence', 'frame', 'height', 'href', 'id', 'largeop', 'length', 'linethickness', 'lquote', 'lspace', 'mathbackground', 'mathcolor', 'mathsize', 'mathvariant', 'maxsize', 'minsize', 'movablelimits', 'notation', 'numalign', 'open', 'rowalign', 'rowlines', 'rowspacing', 'rowspan', 'rspace', 'rquote', 'scriptlevel', 'scriptminsize', 'scriptsizemultiplier', 'selection', 'separator', 'separators', 'stretchy', 'subscriptshift', 'supscriptshift', 'symmetric', 'voffset', 'width', 'xmlns']); +const xml = freeze(['xlink:href', 'xml:id', 'xlink:title', 'xml:space', 'xmlns:xlink']); + +const MUSTACHE_EXPR = seal(/{{[\w\W]*|^[\w\W]*}}/g); +const ERB_EXPR = seal(/<%[\w\W]*|^[\w\W]*%>/g); +const TMPLIT_EXPR = seal(/\${[\w\W]*/g); +const DATA_ATTR = seal(/^data-[\-\w.\u00B7-\uFFFF]+$/); // eslint-disable-line no-useless-escape +const ARIA_ATTR = seal(/^aria-[\-\w]+$/); // eslint-disable-line no-useless-escape +const IS_ALLOWED_URI = seal(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i // eslint-disable-line no-useless-escape +); +const IS_SCRIPT_OR_DATA = seal(/^(?:\w+script|data):/i); +const ATTR_WHITESPACE = seal(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g // eslint-disable-line no-control-regex +); +const DOCTYPE_NAME = seal(/^html$/i); +const CUSTOM_ELEMENT = seal(/^[a-z][.\w]*(-[.\w]+)+$/i); +// Markup-significant character probes used by _sanitizeElements. +// Shared module-level instances are safe despite the sticky /g flags: +// unapply() resets lastIndex for RegExp receivers before every call. +const ELEMENT_MARKUP_PROBE = seal(/<[/\w!]/g); +const COMMENT_MARKUP_PROBE = seal(/<[/\w]/g); +const FALLBACK_TAG_CLOSE = seal(/<\/no(script|embed|frames)/i); +const SELF_CLOSING_TAG = seal(/\/>/i); + +// https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType +const NODE_TYPE = { + element: 1, + attribute: 2, + text: 3, + cdataSection: 4, + entityReference: 5, + // Deprecated + entityNode: 6, + // Deprecated + processingInstruction: 7, + comment: 8, + document: 9, + documentType: 10, + documentFragment: 11, + notation: 12 // Deprecated +}; +const getGlobal = function getGlobal() { + return typeof window === 'undefined' ? null : window; +}; +/** + * Creates a no-op policy for internal use only. + * Don't export this function outside this module! + * @param trustedTypes The policy factory. + * @param purifyHostElement The Script element used to load DOMPurify (to determine policy name suffix). + * @return The policy created (or null, if Trusted Types + * are not supported or creating the policy failed). + */ +const _createTrustedTypesPolicy = function _createTrustedTypesPolicy(trustedTypes, purifyHostElement) { + if (typeof trustedTypes !== 'object' || typeof trustedTypes.createPolicy !== 'function') { + return null; + } + // Allow the callers to control the unique policy name + // by adding a data-tt-policy-suffix to the script element with the DOMPurify. + // Policy creation with duplicate names throws in Trusted Types. + let suffix = null; + const ATTR_NAME = 'data-tt-policy-suffix'; + if (purifyHostElement && purifyHostElement.hasAttribute(ATTR_NAME)) { + suffix = purifyHostElement.getAttribute(ATTR_NAME); + } + const policyName = 'dompurify' + (suffix ? '#' + suffix : ''); + try { + return trustedTypes.createPolicy(policyName, { + createHTML(html) { + return html; + }, + createScriptURL(scriptUrl) { + return scriptUrl; + } + }); + } catch (_) { + // Policy creation failed (most likely another DOMPurify script has + // already run). Skip creating the policy, as this will only cause errors + // if TT are enforced. + console.warn('TrustedTypes policy ' + policyName + ' could not be created.'); + return null; + } +}; +const _createHooksMap = function _createHooksMap() { + return { + afterSanitizeAttributes: [], + afterSanitizeElements: [], + afterSanitizeShadowDOM: [], + beforeSanitizeAttributes: [], + beforeSanitizeElements: [], + beforeSanitizeShadowDOM: [], + uponSanitizeAttribute: [], + uponSanitizeElement: [], + uponSanitizeShadowNode: [] + }; +}; +/** + * Resolve a set-valued configuration option: a fresh set built from + * cfg[key] when it is an own array property (seeded with a clone of + * options.base when given, case-normalized via options.transform), + * the fallback set otherwise. + * + * @param cfg the cloned, prototype-free configuration object + * @param key the configuration property to read + * @param fallback the set to use when the option is absent or not an array + * @param options transform and optional base set to merge into + * @returns the resolved set + */ +const _resolveSetOption = function _resolveSetOption(cfg, key, fallback, options) { + return objectHasOwnProperty(cfg, key) && arrayIsArray(cfg[key]) ? addToSet(options.base ? clone(options.base) : {}, cfg[key], options.transform) : fallback; +}; +function createDOMPurify() { + let window = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : getGlobal(); + const DOMPurify = root => createDOMPurify(root); + DOMPurify.version = '3.4.11'; + DOMPurify.removed = []; + if (!window || !window.document || window.document.nodeType !== NODE_TYPE.document || !window.Element) { + // Not running in a browser, provide a factory function + // so that you can pass your own Window + DOMPurify.isSupported = false; + return DOMPurify; + } + let document = window.document; + const originalDocument = document; + const currentScript = originalDocument.currentScript; + window.DocumentFragment; + const HTMLTemplateElement = window.HTMLTemplateElement, + Node = window.Node, + Element = window.Element, + NodeFilter = window.NodeFilter, + _window$NamedNodeMap = window.NamedNodeMap; + _window$NamedNodeMap === void 0 ? window.NamedNodeMap || window.MozNamedAttrMap : _window$NamedNodeMap; + window.HTMLFormElement; + const DOMParser = window.DOMParser, + trustedTypes = window.trustedTypes; + const ElementPrototype = Element.prototype; + const cloneNode = lookupGetter(ElementPrototype, 'cloneNode'); + const remove = lookupGetter(ElementPrototype, 'remove'); + const getNextSibling = lookupGetter(ElementPrototype, 'nextSibling'); + const getChildNodes = lookupGetter(ElementPrototype, 'childNodes'); + const getParentNode = lookupGetter(ElementPrototype, 'parentNode'); + const getShadowRoot = lookupGetter(ElementPrototype, 'shadowRoot'); + const getAttributes = lookupGetter(ElementPrototype, 'attributes'); + const getNodeType = Node && Node.prototype ? lookupGetter(Node.prototype, 'nodeType') : null; + const getNodeName = Node && Node.prototype ? lookupGetter(Node.prototype, 'nodeName') : null; + // As per issue #47, the web-components registry is inherited by a + // new document created via createHTMLDocument. As per the spec + // (http://w3c.github.io/webcomponents/spec/custom/#creating-and-passing-registries) + // a new empty registry is used when creating a template contents owner + // document, so we use that as our parent document to ensure nothing + // is inherited. + if (typeof HTMLTemplateElement === 'function') { + const template = document.createElement('template'); + if (template.content && template.content.ownerDocument) { + document = template.content.ownerDocument; + } + } + let trustedTypesPolicy; + let emptyHTML = ''; + // The instance's own internal Trusted Types policy. Unlike a caller-supplied + // `TRUSTED_TYPES_POLICY`, this is created at most once — Trusted Types throws + // on duplicate policy names — and is the only policy allowed to persist + // across configurations and survive `clearConfig()`. + let defaultTrustedTypesPolicy; + let defaultTrustedTypesPolicyResolved = false; + // Tracks whether we are already inside a call to the configured Trusted Types + // policy (`createHTML` or `createScriptURL`). If a supplied policy callback + // itself calls `DOMPurify.sanitize` (the cause of #1422), `sanitize` would + // re-enter the policy and recurse until the stack overflows. We detect that + // re-entry and throw a clear, actionable error instead. The guard is shared + // across both callbacks, because either one re-entering `sanitize` triggers + // the same unbounded recursion. + let IN_TRUSTED_TYPES_POLICY = 0; + const _assertNotInTrustedTypesPolicy = function _assertNotInTrustedTypesPolicy() { + if (IN_TRUSTED_TYPES_POLICY > 0) { + throw typeErrorCreate('A configured TRUSTED_TYPES_POLICY callback (createHTML or ' + 'createScriptURL) must not call DOMPurify.sanitize, as that causes ' + 'infinite recursion. Do not pass a policy whose callbacks wrap ' + 'DOMPurify as TRUSTED_TYPES_POLICY; see the "DOMPurify and Trusted ' + 'Types" section of the README.'); + } + }; + const _createTrustedHTML = function _createTrustedHTML(html) { + _assertNotInTrustedTypesPolicy(); + IN_TRUSTED_TYPES_POLICY++; + try { + return trustedTypesPolicy.createHTML(html); + } finally { + IN_TRUSTED_TYPES_POLICY--; + } + }; + const _createTrustedScriptURL = function _createTrustedScriptURL(scriptUrl) { + _assertNotInTrustedTypesPolicy(); + IN_TRUSTED_TYPES_POLICY++; + try { + return trustedTypesPolicy.createScriptURL(scriptUrl); + } finally { + IN_TRUSTED_TYPES_POLICY--; + } + }; + // Lazily resolve (and cache) the instance's internal default policy. + // Resolution is attempted at most once: a successful `createPolicy` cannot be + // repeated (Trusted Types throws on duplicate names), and a failed or + // unsupported attempt must not be retried on every parse. + const _getDefaultTrustedTypesPolicy = function _getDefaultTrustedTypesPolicy() { + if (!defaultTrustedTypesPolicyResolved) { + defaultTrustedTypesPolicy = _createTrustedTypesPolicy(trustedTypes, currentScript); + defaultTrustedTypesPolicyResolved = true; + } + return defaultTrustedTypesPolicy; + }; + const _document = document, + implementation = _document.implementation, + createNodeIterator = _document.createNodeIterator, + createDocumentFragment = _document.createDocumentFragment, + getElementsByTagName = _document.getElementsByTagName; + const importNode = originalDocument.importNode; + let hooks = _createHooksMap(); + /** + * Expose whether this browser supports running the full DOMPurify. + */ + DOMPurify.isSupported = typeof entries === 'function' && typeof getParentNode === 'function' && implementation && implementation.createHTMLDocument !== undefined; + const MUSTACHE_EXPR$1 = MUSTACHE_EXPR, + ERB_EXPR$1 = ERB_EXPR, + TMPLIT_EXPR$1 = TMPLIT_EXPR, + DATA_ATTR$1 = DATA_ATTR, + ARIA_ATTR$1 = ARIA_ATTR, + IS_SCRIPT_OR_DATA$1 = IS_SCRIPT_OR_DATA, + ATTR_WHITESPACE$1 = ATTR_WHITESPACE, + CUSTOM_ELEMENT$1 = CUSTOM_ELEMENT; + let IS_ALLOWED_URI$1 = IS_ALLOWED_URI; + /** + * We consider the elements and attributes below to be safe. Ideally + * don't add any new ones but feel free to remove unwanted ones. + */ + /* allowed element names */ + let ALLOWED_TAGS = null; + const DEFAULT_ALLOWED_TAGS = addToSet({}, [...html$1, ...svg$1, ...svgFilters, ...mathMl$1, ...text]); + /* Allowed attribute names */ + let ALLOWED_ATTR = null; + const DEFAULT_ALLOWED_ATTR = addToSet({}, [...html, ...svg, ...mathMl, ...xml]); + /* + * Configure how DOMPurify should handle custom elements and their attributes as well as customized built-in elements. + * @property {RegExp|Function|null} tagNameCheck one of [null, regexPattern, predicate]. Default: `null` (disallow any custom elements) + * @property {RegExp|Function|null} attributeNameCheck one of [null, regexPattern, predicate]. Default: `null` (disallow any attributes not on the allow list) + * @property {boolean} allowCustomizedBuiltInElements allow custom elements derived from built-ins if they pass CUSTOM_ELEMENT_HANDLING.tagNameCheck. Default: `false`. + */ + let CUSTOM_ELEMENT_HANDLING = Object.seal(create(null, { + tagNameCheck: { + writable: true, + configurable: false, + enumerable: true, + value: null + }, + attributeNameCheck: { + writable: true, + configurable: false, + enumerable: true, + value: null + }, + allowCustomizedBuiltInElements: { + writable: true, + configurable: false, + enumerable: true, + value: false + } + })); + /* Explicitly forbidden tags (overrides ALLOWED_TAGS/ADD_TAGS) */ + let FORBID_TAGS = null; + /* Explicitly forbidden attributes (overrides ALLOWED_ATTR/ADD_ATTR) */ + let FORBID_ATTR = null; + /* Config object to store ADD_TAGS/ADD_ATTR functions (when used as functions) */ + const EXTRA_ELEMENT_HANDLING = Object.seal(create(null, { + tagCheck: { + writable: true, + configurable: false, + enumerable: true, + value: null + }, + attributeCheck: { + writable: true, + configurable: false, + enumerable: true, + value: null + } + })); + /* Decide if ARIA attributes are okay */ + let ALLOW_ARIA_ATTR = true; + /* Decide if custom data attributes are okay */ + let ALLOW_DATA_ATTR = true; + /* Decide if unknown protocols are okay */ + let ALLOW_UNKNOWN_PROTOCOLS = false; + /* Decide if self-closing tags in attributes are allowed. + * Usually removed due to a mXSS issue in jQuery 3.0 */ + let ALLOW_SELF_CLOSE_IN_ATTR = true; + /* Output should be safe for common template engines. + * This means, DOMPurify removes data attributes, mustaches and ERB + */ + let SAFE_FOR_TEMPLATES = false; + /* Output should be safe even for XML used within HTML and alike. + * This means, DOMPurify removes comments when containing risky content. + */ + let SAFE_FOR_XML = true; + /* Decide if document with ... should be returned */ + let WHOLE_DOCUMENT = false; + /* Track whether config is already set on this instance of DOMPurify. */ + let SET_CONFIG = false; + /* Pristine allowlist bindings captured at setConfig() time. On the + * persistent-config path sanitize() restores the sets from these before + * the per-walk hook clone-guard, so a hook's in-call widening cannot + * carry across calls. Null until setConfig() is called; reset by + * clearConfig(). */ + let SET_CONFIG_ALLOWED_TAGS = null; + let SET_CONFIG_ALLOWED_ATTR = null; + /* Decide if all elements (e.g. style, script) must be children of + * document.body. By default, browsers might move them to document.head */ + let FORCE_BODY = false; + /* Decide if a DOM `HTMLBodyElement` should be returned, instead of a html + * string (or a TrustedHTML object if Trusted Types are supported). + * If `WHOLE_DOCUMENT` is enabled a `HTMLHtmlElement` will be returned instead + */ + let RETURN_DOM = false; + /* Decide if a DOM `DocumentFragment` should be returned, instead of a html + * string (or a TrustedHTML object if Trusted Types are supported) */ + let RETURN_DOM_FRAGMENT = false; + /* Try to return a Trusted Type object instead of a string, return a string in + * case Trusted Types are not supported */ + let RETURN_TRUSTED_TYPE = false; + /* Output should be free from DOM clobbering attacks? + * This sanitizes markups named with colliding, clobberable built-in DOM APIs. + */ + let SANITIZE_DOM = true; + /* Achieve full DOM Clobbering protection by isolating the namespace of named + * properties and JS variables, mitigating attacks that abuse the HTML/DOM spec rules. + * + * HTML/DOM spec rules that enable DOM Clobbering: + * - Named Access on Window (§7.3.3) + * - DOM Tree Accessors (§3.1.5) + * - Form Element Parent-Child Relations (§4.10.3) + * - Iframe srcdoc / Nested WindowProxies (§4.8.5) + * - HTMLCollection (§4.2.10.2) + * + * Namespace isolation is implemented by prefixing `id` and `name` attributes + * with a constant string, i.e., `user-content-` + */ + let SANITIZE_NAMED_PROPS = false; + const SANITIZE_NAMED_PROPS_PREFIX = 'user-content-'; + /* Keep element content when removing element? */ + let KEEP_CONTENT = true; + /* If a `Node` is passed to sanitize(), then performs sanitization in-place instead + * of importing it into a new Document and returning a sanitized copy */ + let IN_PLACE = false; + /* Allow usage of profiles like html, svg and mathMl */ + let USE_PROFILES = {}; + /* Tags to ignore content of when KEEP_CONTENT is true */ + let FORBID_CONTENTS = null; + const DEFAULT_FORBID_CONTENTS = addToSet({}, ['annotation-xml', 'audio', 'colgroup', 'desc', 'foreignobject', 'head', 'iframe', 'math', 'mi', 'mn', 'mo', 'ms', 'mtext', 'noembed', 'noframes', 'noscript', 'plaintext', 'script', + // mirrors the selected