import { css, html } from "lit";
import { property } from "lit/decorators.js";
import { consume } from "@lit/context";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { classMap } from "lit/directives/class-map.js";
import { marked } from "marked";
import { BaseElement } from "../../core/base-element.ts";
import "../ct-copy-button/ct-copy-button.ts";
import "../ct-cell-link/ct-cell-link.ts";
import {
applyThemeToElement,
type CTTheme,
themeContext,
} from "../theme-context.ts";
import { type Cell, isCell } from "@commontools/runner";
export type MarkdownVariant = "default" | "inverse";
/**
* CTMarkdown - Renders markdown content with syntax highlighting and copy buttons
*
* @element ct-markdown
*
* @attr {string} content - The markdown content to render (string or Cell)
* @attr {string} variant - Visual variant: "default" or "inverse" (for light text on dark bg)
* @attr {boolean} streaming - Shows a blinking cursor at the end (for streaming content)
* @attr {boolean} compact - Reduces paragraph spacing for more compact display
*
* @csspart content - The markdown content wrapper
*
* @cssprop [--ct-markdown-inverse-border=rgba(255,255,255,0.3)] - Border color for inverse variant
* @cssprop [--ct-markdown-inverse-surface=rgba(255,255,255,0.2)] - Surface color for inverse variant (code blocks)
* @cssprop [--ct-markdown-inverse-surface-subtle=rgba(255,255,255,0.1)] - Subtle surface for inverse (table headers)
* @cssprop [--ct-markdown-inverse-accent=rgba(255,255,255,0.6)] - Accent color for inverse (blockquote border)
*
* @example
*
*
* @example
*
*
* @example
*
*
* @example
*
*/
export class CTMarkdown extends BaseElement {
static override styles = [
BaseElement.baseStyles,
css`
:host {
display: block;
box-sizing: border-box;
font-family: var(
--ct-theme-font-family,
system-ui,
-apple-system,
sans-serif
);
line-height: 1.6;
color: var(--ct-theme-color-text, var(--ct-color-gray-900, #111827));
}
*,
*::before,
*::after {
box-sizing: inherit;
}
.markdown-content {
word-wrap: break-word;
}
/* Streaming cursor */
.markdown-content.streaming::after {
content: "▊";
animation: blink 1s infinite;
margin-left: 2px;
color: currentColor;
}
@keyframes blink {
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
/* Headings */
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
margin-top: 1.5em;
margin-bottom: 0.5em;
font-weight: 600;
line-height: 1.25;
}
.markdown-content h1:first-child,
.markdown-content h2:first-child,
.markdown-content h3:first-child,
.markdown-content h4:first-child,
.markdown-content h5:first-child,
.markdown-content h6:first-child {
margin-top: 0;
}
.markdown-content h1 {
font-size: 2em;
border-bottom: 1px solid var(--ct-theme-color-border, #e5e7eb);
padding-bottom: 0.3em;
}
.markdown-content h2 {
font-size: 1.5em;
border-bottom: 1px solid var(--ct-theme-color-border, #e5e7eb);
padding-bottom: 0.3em;
}
.markdown-content h3 {
font-size: 1.25em;
}
.markdown-content h4 {
font-size: 1em;
}
.markdown-content h5 {
font-size: 0.875em;
}
.markdown-content h6 {
font-size: 0.85em;
color: var(--ct-theme-color-text-muted, #6b7280);
}
/* Inverse variant heading adjustments */
.markdown-content.inverse h1,
.markdown-content.inverse h2 {
border-bottom-color: var(
--ct-markdown-inverse-border,
rgba(255, 255, 255, 0.3)
);
}
.markdown-content.inverse h6 {
color: inherit;
opacity: 0.8;
}
/* Paragraphs */
.markdown-content p {
margin: 0;
}
.markdown-content p:not(:last-child) {
margin-bottom: var(--ct-theme-spacing, var(--ct-spacing-3, 0.75rem));
}
/* Compact mode paragraph spacing */
.markdown-content.compact p:not(:last-child) {
margin-bottom: var(
--ct-theme-spacing-compact,
var(--ct-spacing-1, 0.25rem)
);
}
/* Links */
.markdown-content a {
color: var(
--ct-theme-color-accent,
var(--ct-color-blue-500, #3b82f6)
);
text-decoration: none;
}
.markdown-content a:hover {
text-decoration: underline;
}
/* Inverse variant links */
.markdown-content.inverse a {
color: inherit;
text-decoration: underline;
opacity: 0.9;
}
/* Lists */
.markdown-content ul,
.markdown-content ol {
margin: var(--ct-theme-spacing, var(--ct-spacing-3, 0.75rem)) 0;
padding-left: 2em;
}
.markdown-content li {
margin-bottom: 0.25em;
}
.markdown-content li > ul,
.markdown-content li > ol {
margin: 0.25em 0;
}
/* Inline code */
.markdown-content code {
background-color: var(--ct-theme-color-surface, #f9fafb);
padding: 0.2em 0.4em;
border-radius: var(--ct-theme-border-radius, 0.375rem);
font-family: var(--ct-theme-mono-font-family, ui-monospace, monospace);
font-size: 0.875em;
}
/* Inverse variant inline code */
.markdown-content.inverse code {
background-color: var(
--ct-markdown-inverse-surface,
rgba(255, 255, 255, 0.2)
);
color: inherit;
}
/* Code blocks */
.markdown-content pre {
background-color: var(--ct-theme-color-surface, #f9fafb);
padding: var(--ct-theme-padding-block, var(--ct-spacing-3, 0.75rem));
border-radius: var(--ct-theme-border-radius, 0.5rem);
border: 1px solid var(--ct-theme-color-border, #e5e7eb);
overflow-x: auto;
margin: var(--ct-theme-spacing, var(--ct-spacing-3, 0.75rem)) 0;
}
.markdown-content pre code {
background-color: transparent;
padding: 0;
font-size: 0.875em;
}
/* Inverse variant code blocks */
.markdown-content.inverse pre {
background-color: var(
--ct-markdown-inverse-surface,
rgba(255, 255, 255, 0.2)
);
border: none;
}
.markdown-content.inverse pre code {
color: inherit;
}
/* Code block container with copy button */
.code-block-container {
position: relative;
}
.code-copy-button {
position: absolute;
top: var(--ct-theme-spacing-normal, var(--ct-spacing-2, 0.5rem));
right: var(--ct-theme-spacing-normal, var(--ct-spacing-2, 0.5rem));
opacity: 0;
transition: opacity var(--ct-theme-animation-duration, 0.2s) ease;
z-index: 1;
}
.code-block-container:hover .code-copy-button {
opacity: 1;
}
/* Blockquotes */
.markdown-content blockquote {
border-left: 4px solid var(--ct-theme-color-border, #e5e7eb);
margin: var(--ct-theme-spacing, var(--ct-spacing-3, 0.75rem)) 0;
padding-left: var(--ct-theme-padding, var(--ct-spacing-3, 0.75rem));
font-style: italic;
color: var(--ct-theme-color-text-muted, #6b7280);
}
.markdown-content blockquote p:last-child {
margin-bottom: 0;
}
/* Inverse variant blockquotes */
.markdown-content.inverse blockquote {
border-left-color: var(
--ct-markdown-inverse-accent,
rgba(255, 255, 255, 0.6)
);
color: inherit;
opacity: 0.9;
}
/* Horizontal rules */
.markdown-content hr {
border: none;
border-top: 1px solid var(--ct-theme-color-border, #e5e7eb);
margin: 1.5em 0;
}
.markdown-content.inverse hr {
border-top-color: var(
--ct-markdown-inverse-border,
rgba(255, 255, 255, 0.3)
);
}
/* Tables */
.markdown-content table {
border-collapse: collapse;
width: 100%;
margin: var(--ct-theme-spacing, var(--ct-spacing-3, 0.75rem)) 0;
}
.markdown-content th,
.markdown-content td {
border: 1px solid var(--ct-theme-color-border, #e5e7eb);
padding: 0.5em 1em;
text-align: left;
}
.markdown-content th {
background-color: var(--ct-theme-color-surface, #f9fafb);
font-weight: 600;
}
/* Inverse variant tables */
.markdown-content.inverse th,
.markdown-content.inverse td {
border-color: var(
--ct-markdown-inverse-border,
rgba(255, 255, 255, 0.3)
);
}
.markdown-content.inverse th {
background-color: var(
--ct-markdown-inverse-surface-subtle,
rgba(255, 255, 255, 0.1)
);
}
/* Images */
.markdown-content img {
max-width: 100%;
height: auto;
border-radius: var(--ct-theme-border-radius, 0.5rem);
}
/* Strong and emphasis */
.markdown-content strong {
font-weight: 600;
}
.markdown-content em {
font-style: italic;
}
/* Task lists */
.markdown-content input[type="checkbox"] {
margin-right: 0.5em;
}
`,
];
@property({ attribute: false })
declare content: Cell | string;
@property({ type: String, reflect: true })
declare variant: MarkdownVariant;
@property({ type: Boolean, reflect: true })
declare streaming: boolean;
@property({ type: Boolean, reflect: true })
declare compact: boolean;
@consume({ context: themeContext, subscribe: true })
@property({ attribute: false })
declare theme?: CTTheme;
private _unsubscribe: (() => void) | null = null;
constructor() {
super();
this.content = "";
this.variant = "default";
this.streaming = false;
this.compact = false;
}
private _getContentValue(): string {
if (isCell(this.content)) {
return this.content.get() ?? "";
}
return this.content ?? "";
}
private _renderMarkdown(content: string): string {
if (!content) return "";
// Use marked.parse with options to avoid mutating global state
let renderedHtml = marked.parse(content, {
breaks: true,
gfm: true,
}) as string;
// Wrap code blocks with copy buttons
renderedHtml = this._wrapCodeBlocksWithCopyButtons(renderedHtml);
// Replace cell links with ct-cell-link
renderedHtml = this._replaceCellLinks(renderedHtml);
// TODO(CT-1088): XSS VULNERABILITY - This component uses unsafeHTML without sanitization!
//
// We need to sanitize the HTML to prevent XSS attacks. Originally we used DOMPurify
// but it added a dependency (isomorphic-dompurify) that caused lockfile issues.
//
// Options to fix this:
// 1. Add DOMPurify back with proper lockfile management
// 2. Implement our own sanitizer that allows our custom elements (ct-cell-link, ct-copy-button)
// 3. Find an alternative sanitization library
//
// For now, only use this component with trusted markdown content!
//
// Security note: The _escapeForAttribute() method helps prevent attribute injection,
// but this doesn't protect against