From 87c09982e970205e6b38aff927cb9fa006c4b361 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Tue, 28 Jul 2026 22:43:11 +0200 Subject: [PATCH] feat(plugins): add bundled relays workspace panel plugin --- .changeset/relays-plugin.md | 5 + docs/plugins.md | 19 + .../relays/markdownDocument.test.ts | 95 ++++ pi-web-plugins/relays/markdownDocument.ts | 82 +++ pi-web-plugins/relays/package.json | 9 + pi-web-plugins/relays/pi-web-plugin.ts | 46 ++ pi-web-plugins/relays/relayDiscovery.test.ts | 231 +++++++++ pi-web-plugins/relays/relayDiscovery.ts | 140 +++++ .../relays/relaysPanelElement.test.ts | 478 ++++++++++++++++++ pi-web-plugins/relays/relaysPanelElement.ts | 358 +++++++++++++ pi-web-plugins/relays/vendor/README.md | 24 + pi-web-plugins/relays/vendor/marked.esm.d.ts | 28 + pi-web-plugins/relays/vendor/marked.esm.js | 76 +++ 13 files changed, 1591 insertions(+) create mode 100644 .changeset/relays-plugin.md create mode 100644 pi-web-plugins/relays/markdownDocument.test.ts create mode 100644 pi-web-plugins/relays/markdownDocument.ts create mode 100644 pi-web-plugins/relays/package.json create mode 100644 pi-web-plugins/relays/pi-web-plugin.ts create mode 100644 pi-web-plugins/relays/relayDiscovery.test.ts create mode 100644 pi-web-plugins/relays/relayDiscovery.ts create mode 100644 pi-web-plugins/relays/relaysPanelElement.test.ts create mode 100644 pi-web-plugins/relays/relaysPanelElement.ts create mode 100644 pi-web-plugins/relays/vendor/README.md create mode 100644 pi-web-plugins/relays/vendor/marked.esm.d.ts create mode 100644 pi-web-plugins/relays/vendor/marked.esm.js diff --git a/.changeset/relays-plugin.md b/.changeset/relays-plugin.md new file mode 100644 index 0000000..69372bb --- /dev/null +++ b/.changeset/relays-plugin.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add a built-in Relays plugin: a read-only workspace tab (and **Open Workspace Relays** action) that browses `.pi-web/relays/` packets, with a most-recent relay picker, ordered document tabs, sanitized markdown rendering, and truncation notices. diff --git a/docs/plugins.md b/docs/plugins.md index acb3710..31c4b8e 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -280,6 +280,25 @@ Task fields: Review task configs before running them, especially in shared projects. Workspace Tasks runs trusted shell commands from your repositories. +### Relays + +**Plugin id:** `relays` +**What it does:** adds a read-only **Relays** workspace tab for browsing the workspace's relays, plus an **Open Workspace Relays** action for the selected workspace that opens the same tab. + +A relay is a directory of markdown notes under `.pi-web/relays//` in the workspace root — the convention used by the Relay method for chaining agent sessions. The tab lists each relay's documents with `status.md`, `charter.md`, and `log.md` first (in that order), followed by any other files alphabetically, and opens `status.md` by default. Markdown documents render as sanitized HTML; other files render as preformatted text, and binary files have no preview. Truncated documents show a notice, and **Refresh** re-scans the workspace and reloads the open document. + +With several relays, a picker pre-selects the most recently modified one; a single relay opens directly. A workspace without `.pi-web/relays/` shows an empty state explaining the convention. The tab never creates, edits, or deletes relay files. + +Relays is enabled by default. To hide it, disable `relays` in **Settings → PI WEB plugins** or set: + +```json +{ + "plugins": { + "relays": { "enabled": false } + } +} +``` + ## Discovery and packaging PI WEB builds the gateway `/pi-web-plugins/manifest.json` from these sources: diff --git a/pi-web-plugins/relays/markdownDocument.test.ts b/pi-web-plugins/relays/markdownDocument.test.ts new file mode 100644 index 0000000..4068c1f --- /dev/null +++ b/pi-web-plugins/relays/markdownDocument.test.ts @@ -0,0 +1,95 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from "vitest"; +import { isMarkdownDocumentPath, renderRelayDocumentHtml } from "./markdownDocument"; + +describe("isMarkdownDocumentPath", () => { + it("matches .md documents case-insensitively", () => { + expect(isMarkdownDocumentPath(".pi-web/relays/r/status.md")).toBe(true); + expect(isMarkdownDocumentPath("NOTES.MD")).toBe(true); + expect(isMarkdownDocumentPath(".pi-web/relays/r/data.json")).toBe(false); + expect(isMarkdownDocumentPath(".pi-web/relays/r/markdown.txt")).toBe(false); + }); +}); + +describe("renderRelayDocumentHtml", () => { + it("renders GFM markdown: headings, emphasis, lists, and code fences", () => { + const fragment = fragmentOf(renderRelayDocumentHtml([ + "# Status", + "", + "All **good**.", + "", + "- one", + "- two", + "", + "```sh", + "echo hi", + "```", + ].join("\n"))); + + expect(fragment.querySelector("h1")?.textContent).toBe("Status"); + expect(fragment.querySelector("strong")?.textContent).toBe("good"); + expect(fragment.querySelectorAll("li")).toHaveLength(2); + expect(fragment.querySelector("pre code")?.textContent).toContain("echo hi"); + }); + + it("renders soft line breaks as
so plain-wrapped relay docs stay readable", () => { + const fragment = fragmentOf(renderRelayDocumentHtml("one\ntwo")); + + expect(fragment.querySelector("p br")).not.toBeNull(); + }); + + it("escapes raw HTML instead of embedding it", () => { + const fragment = fragmentOf(renderRelayDocumentHtml("before\n\n\n\nafter")); + + expect(fragment.querySelector("script")).toBeNull(); + expect(fragment.querySelector("em")).toBeNull(); + expect(fragment.textContent).toContain(""); + }); + + it("strips javascript: URLs from links and images while keeping safe protocols", () => { + const fragment = fragmentOf(renderRelayDocumentHtml([ + "[bad](javascript:alert('xss'))", + "", + "[web](https://example.com/docs)", + "", + "[mail](mailto:ops@example.com)", + "", + "[anchor](#details)", + "", + "![pic](javascript:alert('xss'))", + ].join("\n"))); + + const links = [...fragment.querySelectorAll("a")]; + expect(links.find((link) => link.textContent === "bad")?.hasAttribute("href")).toBe(false); + expect(links.find((link) => link.textContent === "web")?.getAttribute("href")).toBe("https://example.com/docs"); + expect(links.find((link) => link.textContent === "mail")?.getAttribute("href")).toBe("mailto:ops@example.com"); + expect(links.find((link) => link.textContent === "anchor")?.getAttribute("href")).toBe("#details"); + expect(fragment.querySelector("img")?.hasAttribute("src")).toBe(false); + }); + + it("forces rendered links to open in a new tab without opener access", () => { + const fragment = fragmentOf(renderRelayDocumentHtml("[docs](https://example.com)")); + + const link = fragment.querySelector("a"); + expect(link?.getAttribute("target")).toBe("_blank"); + expect(link?.getAttribute("rel")).toBe("noreferrer noopener"); + }); + + it("wraps tables in a labeled scroll region", () => { + const fragment = fragmentOf(renderRelayDocumentHtml("| a | b |\n| - | - |\n| 1 | 2 |")); + + const wrapper = fragment.querySelector(".table-scroll"); + expect(wrapper?.getAttribute("role")).toBe("region"); + expect(wrapper?.getAttribute("aria-label")).toBe("Table"); + expect(wrapper?.querySelector("table")).not.toBeNull(); + expect(wrapper?.querySelectorAll("td")).toHaveLength(2); + }); +}); + +/** Parse rendered HTML back into a fragment for assertions. */ +function fragmentOf(html: string): DocumentFragment { + const template = document.createElement("template"); + template.innerHTML = html; + return template.content; +} diff --git a/pi-web-plugins/relays/markdownDocument.ts b/pi-web-plugins/relays/markdownDocument.ts new file mode 100644 index 0000000..1bcfd4b --- /dev/null +++ b/pi-web-plugins/relays/markdownDocument.ts @@ -0,0 +1,82 @@ +import { marked } from "./vendor/marked.esm.js"; + +// Raw HTML inside relay documents is escaped before sanitizing, so the +// sanitizer only ever sees marked-generated markup. This mirrors the safety +// rules of the app's renderer in src/client/src/formatting/markdown.ts; the +// plugin cannot import that module, so the rules are duplicated here. +const renderer = new marked.Renderer(); +renderer.html = ({ text }) => escapeHtml(text); + +const MAX_MARKDOWN_CACHE_ENTRIES = 300; +const markdownHtmlCache = new Map(); + +/** Relay documents ending in .md render as markdown; everything else stays preformatted text. */ +export function isMarkdownDocumentPath(path: string): boolean { + return path.toLowerCase().endsWith(".md"); +} + +/** Render one relay markdown document into sanitized HTML safe to interpolate into innerHTML. */ +export function renderRelayDocumentHtml(source: string): string { + const cached = markdownHtmlCache.get(source); + if (cached !== undefined) return cached; + const html = marked.parse(source, { async: false, breaks: true, gfm: true, renderer }); + const safeHtml = sanitizeHtml(html); + markdownHtmlCache.set(source, safeHtml); + if (markdownHtmlCache.size > MAX_MARKDOWN_CACHE_ENTRIES) { + const oldest = markdownHtmlCache.keys().next().value; + if (oldest !== undefined) markdownHtmlCache.delete(oldest); + } + return safeHtml; +} + +function escapeHtml(text: string): string { + return text + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +const TABLE_SCROLL_CLASS = "table-scroll"; + +function sanitizeHtml(html: string): string { + const template = document.createElement("template"); + template.innerHTML = html; + template.content.querySelectorAll("script, style, iframe, object, embed").forEach((node) => { node.remove(); }); + template.content.querySelectorAll("*").forEach((element) => { + for (const attribute of [...element.attributes]) { + const name = attribute.name.toLowerCase(); + if (name.startsWith("on")) element.removeAttribute(attribute.name); + if ((name === "href" || name === "src") && !isSafeUrl(attribute.value)) element.removeAttribute(attribute.name); + } + if (element.tagName === "A") { + element.setAttribute("target", "_blank"); + element.setAttribute("rel", "noreferrer noopener"); + } + }); + wrapTablesInScrollRegions(template.content); + return template.innerHTML; +} + +// Tables keep their natural width and scroll horizontally instead of being +// squeezed into the panel, which is unreadable on narrow screens. +function wrapTablesInScrollRegions(root: DocumentFragment): void { + root.querySelectorAll("table").forEach((table) => { + if (table.parentElement?.classList.contains(TABLE_SCROLL_CLASS) === true) return; + const wrapper = document.createElement("div"); + wrapper.className = TABLE_SCROLL_CLASS; + wrapper.setAttribute("role", "region"); + wrapper.setAttribute("aria-label", "Table"); + wrapper.setAttribute("tabindex", "0"); + table.before(wrapper); + wrapper.append(table); + }); +} + +function isSafeUrl(url: string): boolean { + if (url.startsWith("#") || url.startsWith("/")) return true; + try { + return ["http:", "https:", "mailto:"].includes(new URL(url).protocol); + } catch { + return false; + } +} diff --git a/pi-web-plugins/relays/package.json b/pi-web-plugins/relays/package.json new file mode 100644 index 0000000..5be8187 --- /dev/null +++ b/pi-web-plugins/relays/package.json @@ -0,0 +1,9 @@ +{ + "name": "@pi-web/relays-plugin", + "private": true, + "piWeb": { + "plugins": [ + { "id": "relays", "module": "pi-web-plugin.js" } + ] + } +} diff --git a/pi-web-plugins/relays/pi-web-plugin.ts b/pi-web-plugins/relays/pi-web-plugin.ts new file mode 100644 index 0000000..e76254d --- /dev/null +++ b/pi-web-plugins/relays/pi-web-plugin.ts @@ -0,0 +1,46 @@ +import type { PiWebPlugin } from "@jmfederico/pi-web/plugin-api"; +import { RELAYS_ROOT } from "./relayDiscovery.js"; +import { defineRelaysPanelElement } from "./relaysPanelElement.js"; + +const plugin: PiWebPlugin = { + apiVersion: 1, + name: "Relays", + activate: ({ pluginId, html, svg }) => { + defineRelaysPanelElement(); + + return { + contributions: { + actions: [ + { + id: "workspace.open-relays", + title: "Open Workspace Relays", + description: `Open the workspace Relays tab. Relays live in ${RELAYS_ROOT}.`, + group: "Workspace", + enabled: (context) => context.state.selectedWorkspace !== undefined, + run: (context) => { + if (context.state.selectedWorkspace === undefined) return; + context.selectWorkspaceTool(`${pluginId}:workspace.relays`); + }, + }, + ], + workspacePanels: [ + { + id: "workspace.relays", + title: "Relays", + icon: svg` + + + + + + `, + order: 50, + render: (context) => html``, + }, + ], + }, + }; + }, +}; + +export default plugin; diff --git a/pi-web-plugins/relays/relayDiscovery.test.ts b/pi-web-plugins/relays/relayDiscovery.test.ts new file mode 100644 index 0000000..83ca9d5 --- /dev/null +++ b/pi-web-plugins/relays/relayDiscovery.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it, vi } from "vitest"; +import type { FileContentResponse, FileTreeEntry, FileTreeResponse } from "@jmfederico/pi-web/plugin-api"; +import { + defaultRelayDocument, + listRelayDocuments, + listWorkspaceRelays, + orderRelayDocuments, + readRelayDocument, + RELAYS_ROOT, + sortRelaysByRecency, + type RelayDiscoveryFiles, + type RelayDocumentEntry, + type RelaySummary, +} from "./relayDiscovery"; + +describe("listWorkspaceRelays", () => { + it("lists relay directories through the workspace files helper, most recently modified first", async () => { + const listFiles = vi.fn(() => Promise.resolve(tree([ + directoryEntry("older", "2026-01-01T00:00:00.000Z"), + fileEntry("stray-file.md", "2026-03-01T00:00:00.000Z"), + directoryEntry("newer", "2026-02-01T00:00:00.000Z"), + symlinkEntry("linked", "2026-04-01T00:00:00.000Z"), + ]))); + const files = filesWith({ listFiles }); + + const result = await listWorkspaceRelays(files); + + expect(listFiles).toHaveBeenCalledWith(RELAYS_ROOT); + expect(result).toEqual({ + kind: "loaded", + relays: [ + { name: "newer", path: `${RELAYS_ROOT}/newer`, modifiedAt: "2026-02-01T00:00:00.000Z" }, + { name: "older", path: `${RELAYS_ROOT}/older`, modifiedAt: "2026-01-01T00:00:00.000Z" }, + ], + }); + }); + + it("reports an empty relays directory as zero relays", async () => { + const files = filesWith({ listFiles: () => Promise.resolve(tree([])) }); + + await expect(listWorkspaceRelays(files)).resolves.toEqual({ kind: "loaded", relays: [] }); + }); + + it("treats a missing relays directory as missing rather than a failure", async () => { + const files = filesWith({ listFiles: () => Promise.reject(new Error("Path does not exist")) }); + + await expect(listWorkspaceRelays(files)).resolves.toEqual({ kind: "missing" }); + }); + + it("treats a relays root that is not a directory as missing rather than a failure", async () => { + const files = filesWith({ listFiles: () => Promise.reject(new Error("Path is not a directory")) }); + + await expect(listWorkspaceRelays(files)).resolves.toEqual({ kind: "missing" }); + }); + + it("surfaces other listing failures as unavailable with the error detail", async () => { + const files = filesWith({ listFiles: () => Promise.reject(new Error("connection lost")) }); + + await expect(listWorkspaceRelays(files)).resolves.toEqual({ kind: "unavailable", detail: "connection lost" }); + }); +}); + +describe("sortRelaysByRecency", () => { + it("sorts undated and invalid-dated relays after dated ones, alphabetically", () => { + const relays: RelaySummary[] = [ + { name: "zulu", path: "zulu" }, + { name: "middle", path: "middle", modifiedAt: "2026-02-01T00:00:00.000Z" }, + { name: "alpha", path: "alpha" }, + { name: "broken", path: "broken", modifiedAt: "not-a-date" }, + { name: "newest", path: "newest", modifiedAt: "2026-03-01T00:00:00.000Z" }, + ]; + + expect(sortRelaysByRecency(relays).map((relay) => relay.name)).toEqual(["newest", "middle", "alpha", "broken", "zulu"]); + }); + + it("does not mutate the input array", () => { + const relays: RelaySummary[] = [ + { name: "b", path: "b", modifiedAt: "2026-01-01T00:00:00.000Z" }, + { name: "a", path: "a", modifiedAt: "2026-02-01T00:00:00.000Z" }, + ]; + + sortRelaysByRecency(relays); + + expect(relays.map((relay) => relay.name)).toEqual(["b", "a"]); + }); +}); + +describe("listRelayDocuments", () => { + it("lists relay files with anchor documents first, then alphabetical", async () => { + const relayPath = `${RELAYS_ROOT}/my-relay`; + const relayFile = (name: string): FileTreeEntry => ({ name, path: `${relayPath}/${name}`, type: "file" }); + const listFiles = vi.fn(() => Promise.resolve(tree([ + relayFile("notes.md"), + relayFile("log.md"), + directoryEntry("subdir"), + relayFile("status.md"), + relayFile("charter.md"), + relayFile("data.json"), + ]))); + const files = filesWith({ listFiles }); + + const result = await listRelayDocuments(files, relayPath); + + expect(listFiles).toHaveBeenCalledWith(relayPath); + expect(result).toEqual({ + kind: "loaded", + documents: [ + { name: "status.md", path: `${relayPath}/status.md`, modifiedAt: undefined }, + { name: "charter.md", path: `${relayPath}/charter.md`, modifiedAt: undefined }, + { name: "log.md", path: `${relayPath}/log.md`, modifiedAt: undefined }, + { name: "data.json", path: `${relayPath}/data.json`, modifiedAt: undefined }, + { name: "notes.md", path: `${relayPath}/notes.md`, modifiedAt: undefined }, + ], + }); + }); + + it("treats a vanished relay directory as missing", async () => { + const files = filesWith({ listFiles: () => Promise.reject(new Error("Path does not exist")) }); + + await expect(listRelayDocuments(files, `${RELAYS_ROOT}/gone`)).resolves.toEqual({ kind: "missing" }); + }); + + it("surfaces other listing failures as unavailable", async () => { + const files = filesWith({ listFiles: () => Promise.reject(new Error("boom")) }); + + await expect(listRelayDocuments(files, `${RELAYS_ROOT}/x`)).resolves.toEqual({ kind: "unavailable", detail: "boom" }); + }); +}); + +describe("orderRelayDocuments", () => { + it("puts anchor documents first in fixed order without mutating the input", () => { + const documents: RelayDocumentEntry[] = [ + { name: "zebra.md", path: "zebra.md" }, + { name: "log.md", path: "log.md" }, + { name: "status.md", path: "status.md" }, + ]; + + const ordered = orderRelayDocuments(documents); + + expect(ordered.map((document) => document.name)).toEqual(["status.md", "log.md", "zebra.md"]); + expect(documents.map((document) => document.name)).toEqual(["zebra.md", "log.md", "status.md"]); + }); +}); + +describe("defaultRelayDocument", () => { + it("picks status.md when present", () => { + const documents: RelayDocumentEntry[] = [ + { name: "charter.md", path: "charter.md" }, + { name: "status.md", path: "status.md" }, + ]; + + expect(defaultRelayDocument(documents)?.name).toBe("status.md"); + }); + + it("falls back to the first ordered document when status.md is absent", () => { + const documents: RelayDocumentEntry[] = [ + { name: "alpha.md", path: "alpha.md" }, + { name: "charter.md", path: "charter.md" }, + ]; + + expect(defaultRelayDocument(documents)?.name).toBe("charter.md"); + }); + + it("returns undefined for a relay without documents", () => { + expect(defaultRelayDocument([])).toBeUndefined(); + }); +}); + +describe("readRelayDocument", () => { + it("returns document content with its truncation and binary flags", async () => { + const files = filesWith({ + readFile: () => Promise.resolve(fileContent({ content: "# Status", truncated: true, binary: false })), + }); + + await expect(readRelayDocument(files, `${RELAYS_ROOT}/r/log.md`)).resolves.toEqual({ + kind: "loaded", + content: "# Status", + truncated: true, + binary: false, + }); + }); + + it("treats a vanished document as missing", async () => { + const files = filesWith({ readFile: () => Promise.reject(new Error("Path does not exist")) }); + + await expect(readRelayDocument(files, `${RELAYS_ROOT}/r/gone.md`)).resolves.toEqual({ kind: "missing" }); + }); + + it("surfaces other read failures as unavailable", async () => { + const files = filesWith({ readFile: () => Promise.reject(new Error("boom")) }); + + await expect(readRelayDocument(files, `${RELAYS_ROOT}/r/x.md`)).resolves.toEqual({ kind: "unavailable", detail: "boom" }); + }); +}); + +function filesWith(overrides: Partial): RelayDiscoveryFiles { + return { + listFiles: () => Promise.reject(new Error("listFiles not expected")), + readFile: () => Promise.reject(new Error("readFile not expected")), + ...overrides, + }; +} + +function tree(entries: FileTreeEntry[]): FileTreeResponse { + return { path: RELAYS_ROOT, entries, scannedAt: "2026-01-01T00:00:00.000Z", truncated: false }; +} + +function directoryEntry(name: string, modifiedAt?: string): FileTreeEntry { + return { name, path: `${RELAYS_ROOT}/${name}`, type: "directory", ...(modifiedAt === undefined ? {} : { modifiedAt }) }; +} + +function fileEntry(name: string, modifiedAt?: string): FileTreeEntry { + return { name, path: `${RELAYS_ROOT}/${name}`, type: "file", ...(modifiedAt === undefined ? {} : { modifiedAt }) }; +} + +function symlinkEntry(name: string, modifiedAt?: string): FileTreeEntry { + return { name, path: `${RELAYS_ROOT}/${name}`, type: "symlink", ...(modifiedAt === undefined ? {} : { modifiedAt }) }; +} + +function fileContent(overrides: Partial): FileContentResponse { + return { + path: `${RELAYS_ROOT}/r/log.md`, + encoding: "utf8", + size: 8, + modifiedAt: "2026-01-01T00:00:00.000Z", + content: "", + truncated: false, + binary: false, + ...overrides, + }; +} diff --git a/pi-web-plugins/relays/relayDiscovery.ts b/pi-web-plugins/relays/relayDiscovery.ts new file mode 100644 index 0000000..ff5cfb9 --- /dev/null +++ b/pi-web-plugins/relays/relayDiscovery.ts @@ -0,0 +1,140 @@ +import type { FileContentResponse, FileTreeEntry, FileTreeResponse } from "@jmfederico/pi-web/plugin-api"; + +export const RELAYS_ROOT = ".pi-web/relays"; + +/** Documents that anchor a relay packet, in display order. Any other files follow alphabetically. */ +export const RELAY_ANCHOR_DOCUMENTS: readonly string[] = ["status.md", "charter.md", "log.md"]; + +/** Structural subset of the plugin WorkspaceFiles helper this module needs. */ +export interface RelayDiscoveryFiles { + listFiles(path: string): Promise; + readFile(path: string): Promise; +} + +export interface RelaySummary { + name: string; + path: string; + modifiedAt?: string | undefined; +} + +export interface RelayDocumentEntry { + name: string; + path: string; + modifiedAt?: string | undefined; +} + +export type RelaysListing = + | { kind: "loaded"; relays: RelaySummary[] } + | { kind: "missing" } + | { kind: "unavailable"; detail: string }; + +export type RelayDocumentsListing = + | { kind: "loaded"; documents: RelayDocumentEntry[] } + | { kind: "missing" } + | { kind: "unavailable"; detail: string }; + +export type RelayDocumentContent = + | { kind: "loaded"; content: string; truncated: boolean; binary: boolean } + | { kind: "missing" } + | { kind: "unavailable"; detail: string }; + +// The workspace file API rejects with these messages when a path is absent or +// is not a directory. For the relays root both mean "zero relays", not a failure. +const missingListingErrorMessages = new Set(["Path does not exist", "Path is not a directory"]); + +/** List the workspace's relays, most recently modified first. Never rejects. */ +export async function listWorkspaceRelays(files: RelayDiscoveryFiles): Promise { + let listing: FileTreeResponse; + try { + listing = await files.listFiles(RELAYS_ROOT); + } catch (error) { + return fileAccessFailure(error); + } + const relays = sortRelaysByRecency( + listing.entries + .filter((entry) => entry.type === "directory") + .map((entry) => toRelaySummary(entry)), + ); + return { kind: "loaded", relays }; +} + +/** List one relay's documents: status.md, charter.md, log.md first, then alphabetical. Never rejects. */ +export async function listRelayDocuments(files: RelayDiscoveryFiles, relayPath: string): Promise { + let listing: FileTreeResponse; + try { + listing = await files.listFiles(relayPath); + } catch (error) { + return fileAccessFailure(error); + } + const documents = orderRelayDocuments( + listing.entries + .filter((entry) => entry.type === "file") + .map((entry) => toRelayDocumentEntry(entry)), + ); + return { kind: "loaded", documents }; +} + +/** Read one relay document. Never rejects. */ +export async function readRelayDocument(files: RelayDiscoveryFiles, documentPath: string): Promise { + try { + const file = await files.readFile(documentPath); + return { kind: "loaded", content: file.content, truncated: file.truncated, binary: file.binary }; + } catch (error) { + return fileAccessFailure(error); + } +} + +/** Newest first; relays without a usable modifiedAt sort last, alphabetically. */ +export function sortRelaysByRecency(relays: RelaySummary[]): RelaySummary[] { + return [...relays].sort(compareRelaysByRecency); +} + +/** Anchor documents first in fixed order, then everything else alphabetically. */ +export function orderRelayDocuments(documents: RelayDocumentEntry[]): RelayDocumentEntry[] { + return [...documents].sort(compareRelayDocuments); +} + +/** The document a relay opens on: status.md when present, otherwise the first ordered document. */ +export function defaultRelayDocument(documents: RelayDocumentEntry[]): RelayDocumentEntry | undefined { + // Anchor ordering guarantees status.md is first whenever it exists. + return orderRelayDocuments(documents)[0]; +} + +function toRelaySummary(entry: FileTreeEntry): RelaySummary { + return { name: entry.name, path: entry.path, modifiedAt: entry.modifiedAt }; +} + +function toRelayDocumentEntry(entry: FileTreeEntry): RelayDocumentEntry { + return { name: entry.name, path: entry.path, modifiedAt: entry.modifiedAt }; +} + +function compareRelaysByRecency(left: RelaySummary, right: RelaySummary): number { + const leftTime = timestampOf(left.modifiedAt); + const rightTime = timestampOf(right.modifiedAt); + if (leftTime !== undefined && rightTime !== undefined && leftTime !== rightTime) return rightTime - leftTime; + if (leftTime !== undefined) return -1; + if (rightTime !== undefined) return 1; + return left.name.localeCompare(right.name); +} + +function timestampOf(modifiedAt: string | undefined): number | undefined { + if (modifiedAt === undefined) return undefined; + const time = Date.parse(modifiedAt); + return Number.isNaN(time) ? undefined : time; +} + +function compareRelayDocuments(left: RelayDocumentEntry, right: RelayDocumentEntry): number { + const anchorOrder = anchorIndexOf(left.name) - anchorIndexOf(right.name); + if (anchorOrder !== 0) return anchorOrder; + return left.name.localeCompare(right.name); +} + +function anchorIndexOf(name: string): number { + const index = RELAY_ANCHOR_DOCUMENTS.indexOf(name); + return index === -1 ? RELAY_ANCHOR_DOCUMENTS.length : index; +} + +function fileAccessFailure(error: unknown): { kind: "missing" } | { kind: "unavailable"; detail: string } { + if (error instanceof Error && missingListingErrorMessages.has(error.message)) return { kind: "missing" }; + return { kind: "unavailable", detail: error instanceof Error ? error.message : String(error) }; +} diff --git a/pi-web-plugins/relays/relaysPanelElement.test.ts b/pi-web-plugins/relays/relaysPanelElement.test.ts new file mode 100644 index 0000000..992b171 --- /dev/null +++ b/pi-web-plugins/relays/relaysPanelElement.test.ts @@ -0,0 +1,478 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it, vi, type Mock } from "vitest"; +import type { FileContentResponse, FileTreeEntry, WorkspaceFiles, WorkspacePanelContext } from "@jmfederico/pi-web/plugin-api"; +import { RELAYS_ROOT, type RelayDiscoveryFiles } from "./relayDiscovery"; +import { defineRelaysPanelElement, relaysPanelTagName } from "./relaysPanelElement"; + +interface RelaysPanelTestElement extends HTMLElement { + context: WorkspacePanelContext | undefined; +} + +declare global { + interface HTMLElementTagNameMap { + "pi-web-relays-panel": RelaysPanelTestElement; + } +} + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("workspace states", () => { + it("asks for a workspace when no context is set", async () => { + const panel = await mountPanel(); + + expect(shadow(panel).textContent).toContain("Select a workspace."); + }); + + it("explains the relays convention when the workspace has no relays root", async () => { + // The fake rejects unknown paths with "Path does not exist". + const panel = await mountPanel(panelContext(workspaceFilesFake())); + + expect(viewerText(panel)).toContain("No relays in this workspace."); + expect(viewerText(panel)).toContain(`${RELAYS_ROOT}//`); + }); + + it("shows the same empty state when the relays root exists but is empty", async () => { + const fake = workspaceFilesFake(); + fake.addDirectory(RELAYS_ROOT, []); + + const panel = await mountPanel(panelContext(fake)); + + expect(viewerText(panel)).toContain("No relays in this workspace."); + }); + + it("surfaces a scan failure with its detail", async () => { + const fake = workspaceFilesFake(); + fake.failWith(RELAYS_ROOT, new Error("connection lost")); + + const panel = await mountPanel(panelContext(fake)); + + const error = shadow(panel).querySelector(".status.error"); + expect(error?.textContent).toContain("Could not scan workspace relays."); + expect(error?.textContent).toContain("connection lost"); + }); +}); + +describe("single relay", () => { + it("auto-opens the relay without a picker and renders its default document", async () => { + const fake = workspaceFilesFake(); + fake.addDirectory(RELAYS_ROOT, [relayDirectory("only-relay", "2026-02-01T00:00:00.000Z")]); + fake.addDirectory(`${RELAYS_ROOT}/only-relay`, [ + relayDocument("only-relay", "charter.md"), + relayDocument("only-relay", "status.md"), + ]); + fake.addDocument(`${RELAYS_ROOT}/only-relay/status.md`, "# Status\nAll good."); + fake.addDocument(`${RELAYS_ROOT}/only-relay/charter.md`, "# Charter"); + + const panel = await mountPanel(panelContext(fake)); + + expect(picker(panel)).toBeNull(); + expect(shadow(panel).querySelector(".relay-name")?.textContent).toBe("only-relay"); + expect(tabNames(panel)).toEqual(["status.md", "charter.md"]); + expect(activeTab(panel)?.textContent).toBe("status.md"); + expect(documentText(panel)).toContain("All good."); + expect(fake.readFile).toHaveBeenCalledWith(`${RELAYS_ROOT}/only-relay/status.md`); + }); + + it("escapes filesystem-derived names and paths", async () => { + const fake = workspaceFilesFake(); + fake.addDirectory(RELAYS_ROOT, [relayDirectory("")]); + fake.addDirectory(`${RELAYS_ROOT}/`, [relayDocument("", "status.md")]); + fake.addDocument(`${RELAYS_ROOT}//status.md`, "safe"); + + const panel = await mountPanel(panelContext(fake)); + + expect(shadow(panel).querySelector("img")).toBeNull(); + expect(shadow(panel).querySelector(".relay-name")?.textContent).toBe(""); + }); +}); + +describe("multiple relays", () => { + it("pre-selects the most recently modified relay and opens another on picker change", async () => { + const fake = workspaceFilesFake(); + fake.addDirectory(RELAYS_ROOT, [ + relayDirectory("older", "2026-01-01T00:00:00.000Z"), + relayDirectory("newer", "2026-03-01T00:00:00.000Z"), + ]); + fake.addDirectory(`${RELAYS_ROOT}/newer`, [relayDocument("newer", "status.md")]); + fake.addDirectory(`${RELAYS_ROOT}/older`, [relayDocument("older", "status.md")]); + fake.addDocument(`${RELAYS_ROOT}/newer/status.md`, "newer status"); + fake.addDocument(`${RELAYS_ROOT}/older/status.md`, "older status"); + + const panel = await mountPanel(panelContext(fake)); + + expect(picker(panel)?.value).toBe(`${RELAYS_ROOT}/newer`); + expect(documentText(panel)).toBe("newer status"); + + const select = picker(panel); + if (select === null) throw new Error("relay picker missing"); + select.value = `${RELAYS_ROOT}/older`; + select.dispatchEvent(new Event("change")); + await flushAsync(); + + expect(fake.listFiles).toHaveBeenCalledWith(`${RELAYS_ROOT}/older`); + expect(documentText(panel)).toBe("older status"); + }); +}); + +describe("document tabs", () => { + it("orders tabs with anchor documents first and loads the clicked tab's document", async () => { + const fake = workspaceFilesFake(); + fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]); + fake.addDirectory(`${RELAYS_ROOT}/relay`, [ + relayDocument("relay", "notes.md"), + relayDocument("relay", "log.md"), + relayDocument("relay", "data.json"), + relayDocument("relay", "charter.md"), + relayDocument("relay", "status.md"), + ]); + fake.addDocument(`${RELAYS_ROOT}/relay/status.md`, "status body"); + fake.addDocument(`${RELAYS_ROOT}/relay/log.md`, "log body"); + + const panel = await mountPanel(panelContext(fake)); + + expect(tabNames(panel)).toEqual(["status.md", "charter.md", "log.md", "data.json", "notes.md"]); + expect(activeTab(panel)?.textContent).toBe("status.md"); + + tabNamed(panel, "log.md").click(); + await flushAsync(); + + expect(fake.readFile).toHaveBeenCalledWith(`${RELAYS_ROOT}/relay/log.md`); + expect(activeTab(panel)?.textContent).toBe("log.md"); + expect(documentText(panel)).toBe("log body"); + }); + + it("renders all document tabs as direct children of one tab strip", async () => { + // The strip scrolls horizontally instead of wrapping, so every tab must be + // a direct child of the single nav.document-tabs row. + const fake = workspaceFilesFake(); + fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]); + fake.addDirectory(`${RELAYS_ROOT}/relay`, [ + relayDocument("relay", "notes.md"), + relayDocument("relay", "log.md"), + relayDocument("relay", "status.md"), + ]); + fake.addDocument(`${RELAYS_ROOT}/relay/status.md`, "status body"); + + const panel = await mountPanel(panelContext(fake)); + + const strips = shadow(panel).querySelectorAll("nav.document-tabs"); + expect(strips.length).toBe(1); + const strip = strips[0]; + expect(strip?.getAttribute("aria-label")).toBe("Relay documents"); + const children = [...(strip?.children ?? [])]; + expect(children.every((child) => child instanceof HTMLButtonElement)).toBe(true); + expect(children.map((child) => child.textContent)).toEqual(["status.md", "log.md", "notes.md"]); + }); + + it("shows a truncation notice when the open document is truncated", async () => { + const fake = workspaceFilesFake(); + fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]); + fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "log.md")]); + fake.addDocument(`${RELAYS_ROOT}/relay/log.md`, "partial log", { truncated: true }); + + const panel = await mountPanel(panelContext(fake)); + + expect(shadow(panel).querySelector(".status.info")?.textContent).toContain("truncated"); + expect(documentText(panel)).toBe("partial log"); + }); + + it("explains when a document has no text preview", async () => { + const fake = workspaceFilesFake(); + fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]); + fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "status.md")]); + fake.addDocument(`${RELAYS_ROOT}/relay/status.md`, "AAAA", { binary: true }); + + const panel = await mountPanel(panelContext(fake)); + + expect(viewerText(panel)).toContain("Binary file: status.md"); + expect(shadow(panel).querySelector("pre.document")).toBeNull(); + }); + + it("explains when the open document vanishes between listing and read", async () => { + const fake = workspaceFilesFake(); + fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]); + fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "status.md")]); + // No addDocument: the fake readFile rejects with "Path does not exist". + + const panel = await mountPanel(panelContext(fake)); + + expect(viewerText(panel)).toContain("This document no longer exists."); + }); + + it("explains when a relay has no documents", async () => { + const fake = workspaceFilesFake(); + fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]); + fake.addDirectory(`${RELAYS_ROOT}/relay`, []); + + const panel = await mountPanel(panelContext(fake)); + + expect(viewerText(panel)).toContain("This relay has no documents yet."); + }); +}); + +describe("markdown rendering", () => { + it("renders .md documents as sanitized markdown HTML", async () => { + const fake = workspaceFilesFake(); + fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]); + fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "status.md")]); + fake.addDocument(`${RELAYS_ROOT}/relay/status.md`, [ + "# Status", + "", + "See [the docs](https://example.com/docs).", + "", + "", + "", + "[click](javascript:alert('xss'))", + ].join("\n")); + + const panel = await mountPanel(panelContext(fake)); + + const rendered = shadow(panel).querySelector(".document.markdown"); + expect(rendered).not.toBeNull(); + expect(shadow(panel).querySelector("pre.document")).toBeNull(); + expect(rendered?.querySelector("h1")?.textContent).toBe("Status"); + expect(rendered?.querySelector("script")).toBeNull(); + const docsLink = rendered?.querySelector('a[href="https://example.com/docs"]'); + expect(docsLink?.getAttribute("target")).toBe("_blank"); + expect(docsLink?.getAttribute("rel")).toBe("noreferrer noopener"); + const unsafeLink = [...rendered?.querySelectorAll("a") ?? []].find((link) => link.textContent === "click"); + expect(unsafeLink?.hasAttribute("href")).toBe(false); + }); + + it("keeps non-markdown documents as escaped preformatted text", async () => { + const fake = workspaceFilesFake(); + fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]); + fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "notes.txt")]); + fake.addDocument(`${RELAYS_ROOT}/relay/notes.txt`, "# not a heading\n"); + + const panel = await mountPanel(panelContext(fake)); + + expect(shadow(panel).querySelector(".document.markdown")).toBeNull(); + const pre = shadow(panel).querySelector("pre.document"); + expect(pre?.textContent).toContain("# not a heading"); + expect(shadow(panel).querySelector(".viewer h1")).toBeNull(); + expect(shadow(panel).querySelector(".viewer script")).toBeNull(); + }); + + it("keeps the truncation notice ahead of rendered markdown", async () => { + const fake = workspaceFilesFake(); + fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]); + fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "log.md")]); + fake.addDocument(`${RELAYS_ROOT}/relay/log.md`, "# Partial", { truncated: true }); + + const panel = await mountPanel(panelContext(fake)); + + const viewer = shadow(panel).querySelector(".viewer"); + expect(viewer?.querySelector(".status.info")?.textContent).toContain("truncated"); + expect(viewer?.querySelector(".document.markdown h1")?.textContent).toBe("Partial"); + }); +}); + +describe("refresh and context changes", () => { + it("renders Refresh as an icon-only button with an accessible name", async () => { + const fake = workspaceFilesFake(); + fake.addDirectory(RELAYS_ROOT, []); + + const panel = await mountPanel(panelContext(fake)); + + const button = refreshButton(panel); + expect(button.getAttribute("aria-label")).toBe("Refresh"); + expect(button.getAttribute("title")).toBe("Refresh"); + expect(button.textContent.trim()).toBe(""); + const icon = button.querySelector("svg"); + expect(icon).not.toBeNull(); + expect(icon?.getAttribute("aria-hidden")).toBe("true"); + + fake.listFiles.mockClear(); + button.click(); + await flushAsync(); + expect(fake.listFiles).toHaveBeenCalledWith(RELAYS_ROOT); + }); + + it("re-scans on Refresh while keeping the open relay and document", async () => { + const fake = workspaceFilesFake(); + fake.addDirectory(RELAYS_ROOT, [ + relayDirectory("alpha", "2026-01-01T00:00:00.000Z"), + relayDirectory("beta", "2026-02-01T00:00:00.000Z"), + ]); + fake.addDirectory(`${RELAYS_ROOT}/alpha`, [relayDocument("alpha", "status.md"), relayDocument("alpha", "log.md")]); + fake.addDirectory(`${RELAYS_ROOT}/beta`, [relayDocument("beta", "status.md")]); + fake.addDocument(`${RELAYS_ROOT}/alpha/status.md`, "alpha status"); + fake.addDocument(`${RELAYS_ROOT}/alpha/log.md`, "alpha log"); + fake.addDocument(`${RELAYS_ROOT}/beta/status.md`, "beta status"); + + const panel = await mountPanel(panelContext(fake)); + const select = picker(panel); + if (select === null) throw new Error("relay picker missing"); + select.value = `${RELAYS_ROOT}/alpha`; + select.dispatchEvent(new Event("change")); + await flushAsync(); + tabNamed(panel, "log.md").click(); + await flushAsync(); + expect(documentText(panel)).toBe("alpha log"); + + fake.listFiles.mockClear(); + fake.readFile.mockClear(); + refreshButton(panel).click(); + await flushAsync(); + + expect(fake.listFiles).toHaveBeenCalledWith(RELAYS_ROOT); + expect(fake.listFiles).toHaveBeenCalledWith(`${RELAYS_ROOT}/alpha`); + expect(fake.readFile).toHaveBeenCalledWith(`${RELAYS_ROOT}/alpha/log.md`); + expect(picker(panel)?.value).toBe(`${RELAYS_ROOT}/alpha`); + expect(activeTab(panel)?.textContent).toBe("log.md"); + expect(documentText(panel)).toBe("alpha log"); + }); + + it("does not rescan when the same workspace context is set again, but rescans for a new workspace", async () => { + const fake = workspaceFilesFake(); + fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]); + fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "status.md")]); + fake.addDocument(`${RELAYS_ROOT}/relay/status.md`, "status"); + + const panel = await mountPanel(panelContext(fake)); + const callsAfterMount = fake.listFiles.mock.calls.length; + + panel.context = panelContext(fake); + await flushAsync(); + expect(fake.listFiles.mock.calls.length).toBe(callsAfterMount); + + panel.context = panelContext(fake, "ws-2"); + await flushAsync(); + expect(fake.listFiles.mock.calls.length).toBeGreaterThan(callsAfterMount); + }); +}); + +interface WorkspaceFilesFake { + files: WorkspaceFiles; + listFiles: Mock; + readFile: Mock; + addDirectory(path: string, entries: FileTreeEntry[]): void; + addDocument(path: string, content: string, overrides?: Partial): void; + failWith(path: string, error: Error): void; +} + +/** In-memory WorkspaceFiles fake: unknown paths reject with "Path does not exist", like the real helper. */ +function workspaceFilesFake(): WorkspaceFilesFake { + const directories = new Map(); + const documents = new Map(); + const failures = new Map(); + const listFiles = vi.fn((path) => { + const failure = failures.get(path); + if (failure !== undefined) return Promise.reject(failure); + const entries = directories.get(path); + if (entries === undefined) return Promise.reject(new Error("Path does not exist")); + return Promise.resolve({ path, entries, scannedAt: "2026-01-01T00:00:00.000Z", truncated: false }); + }); + const readFile = vi.fn((path) => { + const failure = failures.get(path); + if (failure !== undefined) return Promise.reject(failure); + const file = documents.get(path); + if (file === undefined) return Promise.reject(new Error("Path does not exist")); + return Promise.resolve(file); + }); + return { + files: { + listFiles, + readFile, + // The panel is read-only; the mutating helpers exist only to satisfy WorkspaceFiles. + writeFile: () => Promise.reject(new Error("writeFile not used")), + deleteFile: () => Promise.reject(new Error("deleteFile not used")), + moveFile: () => Promise.reject(new Error("moveFile not used")), + }, + listFiles, + readFile, + addDirectory: (path, entries) => { directories.set(path, entries); }, + addDocument: (path, content, overrides = {}) => { + documents.set(path, { + path, + encoding: "utf8", + size: content.length, + modifiedAt: "2026-01-01T00:00:00.000Z", + content, + truncated: false, + binary: false, + ...overrides, + }); + }, + failWith: (path, error) => { failures.set(path, error); }, + }; +} + +function panelContext(fake: WorkspaceFilesFake, workspaceId = "ws-1"): WorkspacePanelContext { + return { + machine: { id: "machine-1", name: "Local", kind: "local" }, + workspace: { id: workspaceId, projectId: "project-1", path: "/repo", label: "repo", isMain: true, isGitRepo: true, isGitWorktree: false }, + files: fake.files, + host: { requestRender: () => undefined }, + prompt: { insertText: () => undefined, getText: () => "", getSelection: () => null }, + terminal: { open: () => undefined, runCommand: () => Promise.reject(new Error("terminal not used")) }, + }; +} + +async function mountPanel(context?: WorkspacePanelContext): Promise { + defineRelaysPanelElement(); + const panel = document.createElement(relaysPanelTagName); + document.body.append(panel); + if (context !== undefined) panel.context = context; + await flushAsync(); + return panel; +} + +/** One macrotask drains the full microtask chain of the panel's immediate-fake scans. */ +function flushAsync(): Promise { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +function relayDirectory(name: string, modifiedAt?: string): FileTreeEntry { + return { name, path: `${RELAYS_ROOT}/${name}`, type: "directory", ...(modifiedAt === undefined ? {} : { modifiedAt }) }; +} + +function relayDocument(relayName: string, name: string, modifiedAt?: string): FileTreeEntry { + return { name, path: `${RELAYS_ROOT}/${relayName}/${name}`, type: "file", ...(modifiedAt === undefined ? {} : { modifiedAt }) }; +} + +function shadow(panel: RelaysPanelTestElement): ShadowRoot { + const root = panel.shadowRoot; + if (root === null) throw new Error("panel has no shadow root"); + return root; +} + +function viewerText(panel: RelaysPanelTestElement): string { + return shadow(panel).querySelector(".viewer")?.textContent ?? ""; +} + +/** Text of the open document viewer, whether it rendered markdown or a
 block. */
+function documentText(panel: RelaysPanelTestElement): string | null {
+  return shadow(panel).querySelector(".viewer .document")?.textContent.trim() ?? null;
+}
+
+function picker(panel: RelaysPanelTestElement): HTMLSelectElement | null {
+  const select = shadow(panel).querySelector("select[data-relay-picker]");
+  return select instanceof HTMLSelectElement ? select : null;
+}
+
+function refreshButton(panel: RelaysPanelTestElement): HTMLElement {
+  const button = shadow(panel).querySelector("button[data-refresh]");
+  if (!(button instanceof HTMLElement)) throw new Error("refresh button missing");
+  return button;
+}
+
+function tabNames(panel: RelaysPanelTestElement): string[] {
+  return [...shadow(panel).querySelectorAll("button[data-document-path]")].map((tab) => tab.textContent);
+}
+
+function activeTab(panel: RelaysPanelTestElement): Element | null {
+  return shadow(panel).querySelector("button[data-document-path].active");
+}
+
+function tabNamed(panel: RelaysPanelTestElement, name: string): HTMLElement {
+  const tab = [...shadow(panel).querySelectorAll("button[data-document-path]")].find((candidate) => candidate.textContent === name);
+  if (!(tab instanceof HTMLElement)) throw new Error(`tab "${name}" not found`);
+  return tab;
+}
diff --git a/pi-web-plugins/relays/relaysPanelElement.ts b/pi-web-plugins/relays/relaysPanelElement.ts
new file mode 100644
index 0000000..c061e06
--- /dev/null
+++ b/pi-web-plugins/relays/relaysPanelElement.ts
@@ -0,0 +1,358 @@
+import type { WorkspacePanelContext } from "@jmfederico/pi-web/plugin-api";
+import { isMarkdownDocumentPath, renderRelayDocumentHtml } from "./markdownDocument.js";
+import {
+  defaultRelayDocument,
+  listRelayDocuments,
+  listWorkspaceRelays,
+  readRelayDocument,
+  RELAYS_ROOT,
+  type RelayDocumentContent,
+  type RelayDocumentsListing,
+  type RelaysListing,
+} from "./relayDiscovery.js";
+
+export const relaysPanelTagName = "pi-web-relays-panel";
+
+export function defineRelaysPanelElement(): void {
+  if (!customElements.get(relaysPanelTagName)) customElements.define(relaysPanelTagName, PiWebRelaysPanel);
+}
+
+/** Selection a scan should restore after reloading, when the entries still exist. */
+interface RelaySelection {
+  relayPath?: string | undefined;
+  documentPath?: string | undefined;
+}
+
+/**
+ * Read-only relay browser: relay picker (auto-opens a single relay), one tab
+ * per relay document, and a document viewer rendering markdown documents as
+ * sanitized HTML and everything else as preformatted text. All async loads
+ * flow through scanToken so stale responses for a previous workspace or
+ * selection never overwrite newer state.
+ */
+class PiWebRelaysPanel extends HTMLElement {
+  private contextValue: WorkspacePanelContext | undefined;
+  private listing: RelaysListing | undefined;
+  private selectedRelayPath: string | undefined;
+  private documents: RelayDocumentsListing | undefined;
+  private selectedDocumentPath: string | undefined;
+  private documentContent: RelayDocumentContent | undefined;
+  private scanToken = 0;
+  private readonly root: ShadowRoot;
+
+  constructor() {
+    super();
+    this.root = this.attachShadow({ mode: "open" });
+  }
+
+  set context(value: WorkspacePanelContext | undefined) {
+    const previousKey = this.contextValue === undefined ? undefined : contextKey(this.contextValue);
+    const nextKey = value === undefined ? undefined : contextKey(value);
+    this.contextValue = value;
+    // Parent app updates should not rescan or rebuild this shadow DOM for the
+    // same workspace (mirrors the workspace-tasks panel).
+    if (previousKey === nextKey) return;
+    if (value === undefined) {
+      this.resetScanState();
+      this.render();
+      return;
+    }
+    void this.scan(value, {});
+  }
+
+  connectedCallback(): void {
+    this.render();
+  }
+
+  /** Rescan relays, then reload the selected relay's documents and the open document. */
+  private async scan(context: WorkspacePanelContext, selection: RelaySelection): Promise {
+    const token = ++this.scanToken;
+    this.resetScanState();
+    this.render();
+
+    const listing = await listWorkspaceRelays(context.files);
+    if (!this.isCurrentScan(context, token)) return;
+    this.listing = listing;
+
+    // listWorkspaceRelays returns most recently modified first, so the first
+    // relay is the default pre-selection.
+    const relay = listing.kind === "loaded"
+      ? listing.relays.find((candidate) => candidate.path === selection.relayPath) ?? listing.relays[0]
+      : undefined;
+    this.selectedRelayPath = relay?.path;
+    if (relay === undefined) {
+      this.render();
+      return;
+    }
+    await this.loadDocuments(context, token, relay.path, selection.documentPath);
+  }
+
+  private async openRelay(context: WorkspacePanelContext, relayPath: string): Promise {
+    const token = ++this.scanToken;
+    this.selectedRelayPath = relayPath;
+    await this.loadDocuments(context, token, relayPath, undefined);
+  }
+
+  private async openDocument(context: WorkspacePanelContext, documentPath: string): Promise {
+    const token = ++this.scanToken;
+    this.selectedDocumentPath = documentPath;
+    await this.loadDocumentContent(context, token, documentPath);
+  }
+
+  private async loadDocuments(context: WorkspacePanelContext, token: number, relayPath: string, preferredDocumentPath: string | undefined): Promise {
+    this.documents = undefined;
+    this.selectedDocumentPath = undefined;
+    this.documentContent = undefined;
+    this.render();
+
+    const documents = await listRelayDocuments(context.files, relayPath);
+    if (!this.isCurrentScan(context, token)) return;
+    this.documents = documents;
+
+    const document = documents.kind === "loaded"
+      ? documents.documents.find((candidate) => candidate.path === preferredDocumentPath) ?? defaultRelayDocument(documents.documents)
+      : undefined;
+    this.selectedDocumentPath = document?.path;
+    if (document === undefined) {
+      this.render();
+      return;
+    }
+    await this.loadDocumentContent(context, token, document.path);
+  }
+
+  private async loadDocumentContent(context: WorkspacePanelContext, token: number, documentPath: string): Promise {
+    this.documentContent = undefined;
+    this.render();
+
+    const content = await readRelayDocument(context.files, documentPath);
+    if (!this.isCurrentScan(context, token)) return;
+    this.documentContent = content;
+    this.render();
+  }
+
+  private refresh(): void {
+    const context = this.contextValue;
+    if (context === undefined) return;
+    void this.scan(context, { relayPath: this.selectedRelayPath, documentPath: this.selectedDocumentPath });
+  }
+
+  private resetScanState(): void {
+    this.listing = undefined;
+    this.selectedRelayPath = undefined;
+    this.documents = undefined;
+    this.selectedDocumentPath = undefined;
+    this.documentContent = undefined;
+  }
+
+  private isCurrentScan(context: WorkspacePanelContext, token: number): boolean {
+    return token === this.scanToken && this.contextValue !== undefined && contextKey(this.contextValue) === contextKey(context);
+  }
+
+  private render(): void {
+    const context = this.contextValue;
+    if (context === undefined) {
+      this.root.innerHTML = `${relaysStyles()}
Select a workspace.
`; + return; + } + this.root.innerHTML = ` + ${relaysStyles()} +
+ Relays + + ${this.renderRelayPicker()} + + +
+ ${this.renderDocumentTabs()} +
${this.renderViewer()}
+ `; + + this.root.querySelector("button[data-refresh]")?.addEventListener("click", () => { + this.refresh(); + }); + this.root.querySelector("select[data-relay-picker]")?.addEventListener("change", (event) => { + const picker = event.target; + if (picker instanceof HTMLSelectElement) void this.openRelay(context, picker.value); + }); + for (const tab of this.root.querySelectorAll("button[data-document-path]")) { + tab.addEventListener("click", () => { + const documentPath = tab.getAttribute("data-document-path"); + if (documentPath !== null) void this.openDocument(context, documentPath); + }); + } + } + + private renderRelayPicker(): string { + const listing = this.listing; + if (listing?.kind !== "loaded" || listing.relays.length === 0) return ""; + // A single relay opens immediately; a one-option picker would be noise. + if (listing.relays.length === 1) { + const relay = listing.relays[0]; + return relay === undefined ? "" : `${escapeHtml(relay.name)}`; + } + const options = listing.relays.map((relay) => { + const selected = relay.path === this.selectedRelayPath ? " selected" : ""; + return ``; + }).join(""); + return ``; + } + + private renderDocumentTabs(): string { + const documents = this.documents; + if (documents?.kind !== "loaded" || documents.documents.length === 0) return ""; + const tabs = documents.documents.map((document) => { + const active = document.path === this.selectedDocumentPath; + return ``; + }).join(""); + return ``; + } + + private renderViewer(): string { + const listing = this.listing; + if (listing === undefined) return `

Scanning ${escapeHtml(RELAYS_ROOT)}…

`; + if (listing.kind === "unavailable") return renderErrorState("Could not scan workspace relays.", listing.detail); + if (listing.kind === "missing" || listing.relays.length === 0) return renderEmptyState(); + return this.renderSelectedRelay(); + } + + private renderSelectedRelay(): string { + const documents = this.documents; + if (documents === undefined) return `

Loading relay documents…

`; + if (documents.kind === "unavailable") return renderErrorState("Could not list this relay's documents.", documents.detail); + if (documents.kind === "missing") { + return `
This relay no longer exists.

Click Refresh to rescan ${escapeHtml(RELAYS_ROOT)}.

`; + } + if (documents.documents.length === 0) { + return ` +
+ This relay has no documents yet. +

Relay packets usually contain status.md, charter.md, and log.md.

+
+ `; + } + return this.renderSelectedDocument(); + } + + private renderSelectedDocument(): string { + const documentPath = this.selectedDocumentPath; + const content = this.documentContent; + if (documentPath === undefined) return `

Select a document.

`; + if (content === undefined) return `

Loading ${escapeHtml(documentName(documentPath))}…

`; + if (content.kind === "unavailable") return renderErrorState("Could not read this document.", content.detail); + if (content.kind === "missing") { + return `
This document no longer exists.

Click Refresh to rescan the relay.

`; + } + if (content.binary) { + return `
Binary file: ${escapeHtml(documentName(documentPath))}

Binary documents have no text preview.

`; + } + const truncation = content.truncated + ? `
This document is truncated — only the beginning is shown.
` + : ""; + if (isMarkdownDocumentPath(documentPath)) { + return `${truncation}
${renderRelayDocumentHtml(content.content)}
`; + } + return `${truncation}
${escapeHtml(content.content)}
`; + } +} + +/** Reload glyph matching the app's own refresh control (AppRefreshControl). */ +function refreshIconSvg(): string { + return ` + + `; +} + +function contextKey(context: WorkspacePanelContext): string { + return `${context.machine.id}:${context.workspace.projectId}:${context.workspace.id}`; +} + +function documentName(path: string): string { + return path.slice(path.lastIndexOf("/") + 1); +} + +function renderEmptyState(): string { + return ` +
+ No relays in this workspace. +

Relay packets live in ${escapeHtml(RELAYS_ROOT)}/<name>/. This workspace has none yet.

+
+ `; +} + +function renderErrorState(message: string, detail: string): string { + return `
${escapeHtml(message)}
${escapeHtml(detail)}
`; +} + +function relaysStyles(): string { + return ` + + `; +} + +function escapeHtml(value: unknown): string { + return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); +} + +function escapeAttr(value: unknown): string { + return escapeHtml(value).replaceAll('"', """); +} diff --git a/pi-web-plugins/relays/vendor/README.md b/pi-web-plugins/relays/vendor/README.md new file mode 100644 index 0000000..276f2e9 --- /dev/null +++ b/pi-web-plugins/relays/vendor/README.md @@ -0,0 +1,24 @@ +# Vendored dependencies + +## marked (`marked.esm.js`) + +- **Source:** `node_modules/marked/lib/marked.esm.js` — marked v18.0.6, the + version the repo pins (`"marked": "^18.0.6"` in the root `package.json`). +- **License:** MIT — copyright (c) 2018-2026 MarkedJS, (c) 2011-2018 + Christopher Jeffrey. The attribution header at the top of `marked.esm.js` is + preserved; see also `node_modules/marked/LICENSE.md`. +- **Why vendored:** bundled plugins load in the browser as standalone ES + modules and cannot resolve bare package specifiers. The plugin therefore + ships the exact marked build the repo already depends on, rather than + hand-rolling a markdown subset or adding a markdown helper to the plugin API. +- **Local modification:** the trailing `//# sourceMappingURL=marked.esm.js.map` + comment was removed because the (much larger) source map is not vendored. + Everything else is byte-identical to the published file. +- **`marked.esm.d.ts`** is a hand-written minimal declaration covering only the + surface `markdownDocument.ts` uses. The plugin build + (`scripts/build-plugins.mjs`) skips `.d.ts` files and copies `.js` assets + verbatim, so the vendored module ships as-is. + +**Updating:** after a `marked` upgrade in the root `package.json`, copy the new +`node_modules/marked/lib/marked.esm.js` here, re-apply the sourceMappingURL +removal, and update this note. diff --git a/pi-web-plugins/relays/vendor/marked.esm.d.ts b/pi-web-plugins/relays/vendor/marked.esm.d.ts new file mode 100644 index 0000000..7d13409 --- /dev/null +++ b/pi-web-plugins/relays/vendor/marked.esm.d.ts @@ -0,0 +1,28 @@ +/** + * Minimal declarations for the vendored marked ESM build (./marked.esm.js). + * Covers only the surface markdownDocument.ts uses; widen it if usage grows. + * The plugin build copies the .js verbatim and skips this file, so these + * declarations exist for tsc and editor tooling only. + */ + +export interface MarkedHtmlToken { + text: string; +} + +export interface MarkedRenderer { + html: (token: MarkedHtmlToken) => string; +} + +export interface MarkedParseOptions { + async?: false; + breaks?: boolean; + gfm?: boolean; + renderer?: MarkedRenderer; +} + +export interface Marked { + Renderer: new () => MarkedRenderer; + parse(source: string, options?: MarkedParseOptions): string; +} + +export const marked: Marked; diff --git a/pi-web-plugins/relays/vendor/marked.esm.js b/pi-web-plugins/relays/vendor/marked.esm.js new file mode 100644 index 0000000..ef13590 --- /dev/null +++ b/pi-web-plugins/relays/vendor/marked.esm.js @@ -0,0 +1,76 @@ +/** + * marked v18.0.6 - a markdown parser + * Copyright (c) 2018-2026, MarkedJS. (MIT License) + * Copyright (c) 2011-2018, Christopher Jeffrey. (MIT License) + * https://github.com/markedjs/marked + */ + +/** + * DO NOT EDIT THIS FILE + * The code in this file is generated from files in ./src/ + */ + +function M(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var T=M();function N(l){T=l}var _={exec:()=>null};function E(l){let e=[];return t=>{let n=Math.max(0,Math.min(3,t-1)),s=e[n];return s||(s=l(n),e[n]=s),s}}function d(l,e=""){let t=typeof l=="string"?l:l.source,n={replace:(s,r)=>{let i=typeof r=="string"?r:r.source;return i=i.replace(m.caret,"$1"),t=t.replace(s,i),n},getRegex:()=>new RegExp(t,e)};return n}var Te=((l="")=>{try{return!!new RegExp("(?<=1)(?/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] +\S/,listReplaceTask:/^\[[ xX]\] +/,listTaskCheckbox:/\[[ xX]\]/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:l=>new RegExp(`^( {0,3}${l})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:E(l=>new RegExp(`^ {0,${l}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`)),hrRegex:E(l=>new RegExp(`^ {0,${l}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`)),fencesBeginRegex:E(l=>new RegExp(`^ {0,${l}}(?:\`\`\`|~~~)`)),headingBeginRegex:E(l=>new RegExp(`^ {0,${l}}#`)),htmlBeginRegex:E(l=>new RegExp(`^ {0,${l}}<(?:[a-z].*>|!--)`,"i")),blockquoteBeginRegex:E(l=>new RegExp(`^ {0,${l}}>`))},Oe=/^(?:[ \t]*(?:\n|$))+/,we=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,ye=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,B=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,Pe=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,j=/ {0,3}(?:[*+-]|\d{1,9}[.)])/,oe=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,ae=d(oe).replace(/bull/g,j).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),Se=d(oe).replace(/bull/g,j).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),F=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,$e=/^[^\n]+/,U=/(?!\s*\])(?:\\[\s\S]|[^\[\]\\])+/,Le=d(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",U).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),_e=d(/^(bull)([ \t][^\n]*?)?(?:\n|$)/).replace(/bull/g,j).getRegex(),H="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",K=/|$))/,ze=d("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>[^\\n]*\\n+|$)|[^\\n]*\\n+|$)|[^\\n]*\\n+|$)|)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",K).replace("tag",H).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),le=l=>d(F).replace("hr",B).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list",l).replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",H).getRegex(),Me=le(/ {0,3}(?:[*+-]|1[.)])[ \t]+[^ \t\n]/),Ee=le(/ {0,3}(?:[*+-]|\d{1,9}[.)])[ \t]+[^ \t\n]/),Ie=d(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",Ee).getRegex(),W={blockquote:Ie,code:we,def:Le,fences:ye,heading:Pe,hr:B,html:ze,lheading:ae,list:_e,newline:Oe,paragraph:Me,table:_,text:$e},se=d("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",B).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)])[ \\t]").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",H).getRegex(),Ae={...W,lheading:Se,table:se,paragraph:d(F).replace("hr",B).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",se).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)])[ \\t]+[^ \\t\\n]").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",H).getRegex()},Ce={...W,html:d(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",K).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:_,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:d(F).replace("hr",B).replace("heading",` *#{1,6} *[^ +]`).replace("lheading",ae).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},Be=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,qe=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,ue=/^( {2,}|\\)\n(?!\s*$)/,De=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\`+)[^`]+\k(?!`))*?\]\((?:\\[\s\S]|[^\\\(\)]|\((?:\\[\s\S]|[^\\\(\)])*\))*\)/).replace("precode-",Te?"(?`+)[^`]+\k(?!`)/).replace("html",/<(?! )[^<>]*?>/).getRegex(),ce=/^(?:\*+(?:((?!\*)punct)|([^\s*]))?)|^_+(?:((?!_)punct)|([^\s_]))?/,Ne=d(ce,"u").replace(/punct/g,I).getRegex(),Qe=d(ce,"u").replace(/punct/g,pe).getRegex(),he="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",je=d(he,"gu").replace(/notPunctSpace/g,X).replace(/punctSpace/g,Z).replace(/punct/g,I).getRegex(),Fe=d(he,"gu").replace(/notPunctSpace/g,Ze).replace(/punctSpace/g,He).replace(/punct/g,pe).getRegex(),Ue=d("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,X).replace(/punctSpace/g,Z).replace(/punct/g,I).getRegex(),Ke=d(/^~~?(?:((?!~)punct)|[^\s~])/,"u").replace(/punct/g,I).getRegex(),We="^[^~]+(?=[^~])|(?!~)punct(~~?)(?=[\\s]|$)|notPunctSpace(~~?)(?!~)(?=punctSpace|$)|(?!~)punctSpace(~~?)(?=notPunctSpace)|[\\s](~~?)(?!~)(?=punct)|(?!~)punct(~~?)(?!~)(?=punct)|notPunctSpace(~~?)(?=notPunctSpace)",Xe=d(We,"gu").replace(/notPunctSpace/g,X).replace(/punctSpace/g,Z).replace(/punct/g,I).getRegex(),Je=d(/\\(punct)/,"gu").replace(/punct/g,I).getRegex(),Ve=d(/^<(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(),Ye=d(K).replace("(?:-->|$)","-->").getRegex(),et=d("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",Ye).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),v=/(?:\[(?:\\[\s\S]|[^\[\]\\])*\]|\\[\s\S]|`+(?!`)[^`]*?`+(?!`)|``+(?=\])|[^\[\]\\`])*?/,tt=d(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]+(?:\n[ \t]*)?|\n[ \t]*)(title))?\s*\)/).replace("label",v).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]+|(?=\))/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),ke=d(/^!?\[(label)\]\[(ref)\]/).replace("label",v).replace("ref",U).getRegex(),de=d(/^!?\[(ref)\](?:\[\])?/).replace("ref",U).getRegex(),nt=d("reflink|nolink(?!\\()","g").replace("reflink",ke).replace("nolink",de).getRegex(),ie=/[hH][tT][tT][pP][sS]?|[fF][tT][pP]/,J={_backpedal:_,anyPunctuation:Je,autolink:Ve,blockSkip:Ge,br:ue,code:qe,del:_,delLDelim:_,delRDelim:_,emStrongLDelim:Ne,emStrongRDelimAst:je,emStrongRDelimUnd:Ue,escape:Be,link:tt,nolink:de,punctuation:ve,reflink:ke,reflinkSearch:nt,tag:et,text:De,url:_},rt={...J,link:d(/^!?\[(label)\]\((.*?)\)/).replace("label",v).getRegex(),reflink:d(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",v).getRegex()},Q={...J,emStrongRDelimAst:Fe,emStrongLDelim:Qe,delLDelim:Ke,delRDelim:Xe,url:d(/^((?:protocol):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/).replace("protocol",ie).replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\[\s\S]|[^\\])*?(?:\\[\s\S]|[^\s~\\]))\1(?=[^~]|$)/,text:d(/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},ge=l=>it[l];function O(l,e){if(e){if(m.escapeTest.test(l))return l.replace(m.escapeReplace,ge)}else if(m.escapeTestNoEncode.test(l))return l.replace(m.escapeReplaceNoEncode,ge);return l}function V(l){try{l=encodeURI(l).replace(m.percentDecode,"%")}catch{return null}return l}function Y(l,e){let t=l.replace(m.findPipe,(r,i,o)=>{let u=!1,a=i;for(;--a>=0&&o[a]==="\\";)u=!u;return u?"|":" |"}),n=t.split(m.splitPipe),s=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),e)if(n.length>e)n.splice(e);else for(;n.length=0&&m.blankLine.test(e[t]);)t--;return e.length-t<=2?l:e.slice(0,t+1).join(` +`)}function fe(l,e){if(l.indexOf(e[1])===-1)return-1;let t=0;for(let n=0;n0?-2:-1}function me(l,e=0){let t=e,n="";for(let s of l)if(s===" "){let r=4-t%4;n+=" ".repeat(r),t+=r}else n+=s,t++;return n}function xe(l,e,t,n,s){let r=e.href,i=e.title||null,o=l[1].replace(s.other.outputLinkReplace,"$1");n.state.inLink=!0;let u={type:l[0].charAt(0)==="!"?"image":"link",raw:t,href:r,title:i,text:o,tokens:n.inlineTokens(o)};return n.state.inLink=!1,u}function ot(l,e,t){let n=l.match(t.other.indentCodeCompensation);if(n===null)return e;let s=n[1];return e.split(` +`).map(r=>{let i=r.match(t.other.beginningSpace);if(i===null)return r;let[o]=i;return o.length>=s.length?r.slice(s.length):r}).join(` +`)}var w=class{options;rules;lexer;constructor(e){this.options=e||T}space(e){let t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)return{type:"space",raw:t[0]}}code(e){let t=this.rules.block.code.exec(e);if(t){let n=this.options.pedantic?t[0]:ee(t[0]),s=n.replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:n,codeBlockStyle:"indented",text:s}}}fences(e){let t=this.rules.block.fences.exec(e);if(t){let n=t[0],s=ot(n,t[3]||"",this.rules);return{type:"code",raw:n,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:s}}}heading(e){let t=this.rules.block.heading.exec(e);if(t){let n=t[2].trim();if(this.rules.other.endingHash.test(n)){let s=$(n,"#");(this.options.pedantic||!s||this.rules.other.endingSpaceChar.test(s))&&(n=s.trim())}return{type:"heading",raw:$(t[0],` +`),depth:t[1].length,text:n,tokens:this.lexer.inline(n)}}}hr(e){let t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:$(t[0],` +`)}}blockquote(e){let t=this.rules.block.blockquote.exec(e);if(t){let n=$(t[0],` +`).split(` +`),s="",r="",i=[];for(;n.length>0;){let o=!1,u=[],a;for(a=0;a1,r={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");let i=this.rules.other.listItemRegex(n),o=!1;for(;e;){let a=!1,c="",p="";if(!(t=i.exec(e))||this.rules.block.hr.test(e))break;c=t[0],e=e.substring(c.length);let k=me(t[2].split(` +`,1)[0],t[1].length),h=e.split(` +`,1)[0],R=!k.trim(),f=0;if(this.options.pedantic?(f=2,p=k.trimStart()):R?f=t[1].length+1:(f=k.search(this.rules.other.nonSpaceChar),f=f>4?1:f,p=k.slice(f),f+=t[1].length),R&&this.rules.other.blankLine.test(h)&&(c+=h+` +`,e=e.substring(h.length+1),a=!0),!a){let S=this.rules.other.nextBulletRegex(f),te=this.rules.other.hrRegex(f),ne=this.rules.other.fencesBeginRegex(f),re=this.rules.other.headingBeginRegex(f),be=this.rules.other.htmlBeginRegex(f),Re=this.rules.other.blockquoteBeginRegex(f);for(;e;){let G=e.split(` +`,1)[0],C;if(h=G,this.options.pedantic?(h=h.replace(this.rules.other.listReplaceNesting," "),C=h):C=h.replace(this.rules.other.tabCharGlobal," "),ne.test(h)||re.test(h)||be.test(h)||Re.test(h)||S.test(h)||te.test(h))break;if(C.search(this.rules.other.nonSpaceChar)>=f||!h.trim())p+=` +`+C.slice(f);else{if(R||k.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||ne.test(k)||re.test(k)||te.test(k))break;p+=` +`+h}R=!h.trim(),c+=G+` +`,e=e.substring(G.length+1),k=C.slice(f)}}r.loose||(o?r.loose=!0:this.rules.other.doubleBlankLine.test(c)&&(o=!0)),r.items.push({type:"list_item",raw:c,task:!!this.options.gfm&&this.rules.other.listIsTask.test(p),loose:!1,text:p,tokens:[]}),r.raw+=c}let u=r.items.at(-1);if(u)u.raw=u.raw.trimEnd(),u.text=u.text.trimEnd();else return;r.raw=r.raw.trimEnd();for(let a of r.items){this.lexer.state.top=!1,a.tokens=this.lexer.blockTokens(a.text,[]);let c=a.tokens[0];if(a.task&&(c?.type==="text"||c?.type==="paragraph")){a.text=a.text.replace(this.rules.other.listReplaceTask,""),c.raw=c.raw.replace(this.rules.other.listReplaceTask,""),c.text=c.text.replace(this.rules.other.listReplaceTask,"");for(let k=this.lexer.inlineQueue.length-1;k>=0;k--)if(this.rules.other.listIsTask.test(this.lexer.inlineQueue[k].src)){this.lexer.inlineQueue[k].src=this.lexer.inlineQueue[k].src.replace(this.rules.other.listReplaceTask,"");break}let p=this.rules.other.listTaskCheckbox.exec(a.raw);if(p){let k={type:"checkbox",raw:p[0]+" ",checked:p[0]!=="[ ]"};a.checked=k.checked,r.loose?a.tokens[0]&&["paragraph","text"].includes(a.tokens[0].type)&&"tokens"in a.tokens[0]&&a.tokens[0].tokens?(a.tokens[0].raw=k.raw+a.tokens[0].raw,a.tokens[0].text=k.raw+a.tokens[0].text,a.tokens[0].tokens.unshift(k)):a.tokens.unshift({type:"paragraph",raw:k.raw,text:k.raw,tokens:[k]}):a.tokens.unshift(k)}}else a.task&&(a.task=!1);if(!r.loose){let p=a.tokens.filter(h=>h.type==="space"),k=p.length>0&&p.some(h=>this.rules.other.anyLine.test(h.raw));r.loose=k}}if(r.loose)for(let a of r.items){a.loose=!0;for(let c of a.tokens)c.type==="text"&&(c.type="paragraph")}return r}}html(e){let t=this.rules.block.html.exec(e);if(t){let n=ee(t[0]);return{type:"html",block:!0,raw:n,pre:t[1]==="pre"||t[1]==="script"||t[1]==="style",text:n}}}def(e){let t=this.rules.block.def.exec(e);if(t){let n=t[1].toLowerCase().replace(this.rules.other.multipleSpaceGlobal," "),s=t[2]?t[2].replace(this.rules.other.hrefBrackets,"$1").replace(this.rules.inline.anyPunctuation,"$1"):"",r=t[3]?t[3].substring(1,t[3].length-1).replace(this.rules.inline.anyPunctuation,"$1"):t[3];return{type:"def",tag:n,raw:$(t[0],` +`),href:s,title:r}}}table(e){let t=this.rules.block.table.exec(e);if(!t||!this.rules.other.tableDelimiter.test(t[2]))return;let n=Y(t[1]),s=t[2].replace(this.rules.other.tableAlignChars,"").split("|"),r=t[3]?.trim()?t[3].replace(this.rules.other.tableRowBlankLine,"").split(` +`):[],i={type:"table",raw:$(t[0],` +`),header:[],align:[],rows:[]};if(n.length===s.length){for(let o of s)this.rules.other.tableAlignRight.test(o)?i.align.push("right"):this.rules.other.tableAlignCenter.test(o)?i.align.push("center"):this.rules.other.tableAlignLeft.test(o)?i.align.push("left"):i.align.push(null);for(let o=0;o({text:u,tokens:this.lexer.inline(u),header:!1,align:i.align[a]})));return i}}lheading(e){let t=this.rules.block.lheading.exec(e);if(t){let n=t[1].trim();return{type:"heading",raw:$(t[0],` +`),depth:t[2].charAt(0)==="="?1:2,text:n,tokens:this.lexer.inline(n)}}}paragraph(e){let t=this.rules.block.paragraph.exec(e);if(t){let n=t[1].charAt(t[1].length-1)===` +`?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:n,tokens:this.lexer.inline(n)}}}text(e){let t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){let t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){let t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){let t=this.rules.inline.link.exec(e);if(t){let n=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(n)){if(!this.rules.other.endAngleBracket.test(n))return;let i=$(n.slice(0,-1),"\\");if((n.length-i.length)%2===0)return}else{let i=fe(t[2],"()");if(i===-2)return;if(i>-1){let u=(t[0].indexOf("!")===0?5:4)+t[1].length+i;t[2]=t[2].substring(0,i),t[0]=t[0].substring(0,u).trim(),t[3]=""}}let s=t[2],r="";if(this.options.pedantic){let i=this.rules.other.pedanticHrefTitle.exec(s);i&&(s=i[1],r=i[3])}else r=t[3]?t[3].slice(1,-1):"";return s=s.trim(),this.rules.other.startAngleBracket.test(s)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(n)?s=s.slice(1):s=s.slice(1,-1)),xe(t,{href:s&&s.replace(this.rules.inline.anyPunctuation,"$1"),title:r&&r.replace(this.rules.inline.anyPunctuation,"$1")},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){let s=(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," "),r=t[s.toLowerCase()];if(!r){let i=n[0].charAt(0);return{type:"text",raw:i,text:i}}return xe(n,r,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s||!s[1]&&!s[2]&&!s[3]&&!s[4]||s[4]&&n.match(this.rules.other.unicodeAlphaNumeric))return;if(!(s[1]||s[3]||"")||!n||this.rules.inline.punctuation.exec(n)){let i=[...s[0]].length-1,o,u,a=i,c=0,p=s[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(p.lastIndex=0,t=t.slice(-1*e.length+i);(s=p.exec(t))!==null;){if(o=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!o)continue;if(u=[...o].length,s[3]||s[4]){a+=u;continue}else if((s[5]||s[6])&&i%3&&!((i+u)%3)){c+=u;continue}if(a-=u,a>0)continue;u=Math.min(u,u+a+c);let k=[...s[0]][0].length,h=e.slice(0,i+s.index+k+u);if(Math.min(i,u)%2){let f=h.slice(1,-1);return{type:"em",raw:h,text:f,tokens:this.lexer.inlineTokens(f)}}let R=h.slice(2,-2);return{type:"strong",raw:h,text:R,tokens:this.lexer.inlineTokens(R)}}}}codespan(e){let t=this.rules.inline.code.exec(e);if(t){let n=t[2].replace(this.rules.other.newLineCharGlobal," "),s=this.rules.other.nonSpaceChar.test(n),r=this.rules.other.startingSpaceChar.test(n)&&this.rules.other.endingSpaceChar.test(n);return s&&r&&(n=n.substring(1,n.length-1)),{type:"codespan",raw:t[0],text:n}}}br(e){let t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e,t,n=""){let s=this.rules.inline.delLDelim.exec(e);if(!s)return;if(!(s[1]||"")||!n||this.rules.inline.punctuation.exec(n)){let i=[...s[0]].length-1,o,u,a=i,c=this.rules.inline.delRDelim;for(c.lastIndex=0,t=t.slice(-1*e.length+i);(s=c.exec(t))!==null;){if(o=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!o||(u=[...o].length,u!==i))continue;if(s[3]||s[4]){a+=u;continue}if(a-=u,a>0)continue;u=Math.min(u,u+a);let p=[...s[0]][0].length,k=e.slice(0,i+s.index+p+u),h=k.slice(i,-i);return{type:"del",raw:k,text:h,tokens:this.lexer.inlineTokens(h)}}}}autolink(e){let t=this.rules.inline.autolink.exec(e);if(t){let n,s;return t[2]==="@"?(n=t[1],s="mailto:"+n):(n=t[1],s=n),{type:"link",raw:t[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let n,s;if(t[2]==="@")n=t[0],s="mailto:"+n;else{let r;do r=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??"";while(r!==t[0]);n=t[0],t[1]==="www."?s="http://"+t[0]:s=t[0]}return{type:"link",raw:t[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}inlineText(e){let t=this.rules.inline.text.exec(e);if(t){let n=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:n}}}};var x=class l{tokens;options;state;inlineQueue;tokenizer;constructor(e){this.tokens=[],this.tokens.links=Object.create(null),this.options=e||T,this.options.tokenizer=this.options.tokenizer||new w,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let t={other:m,block:q.normal,inline:A.normal};this.options.pedantic?(t.block=q.pedantic,t.inline=A.pedantic):this.options.gfm&&(t.block=q.gfm,this.options.breaks?t.inline=A.breaks:t.inline=A.gfm),this.tokenizer.rules=t}static get rules(){return{block:q,inline:A}}static lex(e,t){return new l(t).lex(e)}static lexInline(e,t){return new l(t).inlineTokens(e)}lex(e){e=e.replace(m.carriageReturn,` +`),this.blockTokens(e,this.tokens);for(let t=0;t(r=o.call({lexer:this},e,t))?(e=e.substring(r.raw.length),t.push(r),!0):!1))continue;if(r=this.tokenizer.space(e)){e=e.substring(r.raw.length);let o=t.at(-1);r.raw.length===1&&o!==void 0?o.raw+=` +`:t.push(r);continue}if(r=this.tokenizer.code(e)){e=e.substring(r.raw.length);let o=t.at(-1);o?.type==="paragraph"||o?.type==="text"?(o.raw+=(o.raw.endsWith(` +`)?"":` +`)+r.raw,o.text+=` +`+r.text,this.inlineQueue.at(-1).src=o.text):t.push(r);continue}if(r=this.tokenizer.fences(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.heading(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.hr(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.blockquote(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.list(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.html(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.def(e)){e=e.substring(r.raw.length);let o=t.at(-1);o?.type==="paragraph"||o?.type==="text"?(o.raw+=(o.raw.endsWith(` +`)?"":` +`)+r.raw,o.text+=` +`+r.raw,this.inlineQueue.at(-1).src=o.text):this.tokens.links[r.tag]||(this.tokens.links[r.tag]={href:r.href,title:r.title},t.push(r));continue}if(r=this.tokenizer.table(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.lheading(e)){e=e.substring(r.raw.length),t.push(r);continue}let i=e;if(this.options.extensions?.startBlock){let o=1/0,u=e.slice(1),a;this.options.extensions.startBlock.forEach(c=>{a=c.call({lexer:this},u),typeof a=="number"&&a>=0&&(o=Math.min(o,a))}),o<1/0&&o>=0&&(i=e.substring(0,o+1))}if(this.state.top&&(r=this.tokenizer.paragraph(i))){let o=t.at(-1);n&&o?.type==="paragraph"?(o.raw+=(o.raw.endsWith(` +`)?"":` +`)+r.raw,o.text+=` +`+r.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=o.text):t.push(r),n=i.length!==e.length,e=e.substring(r.raw.length);continue}if(r=this.tokenizer.text(e)){e=e.substring(r.raw.length);let o=t.at(-1);o?.type==="text"?(o.raw+=(o.raw.endsWith(` +`)?"":` +`)+r.raw,o.text+=` +`+r.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=o.text):t.push(r);continue}if(e){this.infiniteLoopError(e.charCodeAt(0));break}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){this.tokenizer.lexer=this;let n=e,s=null;if(this.tokens.links){let a=Object.keys(this.tokens.links);if(a.length>0)for(;(s=this.tokenizer.rules.inline.reflinkSearch.exec(n))!==null;)a.includes(s[0].slice(s[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(s=this.tokenizer.rules.inline.anyPunctuation.exec(n))!==null;)n=n.slice(0,s.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);let r;for(;(s=this.tokenizer.rules.inline.blockSkip.exec(n))!==null;)r=s[2]?s[2].length:0,n=n.slice(0,s.index+r)+"["+"a".repeat(s[0].length-r-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);n=this.options.hooks?.emStrongMask?.call({lexer:this},n)??n;let i=!1,o="",u=1/0;for(;e;){if(e.length(a=p.call({lexer:this},e,t))?(e=e.substring(a.raw.length),t.push(a),!0):!1))continue;if(a=this.tokenizer.escape(e)){e=e.substring(a.raw.length),t.push(a);continue}if(a=this.tokenizer.tag(e)){e=e.substring(a.raw.length),t.push(a);continue}if(a=this.tokenizer.link(e)){e=e.substring(a.raw.length),t.push(a);continue}if(a=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(a.raw.length);let p=t.at(-1);a.type==="text"&&p?.type==="text"?(p.raw+=a.raw,p.text+=a.text):t.push(a);continue}if(a=this.tokenizer.emStrong(e,n,o)){e=e.substring(a.raw.length),t.push(a);continue}if(a=this.tokenizer.codespan(e)){e=e.substring(a.raw.length),t.push(a);continue}if(a=this.tokenizer.br(e)){e=e.substring(a.raw.length),t.push(a);continue}if(a=this.tokenizer.del(e,n,o)){e=e.substring(a.raw.length),t.push(a);continue}if(a=this.tokenizer.autolink(e)){e=e.substring(a.raw.length),t.push(a);continue}if(!this.state.inLink&&(a=this.tokenizer.url(e))){e=e.substring(a.raw.length),t.push(a);continue}let c=e;if(this.options.extensions?.startInline){let p=1/0,k=e.slice(1),h;this.options.extensions.startInline.forEach(R=>{h=R.call({lexer:this},k),typeof h=="number"&&h>=0&&(p=Math.min(p,h))}),p<1/0&&p>=0&&(c=e.substring(0,p+1))}if(a=this.tokenizer.inlineText(c)){e=e.substring(a.raw.length),a.raw.slice(-1)!=="_"&&(o=a.raw.slice(-1)),i=!0;let p=t.at(-1);p?.type==="text"?(p.raw+=a.raw,p.text+=a.text):t.push(a);continue}if(e){this.infiniteLoopError(e.charCodeAt(0));break}}return t}infiniteLoopError(e){let t="Infinite loop on byte: "+e;if(this.options.silent)console.error(t);else throw new Error(t)}};var y=class{options;parser;constructor(e){this.options=e||T}space(e){return""}code({text:e,lang:t,escaped:n}){let s=(t||"").match(m.notSpaceStart)?.[0],r=e.replace(m.endingNewline,"")+` +`;return s?'
'+(n?r:O(r,!0))+`
+`:"
"+(n?r:O(r,!0))+`
+`}blockquote({tokens:e}){return`
+${this.parser.parse(e)}
+`}html({text:e}){return e}def(e){return""}heading({tokens:e,depth:t}){return`${this.parser.parseInline(e)} +`}hr(e){return`
+`}list(e){let t=e.ordered,n=e.start,s="";for(let o=0;o +`+s+" +`}listitem(e){return`
  • ${this.parser.parse(e.tokens)}
  • +`}checkbox({checked:e}){return" '}paragraph({tokens:e}){return`

    ${this.parser.parseInline(e)}

    +`}table(e){let t="",n="";for(let r=0;r${s}`),` + +`+t+` +`+s+`
    +`}tablerow({text:e}){return` +${e} +`}tablecell(e){let t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+` +`}strong({tokens:e}){return`${this.parser.parseInline(e)}`}em({tokens:e}){return`${this.parser.parseInline(e)}`}codespan({text:e}){return`${O(e,!0)}`}br(e){return"
    "}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:n}){let s=this.parser.parseInline(n),r=V(e);if(r===null)return s;e=r;let i='
    ",i}image({href:e,title:t,text:n,tokens:s}){s&&(n=this.parser.parseInline(s,this.parser.textRenderer));let r=V(e);if(r===null)return O(n);e=r;let i=`${O(n)}{let o=r[i].flat(1/0);n=n.concat(this.walkTokens(o,t))}):r.tokens&&(n=n.concat(this.walkTokens(r.tokens,t)))}}return n}use(...e){let t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach(n=>{let s={...n};if(s.async=this.defaults.async||s.async||!1,n.extensions&&(n.extensions.forEach(r=>{if(!r.name)throw new Error("extension name required");if("renderer"in r){let i=t.renderers[r.name];i?t.renderers[r.name]=function(...o){let u=r.renderer.apply(this,o);return u===!1&&(u=i.apply(this,o)),u}:t.renderers[r.name]=r.renderer}if("tokenizer"in r){if(!r.level||r.level!=="block"&&r.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");let i=t[r.level];i?i.unshift(r.tokenizer):t[r.level]=[r.tokenizer],r.start&&(r.level==="block"?t.startBlock?t.startBlock.push(r.start):t.startBlock=[r.start]:r.level==="inline"&&(t.startInline?t.startInline.push(r.start):t.startInline=[r.start]))}"childTokens"in r&&r.childTokens&&(t.childTokens[r.name]=r.childTokens)}),s.extensions=t),n.renderer){let r=this.defaults.renderer||new y(this.defaults);for(let i in n.renderer){if(!(i in r))throw new Error(`renderer '${i}' does not exist`);if(["options","parser"].includes(i))continue;let o=i,u=n.renderer[o],a=r[o];r[o]=(...c)=>{let p=u.apply(r,c);return p===!1&&(p=a.apply(r,c)),p||""}}s.renderer=r}if(n.tokenizer){let r=this.defaults.tokenizer||new w(this.defaults);for(let i in n.tokenizer){if(!(i in r))throw new Error(`tokenizer '${i}' does not exist`);if(["options","rules","lexer"].includes(i))continue;let o=i,u=n.tokenizer[o],a=r[o];r[o]=(...c)=>{let p=u.apply(r,c);return p===!1&&(p=a.apply(r,c)),p}}s.tokenizer=r}if(n.hooks){let r=this.defaults.hooks||new P;for(let i in n.hooks){if(!(i in r))throw new Error(`hook '${i}' does not exist`);if(["options","block"].includes(i))continue;let o=i,u=n.hooks[o],a=r[o];P.passThroughHooks.has(i)?r[o]=c=>{if(this.defaults.async&&P.passThroughHooksRespectAsync.has(i))return(async()=>{let k=await u.call(r,c);return a.call(r,k)})();let p=u.call(r,c);return a.call(r,p)}:r[o]=(...c)=>{if(this.defaults.async)return(async()=>{let k=await u.apply(r,c);return k===!1&&(k=await a.apply(r,c)),k})();let p=u.apply(r,c);return p===!1&&(p=a.apply(r,c)),p}}s.hooks=r}if(n.walkTokens){let r=this.defaults.walkTokens,i=n.walkTokens;s.walkTokens=function(o){let u=[];return u.push(i.call(this,o)),r&&(u=u.concat(r.call(this,o))),u}}this.defaults={...this.defaults,...s}}),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return x.lex(e,t??this.defaults)}parser(e,t){return b.parse(e,t??this.defaults)}parseMarkdown(e){return(n,s)=>{let r={...s},i={...this.defaults,...r},o=this.onError(!!i.silent,!!i.async);if(this.defaults.async===!0&&r.async===!1)return o(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof n>"u"||n===null)return o(new Error("marked(): input parameter is undefined or null"));if(typeof n!="string")return o(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));if(i.hooks&&(i.hooks.options=i,i.hooks.block=e),i.async)return(async()=>{let u=i.hooks?await i.hooks.preprocess(n):n,c=await(i.hooks?await i.hooks.provideLexer(e):e?x.lex:x.lexInline)(u,i),p=i.hooks?await i.hooks.processAllTokens(c):c;i.walkTokens&&await Promise.all(this.walkTokens(p,i.walkTokens));let h=await(i.hooks?await i.hooks.provideParser(e):e?b.parse:b.parseInline)(p,i);return i.hooks?await i.hooks.postprocess(h):h})().catch(o);try{i.hooks&&(n=i.hooks.preprocess(n));let a=(i.hooks?i.hooks.provideLexer(e):e?x.lex:x.lexInline)(n,i);i.hooks&&(a=i.hooks.processAllTokens(a)),i.walkTokens&&this.walkTokens(a,i.walkTokens);let p=(i.hooks?i.hooks.provideParser(e):e?b.parse:b.parseInline)(a,i);return i.hooks&&(p=i.hooks.postprocess(p)),p}catch(u){return o(u)}}}onError(e,t){return n=>{if(n.message+=` +Please report this to https://github.com/markedjs/marked.`,e){let s="

    An error occurred:

    "+O(n.message+"",!0)+"
    ";return t?Promise.resolve(s):s}if(t)return Promise.reject(n);throw n}}};var z=new D;function g(l,e){return z.parse(l,e)}g.options=g.setOptions=function(l){return z.setOptions(l),g.defaults=z.defaults,N(g.defaults),g};g.getDefaults=M;g.defaults=T;g.use=function(...l){return z.use(...l),g.defaults=z.defaults,N(g.defaults),g};g.walkTokens=function(l,e){return z.walkTokens(l,e)};g.parseInline=z.parseInline;g.Parser=b;g.parser=b.parse;g.Renderer=y;g.TextRenderer=L;g.Lexer=x;g.lexer=x.lex;g.Tokenizer=w;g.Hooks=P;g.parse=g;var Kt=g.options,Wt=g.setOptions,Xt=g.use,Jt=g.walkTokens,Vt=g.parseInline,Yt=g,en=b.parse,tn=x.lex;export{P as Hooks,x as Lexer,D as Marked,b as Parser,y as Renderer,L as TextRenderer,w as Tokenizer,T as defaults,M as getDefaults,tn as lexer,g as marked,Kt as options,Yt as parse,Vt as parseInline,en as parser,Wt as setOptions,Xt as use,Jt as walkTokens};