feat(desktop): embed BoosAPI Desktop client as a subproject

Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as
a monorepo subdirectory under desktop/. Source only — node_modules,
dist, Cargo target and the upstream .git history are excluded.

desktop/ keeps its own independent Cargo workspace (desktop/src-tauri
with crates/*); it is not part of the backend boosapi crate, so a
`cargo check` at the metapi root still compiles only the backend. Build
the desktop app from desktop/src-tauri per desktop/README.md.

Note: app icons are still placeholder assets pending the final BoosAPI
logo. The updater signing keys live outside the repo and are not
included.
This commit is contained in:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
@@ -0,0 +1,108 @@
# markstream-angular
Angular version of Markstream: a streaming-friendly Markdown renderer with KaTeX, Mermaid, Monaco code blocks, D2, infographic blocks, custom HTML tags, and cross-framework playground parity.
## Install
```bash
pnpm add markstream-angular @angular/core @angular/common
```
Optional peer dependencies:
- `stream-monaco` for Monaco-powered code blocks
- `mermaid` for Mermaid diagrams
- `katex` for math rendering
- `@terrastruct/d2` for D2 diagrams
- `@antv/infographic` for infographic blocks
Example:
```bash
pnpm add stream-monaco mermaid katex @terrastruct/d2 @antv/infographic
```
## Quick Start
Import the stylesheet once in your Angular app entry:
```ts
import 'markstream-angular/index.css'
import 'katex/dist/katex.min.css'
```
Use the standalone component directly:
```ts
import { Component, signal } from '@angular/core'
import { bootstrapApplication } from '@angular/platform-browser'
import { MarkstreamAngularComponent } from 'markstream-angular'
@Component({
selector: 'app-root',
standalone: true,
imports: [MarkstreamAngularComponent],
template: `
<markstream-angular
[content]="markdown()"
[final]="true"
[codeBlockStream]="true"
/>
`,
})
class AppComponent {
readonly markdown = signal(`# Hello Angular
- streaming markdown
- code blocks
- Mermaid / KaTeX / D2`)
}
bootstrapApplication(AppComponent)
```
## TypeScript
`markstream-angular` exports the same public props/context helpers you use at runtime:
```ts
import type {
AngularRenderContext,
CodeBlockMonacoOptions,
CustomComponentMap,
MarkstreamAngularComponentProps,
NodeRendererProps,
} from 'markstream-angular'
```
## Workers
KaTeX and Mermaid can use the same off-thread worker path as the React/Vue packages:
```ts
import { setKaTeXWorker, setMermaidWorker } from 'markstream-angular'
import KatexWorker from 'markstream-angular/workers/katexRenderer.worker?worker'
import MermaidWorker from 'markstream-angular/workers/mermaidParser.worker?worker'
setKaTeXWorker(new KatexWorker())
setMermaidWorker(new MermaidWorker())
```
## Playground
In this monorepo:
- Angular playground: `pnpm play:angular`
- Angular regression lab: `http://127.0.0.1:4175/test`
- Angular version sandbox: `http://127.0.0.1:4175/test-sandbox`
## Status
This package is still tagged `alpha`, but the current direction is already aligned with `markstream-react` / `markstream-vue2` for:
- node-component renderer structure
- streaming code block behavior
- shared `/test` fixtures and cross-framework comparison
- KaTeX / Mermaid worker integration
Issue tracker and source: [Simon-He95/markstream-vue](https://github.com/Simon-He95/markstream-vue)
@@ -0,0 +1,88 @@
{
"name": "markstream-angular",
"type": "module",
"version": "0.0.2-beta.7",
"description": "Experimental Angular Markdown renderer for Markstream, built on stream-markdown AST and a stable HTML baseline renderer.",
"author": "Simon He",
"license": "MIT",
"homepage": "https://github.com/Simon-He95/markstream-vue#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/Simon-He95/markstream-vue.git",
"directory": "packages/markstream-angular"
},
"bugs": {
"url": "https://github.com/Simon-He95/markstream-vue/issues"
},
"keywords": [
"angular",
"markdown",
"markdown-renderer",
"streaming-markdown",
"markstream",
"markstream-angular"
],
"sideEffects": [
"**/*.css"
],
"exports": {
".": {
"types": "./dist/types/index.d.ts",
"import": "./dist/index.js"
},
"./index.css": "./dist/index.css",
"./workers/katexRenderer.worker": "./dist/workers/katexRenderer.worker.js",
"./workers/mermaidParser.worker": "./dist/workers/mermaidParser.worker.js"
},
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/types/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "vite build --mode npm",
"dev": "vite dev",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"prepublishOnly": "pnpm run build",
"release": "bumpp --commit --no-tag --no-push && pnpm publish --access public && node ../../scripts/tag-package.mjs --package-json package.json --push"
},
"peerDependencies": {
"@angular/common": ">=20",
"@angular/core": ">=20",
"@antv/infographic": "^0.2.3",
"@terrastruct/d2": ">=0.1.33",
"katex": ">=0.16.22",
"mermaid": ">=11",
"stream-monaco": ">=0.0.40"
},
"peerDependenciesMeta": {
"@antv/infographic": {
"optional": true
},
"@terrastruct/d2": {
"optional": true
},
"katex": {
"optional": true
},
"mermaid": {
"optional": true
},
"stream-monaco": {
"optional": true
}
},
"dependencies": {
"@floating-ui/dom": "^1.7.6",
"stream-markdown-parser": "workspace:*"
},
"devDependencies": {
"@types/node": "^18.19.130",
"rollup": "^3.30.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-dts": "^4.5.4"
}
}
@@ -0,0 +1,5 @@
module.exports = {
plugins: {
autoprefixer: {},
},
}
@@ -0,0 +1,49 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { capitalize, getNodeList, getString, sanitizeClassToken } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-admonition-node',
standalone: true,
imports: [CommonModule, forwardRef(() => NestedRendererComponent)],
template: `
<div class="markstream-nested-admonition" [class]="kindClass">
<div class="markstream-nested-admonition__title">{{ resolvedTitle }}</div>
<div class="markstream-nested-admonition__content">
<markstream-angular-nested-renderer
[nodes]="children"
[context]="context"
[indexPrefix]="nestedPrefix"
/>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AdmonitionNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get kind() {
return sanitizeClassToken(getString((this.node as any)?.kind || 'note')) || 'note'
}
get kindClass() {
return `markstream-nested-admonition--${this.kind}`
}
get resolvedTitle() {
return getString((this.node as any)?.title) || capitalize(this.kind)
}
get children() {
return getNodeList((this.node as any)?.children)
}
get nestedPrefix() {
return `${this.indexKey || 'admonition'}-children`
}
}
@@ -0,0 +1,46 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { getNodeList } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-blockquote-node',
standalone: true,
imports: [CommonModule, forwardRef(() => NestedRendererComponent)],
template: `
<blockquote class="blockquote-node" dir="auto" [attr.cite]="cite">
<markstream-angular-nested-renderer
[nodes]="children"
[context]="context"
[indexPrefix]="nestedPrefix"
/>
</blockquote>
`,
styles: [`
.blockquote-node :where(.markstream-angular) {
content-visibility: visible;
contain: content;
contain-intrinsic-size: 0 0;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BlockquoteNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get children() {
return getNodeList((this.node as any)?.children)
}
get cite() {
const value = (this.node as any)?.cite
return typeof value === 'string' && value.trim() ? value : null
}
get nestedPrefix() {
return `${this.indexKey || 'blockquote'}-quote`
}
}
@@ -0,0 +1,16 @@
import type { AngularRenderableNode } from '../shared/node-helpers'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
@Component({
selector: 'markstream-angular-checkbox-node',
standalone: true,
template: '<input type="checkbox" disabled [checked]="checked">',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CheckboxNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
get checked() {
return !!(this.node as any)?.checked
}
}
@@ -0,0 +1,824 @@
import type { AfterViewInit, ElementRef, OnChanges, OnDestroy } from '@angular/core'
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
inject,
Input,
ViewChild,
} from '@angular/core'
import { useSafeI18n } from '../../i18n/useSafeI18n'
import { getUseMonaco } from '../../optional/monaco'
import { getLanguageIcon, languageMap, normalizeLanguageIdentifier, resolveMonacoLanguageId } from '../../utils/languageIcon'
import { PreCodeNodeComponent } from '../PreCodeNode/PreCodeNode.component'
import { getString } from '../shared/node-helpers'
import { HtmlPreviewFrameComponent } from './HtmlPreviewFrame.component'
interface MonacoHelpers {
createEditor?: (container: HTMLElement, code: string, language: string) => Promise<unknown> | unknown
createDiffEditor?: (container: HTMLElement, original: string, modified: string, language: string) => Promise<unknown> | unknown
updateCode?: (code: string, language?: string) => Promise<unknown> | unknown
updateDiff?: (original: string, modified: string, language?: string) => Promise<unknown> | unknown
cleanupEditor?: () => void
safeClean?: () => void
getEditorView?: () => any
getDiffEditorView?: () => any
setTheme?: (theme?: string) => Promise<unknown> | unknown
}
@Component({
selector: 'markstream-angular-code-block-node',
standalone: true,
imports: [CommonModule, PreCodeNodeComponent, HtmlPreviewFrameComponent],
template: `
<div
class="code-block-container"
[class.is-dark]="resolvedIsDark"
[class.is-plain-text]="isPlainTextLanguage"
[class.is-rendering]="resolvedLoading"
[attr.data-markstream-monaco]="editorReady && !useFallback ? '1' : null"
[attr.data-markstream-monaco-diff]="editorReady && isDiff && !useFallback ? '1' : null"
[ngStyle]="containerStyle"
>
<div
*ngIf="resolvedShowHeader"
class="code-block-header"
[style.color]="headerForeground"
[style.backgroundColor]="headerBackground"
>
<div class="code-block-header__meta">
<span class="icon-slot code-block-language-icon">
<img class="code-block-language-icon__image" [src]="languageIconDataUrl" alt="" />
</span>
<span class="code-block-header__label">{{ displayLanguage }}</span>
</div>
<div class="code-block-header__actions">
<button
*ngIf="resolvedShowCollapseButton"
type="button"
class="code-action-btn"
[attr.title]="tooltipFor(collapsed ? t('common.expand') : t('common.collapse'))"
[attr.aria-pressed]="collapsed"
(click)="toggleCollapsed()"
>
<svg
[style.rotate]="collapsed ? '0deg' : '90deg'"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
width="1em"
height="1em"
viewBox="0 0 24 24"
class="code-action-btn__icon"
>
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m9 18l6-6l-6-6" />
</svg>
</button>
<ng-container *ngIf="resolvedShowFontSizeButtons && resolvedEnableFontSizeControl">
<button
type="button"
class="code-action-btn"
[disabled]="fontSize <= 10"
[attr.title]="tooltipFor(t('common.decrease'))"
(click)="changeFontSize(-1)"
>
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="code-action-btn__icon">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14" />
</svg>
</button>
<button
type="button"
class="code-action-btn"
[disabled]="fontSize === defaultFontSize"
[attr.title]="tooltipFor(t('common.reset'))"
(click)="resetFontSize()"
>
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="code-action-btn__icon">
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
<path d="M3 12a9 9 0 1 0 9-9a9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</g>
</svg>
</button>
<button
type="button"
class="code-action-btn"
[disabled]="fontSize >= 36"
[attr.title]="tooltipFor(t('common.increase'))"
(click)="changeFontSize(1)"
>
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="code-action-btn__icon">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14m-7-7v14" />
</svg>
</button>
</ng-container>
<button
*ngIf="resolvedShowCopyButton"
type="button"
class="code-action-btn"
[attr.title]="tooltipFor(copied ? t('common.copied') : t('common.copy'))"
[attr.aria-label]="copied ? t('common.copied') : t('common.copy')"
(click)="copyCode()"
>
<svg *ngIf="!copied" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="code-action-btn__icon">
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</g>
</svg>
<svg *ngIf="copied" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="code-action-btn__icon">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 6L9 17l-5-5" />
</svg>
</button>
<button
*ngIf="resolvedShowExpandButton"
type="button"
class="code-action-btn"
[attr.title]="tooltipFor(expanded ? t('common.collapse') : t('common.expand'))"
[attr.aria-pressed]="expanded"
(click)="toggleExpanded()"
>
<svg *ngIf="expanded" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="code-action-btn__icon">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m14 10l7-7m-1 7h-6V4M3 21l7-7m-6 0h6v6" />
</svg>
<svg *ngIf="!expanded" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="code-action-btn__icon">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 3h6v6m0-6l-7 7M3 21l7-7m-1 7H3v-6" />
</svg>
</button>
<button
*ngIf="resolvedShowPreviewButton && isPreviewable"
type="button"
class="code-action-btn"
[attr.title]="tooltipFor(t('common.preview'))"
[attr.aria-label]="t('common.preview')"
(click)="previewCode()"
>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" aria-hidden="true" class="code-action-btn__icon">
<g fill="currentColor" fill-rule="evenodd" clip-rule="evenodd">
<path d="M23.628 7.41c-.12-1.172-.08-3.583-.9-4.233c-1.921-1.51-6.143-1.11-8.815-1.19c-3.481-.15-7.193.14-10.625.24a.34.34 0 0 0 0 .67c3.472-.05 7.074-.29 10.575-.09c2.471.15 6.653-.14 8.254 1.16c.4.33.41 2.732.49 3.582a42 42 0 0 1 .08 9.005a13.8 13.8 0 0 1-.45 3.001c-2.42 1.4-19.69 2.381-20.72.55a21 21 0 0 1-.65-4.632a41.5 41.5 0 0 1 .12-7.964c.08 0 7.334.33 12.586.24c2.331 0 4.682-.13 6.764-.21a.33.33 0 0 0 0-.66c-7.714-.16-12.897-.43-19.31.05c.11-1.38.48-3.922.38-4.002a.3.3 0 0 0-.42 0c-.37.41-.29 1.77-.36 2.251s-.14 1.07-.2 1.6a45 45 0 0 0-.36 8.645a21.8 21.8 0 0 0 .66 5.002c1.46 2.702 17.248 1.461 20.95.43c1.45-.4 1.69-.8 1.871-1.95c.575-3.809.602-7.68.08-11.496" />
<path d="M4.528 5.237a.84.84 0 0 0-.21-1c-.77-.41-1.71.39-1 1.1a.83.83 0 0 0 1.21-.1m2.632-.25c.14-.14.19-.84-.2-1c-.77-.41-1.71.39-1 1.09a.82.82 0 0 0 1.2-.09m2.88 0a.83.83 0 0 0-.21-1c-.77-.41-1.71.39-1 1.09a.82.82 0 0 0 1.21-.09m-4.29 8.735c0 .08.23 2.471.31 2.561a.371.371 0 0 0 .63-.14c0-.09 0 0 .15-1.72a10 10 0 0 0-.11-2.232a5.3 5.3 0 0 1-.26-1.37a.3.3 0 0 0-.54-.24a6.8 6.8 0 0 0-.2 2.33c-1.281-.38-1.121.13-1.131-.42a15 15 0 0 0-.19-1.93c-.16-.17-.36-.17-.51.14a20 20 0 0 0-.43 3.471c.04.773.18 1.536.42 2.272c.26.4.7.22.7-.1c0-.09-.16-.09 0-1.862c.06-1.18-.23-.3 1.16-.76m5.033-2.552c.32-.07.41-.28.39-.37c0-.55-3.322-.34-3.462-.24s-.2.18-.18.28s0 .11 0 .16a3.8 3.8 0 0 0 1.591.361v.82a15 15 0 0 0-.13 3.132c0 .2-.09.94.17 1.16a.34.34 0 0 0 .48 0c.125-.35.196-.718.21-1.09a8 8 0 0 0 .14-3.232c0-.13.05-.7-.1-.89a8 8 0 0 0 .89-.09m5.544-.181a.69.69 0 0 0-.89-.44a2.8 2.8 0 0 0-1.252 1.001a2.3 2.3 0 0 0-.41-.83a1 1 0 0 0-1.6.27a7 7 0 0 0-.35 2.07c0 .571 0 2.642.06 2.762c.14 1.09 1 .51.63.13a17.6 17.6 0 0 1 .38-3.962c.32-1.18.32.2.39.51s.11 1.081.73 1.081s.48-.93 1.401-1.78q.075 1.345 0 2.69a15 15 0 0 0 0 1.811a.34.34 0 0 0 .68 0q.112-.861.11-1.73a16.7 16.7 0 0 0 .12-3.582m1.441-.201c-.05.16-.3 3.002-.31 3.202a6.3 6.3 0 0 0 .21 1.741c.33 1 1.21 1.07 2.291.82a3.7 3.7 0 0 0 1.14-.23c.21-.22.10-.59-.41-.64q-.817.096-1.64.07c-.44-.07-.34 0-.67-4.442q.015-.185 0-.37a.316.316 0 0 0-.23-.38a.316.316 0 0 0-.38.23" />
</g>
</svg>
</button>
</div>
</div>
<div
class="code-block-body"
[class.code-block-body--collapsed]="collapsed"
[class.code-block-body--expanded]="expanded"
>
<ng-container *ngIf="!showLoadingPlaceholder; else loadingTpl">
<markstream-angular-pre-code-node *ngIf="useFallback; else editorTpl" [node]="node" />
<ng-template #editorTpl>
<div #editorHost class="code-editor-container" [style.visibility]="editorReady ? 'visible' : 'hidden'"></div>
<div *ngIf="!editorReady" style="position:absolute; inset:0; overflow:auto; padding:1rem;">
<pre class="code-fallback-plain m-0" [attr.aria-busy]="resolvedLoading" [attr.aria-label]="ariaLabel" tabindex="0"><code translate="no" [style.fontSize.px]="fontSize" [textContent]="resolvedCode"></code></pre>
</div>
</ng-template>
</ng-container>
</div>
<markstream-angular-html-preview-frame
*ngIf="inlinePreviewOpen && isPreviewable"
[code]="resolvedCode"
[isDark]="resolvedIsDark"
[title]="previewTitle"
[onClose]="closeInlinePreview"
/>
<span class="sr-only" aria-live="polite" role="status">{{ copied ? t('common.copied') : '' }}</span>
</div>
<ng-template #loadingTpl>
<div class="code-loading-placeholder">
<div class="loading-skeleton">
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
</div>
</div>
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CodeBlockNodeComponent implements AfterViewInit, OnChanges, OnDestroy {
private readonly cdr = inject(ChangeDetectorRef)
private readonly i18n = useSafeI18n()
@ViewChild('editorHost') private editorHost?: ElementRef<HTMLElement>
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() props?: Record<string, any>
useFallback = false
copied = false
collapsed = false
expanded = false
inlinePreviewOpen = false
editorReady = false
defaultFontSize = 14
fontSize = 14
private helpers: MonacoHelpers | null = null
private createPromise: Promise<void> | null = null
private syncPromise: Promise<void> | null = null
private editorKind: 'single' | 'diff' | null = null
private viewReady = false
private destroyed = false
private copyTimer: number | null = null
private deferredHeightSyncRaf: number | null = null
get t() {
return this.i18n.t
}
get mergedProps() {
return {
...(this.context?.codeBlockProps || {}),
...(this.props || {}),
}
}
get resolvedLoading() {
const explicit = this.mergedProps.loading
if (typeof explicit === 'boolean')
return explicit
return (this.node as any)?.loading !== false
}
get resolvedStream() {
const explicit = this.mergedProps.stream
if (typeof explicit === 'boolean')
return explicit
if (typeof this.context?.codeBlockStream === 'boolean')
return this.context.codeBlockStream
return true
}
get resolvedIsDark() {
const explicit = this.mergedProps.isDark
if (typeof explicit === 'boolean')
return explicit
return this.context?.isDark === true
}
get resolvedDarkTheme() {
return this.mergedProps.darkTheme ?? this.context?.codeBlockThemes?.darkTheme ?? 'vitesse-dark'
}
get resolvedLightTheme() {
return this.mergedProps.lightTheme ?? this.context?.codeBlockThemes?.lightTheme ?? 'vitesse-light'
}
get resolvedThemes() {
return this.mergedProps.themes ?? this.context?.codeBlockThemes?.themes ?? ['vitesse-dark', 'vitesse-light']
}
get resolvedMonacoOptions() {
return {
...(this.context?.codeBlockThemes?.monacoOptions || {}),
...(this.mergedProps.monacoOptions || {}),
}
}
get resolvedEnableFontSizeControl() {
if (typeof this.mergedProps.enableFontSizeControl === 'boolean')
return this.mergedProps.enableFontSizeControl
return true
}
get resolvedShowHeader() {
return this.mergedProps.showHeader !== false
}
get resolvedShowCopyButton() {
return this.mergedProps.showCopyButton !== false
}
get resolvedShowExpandButton() {
return this.mergedProps.showExpandButton !== false
}
get resolvedShowPreviewButton() {
return this.mergedProps.showPreviewButton !== false
}
get resolvedShowCollapseButton() {
return this.mergedProps.showCollapseButton !== false
}
get resolvedShowFontSizeButtons() {
return this.mergedProps.showFontSizeButtons !== false
}
get resolvedShowTooltips() {
if (typeof this.mergedProps.showTooltips === 'boolean')
return this.mergedProps.showTooltips
return this.context?.showTooltips !== false
}
get resolvedIsShowPreview() {
if (typeof this.mergedProps.isShowPreview === 'boolean')
return this.mergedProps.isShowPreview
return true
}
get rawLanguage() {
return getString((this.node as any)?.language || 'plaintext')
}
get canonicalLanguage() {
return normalizeLanguageIdentifier(this.rawLanguage) || 'plain'
}
get monacoLanguage() {
return resolveMonacoLanguageId(this.rawLanguage)
}
get isPlainTextLanguage() {
return this.monacoLanguage === 'plaintext'
}
get displayLanguage() {
const label = languageMap[this.canonicalLanguage] || this.canonicalLanguage
return label ? label.charAt(0).toUpperCase() + label.slice(1) : 'Text'
}
get languageIconDataUrl() {
const icon = getLanguageIcon(this.rawLanguage).trim()
const svg = icon.includes('xmlns=')
? icon
: icon.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"')
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
}
get resolvedCode() {
if (this.isDiff)
return getString((this.node as any)?.updatedCode || (this.node as any)?.code)
return getString((this.node as any)?.code)
}
get originalCode() {
return getString((this.node as any)?.originalCode)
}
get isDiff() {
return !!(this.node as any)?.diff
}
get isPreviewable() {
if (!this.resolvedIsShowPreview)
return false
return this.canonicalLanguage === 'html' || this.canonicalLanguage === 'svg'
}
get previewTitle() {
return this.canonicalLanguage === 'html'
? this.t('artifacts.htmlPreviewTitle')
: this.t('artifacts.svgPreviewTitle')
}
get headerForeground() {
return `var(--vscode-editor-foreground, ${this.resolvedIsDark ? '#e5e7eb' : '#111827'})`
}
get headerBackground() {
return `var(--vscode-editor-background, ${this.resolvedIsDark ? '#111827' : '#ffffff'})`
}
get ariaLabel() {
return `Code block: ${this.displayLanguage}`
}
get showLoadingPlaceholder() {
return this.shouldDelayEditor
}
get shouldDelayEditor() {
return !this.resolvedStream && this.resolvedLoading
}
get containerStyle() {
const style: Record<string, string> = {}
const min = this.resolveCssSize(this.mergedProps.minWidth ?? this.context?.codeBlockThemes?.minWidth)
const max = this.resolveCssSize(this.mergedProps.maxWidth ?? this.context?.codeBlockThemes?.maxWidth)
if (min)
style.minWidth = min
if (max)
style.maxWidth = max
return style
}
readonly closeInlinePreview = () => {
this.inlinePreviewOpen = false
this.cdr.markForCheck()
}
ngAfterViewInit() {
this.viewReady = true
this.applyInitialFontSize()
void this.syncEditorState()
}
ngOnChanges() {
this.applyInitialFontSize()
if (!this.viewReady)
return
void this.syncEditorState()
}
ngOnDestroy() {
this.destroyed = true
if (this.copyTimer != null && typeof window !== 'undefined')
window.clearTimeout(this.copyTimer)
this.cancelDeferredHeightSync()
this.cleanupEditor()
}
tooltipFor(text: string) {
return this.resolvedShowTooltips ? text : null
}
toggleCollapsed() {
this.collapsed = !this.collapsed
this.inlinePreviewOpen = false
if (!this.collapsed)
queueMicrotask(() => void this.syncEditorState())
this.cdr.markForCheck()
}
toggleExpanded() {
this.expanded = !this.expanded
queueMicrotask(() => this.applyEditorHeight())
this.cdr.markForCheck()
}
changeFontSize(delta: number) {
this.fontSize = Math.min(36, Math.max(10, this.fontSize + delta))
this.applyEditorFontSize()
this.applyEditorHeight()
this.cdr.markForCheck()
}
resetFontSize() {
this.fontSize = this.defaultFontSize
this.applyEditorFontSize()
this.applyEditorHeight()
this.cdr.markForCheck()
}
async copyCode() {
const text = this.resolvedCode
try {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText)
await navigator.clipboard.writeText(text)
else
this.copyUsingTextarea(text)
}
catch {
this.copyUsingTextarea(text)
}
this.copied = true
this.context?.events.onCopy?.(text)
if (this.copyTimer != null && typeof window !== 'undefined')
window.clearTimeout(this.copyTimer)
if (typeof window !== 'undefined') {
this.copyTimer = window.setTimeout(() => {
this.copied = false
this.cdr.markForCheck()
}, 1000)
}
this.cdr.markForCheck()
}
previewCode() {
if (!this.isPreviewable)
return
const artifactType = this.canonicalLanguage === 'html' ? 'text/html' : 'image/svg+xml'
const artifactTitle = this.previewTitle
if (typeof this.context?.events.onHandleArtifactClick === 'function') {
this.context.events.onHandleArtifactClick({
node: this.node,
artifactType,
artifactTitle,
id: `temp-${this.canonicalLanguage}-${Date.now()}`,
})
return
}
if (this.canonicalLanguage === 'html')
this.inlinePreviewOpen = !this.inlinePreviewOpen
this.cdr.markForCheck()
}
private applyInitialFontSize() {
const initial = Number(this.resolvedMonacoOptions.fontSize)
this.defaultFontSize = Number.isFinite(initial) && initial > 0 ? initial : 14
if (!(typeof this.fontSize === 'number' && Number.isFinite(this.fontSize) && this.fontSize > 0))
this.fontSize = this.defaultFontSize
if (this.fontSize < 10 || this.fontSize > 36)
this.fontSize = this.defaultFontSize
}
private async syncEditorState() {
if (this.destroyed || !this.viewReady)
return
if (this.collapsed || this.showLoadingPlaceholder) {
this.editorReady = false
this.cdr.markForCheck()
return
}
await this.ensureHelpers()
if (this.destroyed || this.useFallback || !this.helpers) {
this.cdr.markForCheck()
return
}
if (!this.editorHost?.nativeElement) {
this.editorReady = false
this.cdr.markForCheck()
return
}
if (this.syncPromise)
return this.syncPromise
this.syncPromise = (async () => {
try {
const desiredKind = this.isDiff && typeof this.helpers?.createDiffEditor === 'function' ? 'diff' : 'single'
if (this.editorKind !== desiredKind) {
await this.recreateEditor(desiredKind)
}
else {
await this.updateEditor()
}
await Promise.resolve(this.helpers?.setTheme?.(this.resolvedIsDark ? this.resolvedDarkTheme : this.resolvedLightTheme))
this.applyEditorFontSize()
if (this.expanded || !this.resolvedLoading || !this.editorReady)
this.applyEditorHeight()
this.editorReady = true
this.scheduleDeferredHeightSync()
}
catch {
this.useFallback = true
this.editorReady = false
this.cleanupEditor()
}
finally {
this.syncPromise = null
this.cdr.markForCheck()
}
})()
return this.syncPromise
}
private async ensureHelpers() {
if (this.helpers || this.useFallback)
return
if (this.createPromise)
return this.createPromise
this.createPromise = (async () => {
const monacoModule = await getUseMonaco()
if (!monacoModule || typeof monacoModule.useMonaco !== 'function') {
this.useFallback = true
return
}
const options = {
wordWrap: 'on',
wrappingIndent: 'same',
readOnly: true,
minimap: { enabled: false },
lineNumbers: 'on',
revealDebounceMs: 75,
MAX_HEIGHT: 500,
fontSize: this.defaultFontSize,
themes: this.resolvedThemes,
theme: this.resolvedIsDark ? this.resolvedDarkTheme : this.resolvedLightTheme,
...(this.resolvedMonacoOptions || {}),
}
this.helpers = monacoModule.useMonaco(options)
})()
try {
await this.createPromise
}
finally {
this.createPromise = null
}
}
private async recreateEditor(kind: 'single' | 'diff') {
const host = this.editorHost?.nativeElement
if (!host || !this.helpers)
return
this.cleanupEditor()
host.innerHTML = ''
this.editorKind = kind
if (kind === 'diff') {
await Promise.resolve(this.helpers.createDiffEditor?.(
host,
this.originalCode,
this.resolvedCode,
this.monacoLanguage,
))
return
}
await Promise.resolve(this.helpers.createEditor?.(
host,
this.resolvedCode,
this.monacoLanguage,
))
}
private async updateEditor() {
if (!this.helpers)
return
if (this.isDiff && this.editorKind === 'diff' && typeof this.helpers.updateDiff === 'function') {
await Promise.resolve(this.helpers.updateDiff(this.originalCode, this.resolvedCode, this.monacoLanguage))
return
}
await Promise.resolve(this.helpers.updateCode?.(this.resolvedCode, this.monacoLanguage))
}
private applyEditorFontSize() {
const view = this.editorKind === 'diff'
? this.helpers?.getDiffEditorView?.()
: this.helpers?.getEditorView?.()
try {
view?.updateOptions?.({ fontSize: this.fontSize, automaticLayout: this.expanded })
if (this.editorKind === 'diff' && typeof view?.getModifiedEditor === 'function')
view.getModifiedEditor()?.updateOptions?.({ fontSize: this.fontSize })
}
catch {}
}
private applyEditorHeight() {
const host = this.editorHost?.nativeElement
if (!host)
return
const maxHeight = this.resolveMaxHeight()
host.style.minHeight = '0px'
host.style.maxHeight = this.expanded ? 'none' : `${maxHeight}px`
host.style.overflow = this.expanded ? 'visible' : 'auto'
const view = this.editorKind === 'diff'
? this.helpers?.getDiffEditorView?.()
: this.helpers?.getEditorView?.()
if (!view)
return
try {
view.updateOptions?.({ automaticLayout: this.expanded })
}
catch {}
const height = this.resolveEditorHeight(view, maxHeight)
host.style.height = `${height}px`
try {
view.layout?.()
}
catch {}
}
private resolveEditorHeight(view: any, maxHeight: number) {
let contentHeight = Number.NaN
try {
if (typeof view.getContentHeight === 'function')
contentHeight = Number(view.getContentHeight())
else if (this.editorKind === 'diff' && typeof view.getModifiedEditor === 'function')
contentHeight = Number(view.getModifiedEditor()?.getContentHeight?.())
}
catch {}
if (!Number.isFinite(contentHeight) || contentHeight <= 0) {
const lines = this.resolveEditorLineCount(view)
const lineHeight = this.resolveEditorLineHeight()
const estimate = lines * (lineHeight + 1.5) + 1
return Math.ceil(Math.max(1, this.expanded ? estimate : Math.min(estimate, maxHeight)))
}
return Math.ceil(Math.max(1, this.expanded ? contentHeight : Math.min(contentHeight, maxHeight)))
}
private resolveEditorLineCount(view: any) {
try {
if (
this.editorKind === 'diff'
&& typeof view?.getOriginalEditor === 'function'
&& typeof view?.getModifiedEditor === 'function'
) {
const originalLines = Number(view.getOriginalEditor?.()?.getModel?.()?.getLineCount?.() ?? 1)
const modifiedLines = Number(view.getModifiedEditor?.()?.getModel?.()?.getLineCount?.() ?? 1)
return Math.max(1, originalLines, modifiedLines)
}
const lines = Number(view?.getModel?.()?.getLineCount?.() ?? 1)
if (Number.isFinite(lines) && lines > 0)
return lines
}
catch {}
return Math.max(1, String(this.resolvedCode || '').split('\n').length)
}
private resolveEditorLineHeight() {
const fromOptions = Number(this.resolvedMonacoOptions.lineHeight)
if (Number.isFinite(fromOptions) && fromOptions > 0)
return fromOptions
const fromFontOption = Number(this.resolvedMonacoOptions.fontSize)
if (Number.isFinite(fromFontOption) && fromFontOption > 0)
return Math.max(12, Math.round(fromFontOption * 1.35))
const fromState = Number(this.fontSize)
if (Number.isFinite(fromState) && fromState > 0)
return Math.max(12, Math.round(fromState * 1.35))
return 18
}
private resolveMaxHeight() {
const raw = this.resolvedMonacoOptions.MAX_HEIGHT ?? 500
if (typeof raw === 'number' && Number.isFinite(raw))
return raw > 0 ? raw : 500
const matched = String(raw).match(/^(\d+(?:\.\d+)?)/)
const parsed = matched ? Number.parseFloat(matched[1]) : 500
return Number.isFinite(parsed) && parsed > 0 ? parsed : 500
}
private cancelDeferredHeightSync() {
if (this.deferredHeightSyncRaf == null || typeof window === 'undefined')
return
window.cancelAnimationFrame(this.deferredHeightSyncRaf)
this.deferredHeightSyncRaf = null
}
private scheduleDeferredHeightSync() {
if (typeof window === 'undefined' || this.destroyed)
return
this.cancelDeferredHeightSync()
this.deferredHeightSyncRaf = window.requestAnimationFrame(() => {
this.deferredHeightSyncRaf = window.requestAnimationFrame(() => {
this.deferredHeightSyncRaf = null
this.applyEditorHeight()
})
})
}
private cleanupEditor() {
try {
this.helpers?.safeClean?.()
}
catch {}
try {
this.helpers?.cleanupEditor?.()
}
catch {}
this.editorKind = null
}
private resolveCssSize(value: unknown) {
if (value == null || value === '')
return null
return typeof value === 'number' ? `${value}px` : String(value)
}
private copyUsingTextarea(text: string) {
if (typeof document === 'undefined')
return
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', 'true')
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
textarea.style.pointerEvents = 'none'
document.body.appendChild(textarea)
textarea.select()
try {
document.execCommand('copy')
}
catch {}
finally {
textarea.remove()
}
}
}
@@ -0,0 +1,87 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { useSafeI18n } from '../../i18n/useSafeI18n'
@Component({
selector: 'markstream-angular-html-preview-frame',
standalone: true,
imports: [CommonModule],
template: `
<div
class="html-preview-frame__backdrop"
[class.html-preview-frame__backdrop--dark]="isDark"
(click)="onClose?.()"
>
<div
class="html-preview-frame"
[class.html-preview-frame--dark]="isDark"
(click)="$event.stopPropagation()"
>
<div class="html-preview-frame__header">
<div class="html-preview-frame__title">
<span class="html-preview-frame__dot"></span>
<span class="html-preview-frame__label">{{ resolvedTitle }}</span>
</div>
<button
type="button"
class="html-preview-frame__close"
[class.html-preview-frame__close--dark]="isDark"
(click)="onClose?.()"
>
×
</button>
</div>
<iframe
class="html-preview-frame__iframe"
sandbox="allow-scripts allow-same-origin"
[srcdoc]="srcdoc"
[title]="resolvedTitle"
></iframe>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HtmlPreviewFrameComponent {
@Input() code = ''
@Input() isDark = false
@Input() title?: string
@Input() onClose?: () => void
private readonly i18n = useSafeI18n()
get resolvedTitle() {
return this.title || `HTML ${this.i18n.t('common.preview')}`
}
get srcdoc() {
const base = this.code || ''
const lowered = base.trim().toLowerCase()
if (lowered.startsWith('<!doctype') || lowered.startsWith('<html') || lowered.startsWith('<body'))
return base
const bg = this.isDark ? '#020617' : '#ffffff'
const fg = this.isDark ? '#e5e7eb' : '#020617'
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
html, body {
margin: 0;
padding: 0;
height: 100%;
background-color: ${bg};
color: ${fg};
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'SF Pro Text', ui-sans-serif, sans-serif;
}
</style>
</head>
<body>
${base}
</body>
</html>`
}
}
@@ -0,0 +1,383 @@
import type { AfterViewInit, ElementRef, OnChanges, OnDestroy } from '@angular/core'
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
inject,
Input,
ViewChild,
} from '@angular/core'
import { getD2 } from '../../optional/d2'
import { extractRenderedSvg, toSafeSvgMarkup } from '../../sanitizeSvg'
import { getString } from '../shared/node-helpers'
import {
copyTextToClipboard,
downloadSvgMarkup,
resolveCssSize,
setElementHtml,
} from '../shared/rich-block-helpers'
const DARK_THEME_OVERRIDES: Record<string, string> = {
N1: '#E5E7EB',
N2: '#CBD5E1',
N3: '#94A3B8',
N4: '#64748B',
N5: '#475569',
N6: '#334155',
N7: '#0B1220',
B1: '#60A5FA',
B2: '#3B82F6',
B3: '#2563EB',
B4: '#1D4ED8',
B5: '#1E40AF',
B6: '#111827',
AA2: '#22D3EE',
AA4: '#0EA5E9',
AA5: '#0284C7',
AB4: '#FBBF24',
AB5: '#F59E0B',
}
@Component({
selector: 'markstream-angular-d2-block-node',
standalone: true,
imports: [CommonModule],
template: `
<div
class="markstream-angular-enhanced-block markstream-angular-enhanced-block--d2"
[attr.data-markstream-d2]="'1'"
[attr.data-mode]="showSource ? 'source' : 'preview'"
>
<div *ngIf="resolvedShowHeader" class="markstream-angular-enhanced-block__header">
<div class="flex items-center gap-2 min-w-0">
<span class="markstream-angular-enhanced-block__badge">D2</span>
<span class="mermaid-title__text truncate">Diagram</span>
</div>
<div class="markstream-angular-enhanced-block__actions">
<button
*ngIf="resolvedShowModeToggle"
type="button"
class="markstream-angular-enhanced-block__action mode-btn"
[class.is-active]="!showSource"
(click)="setMode(false)"
>
Preview
</button>
<button
*ngIf="resolvedShowModeToggle"
type="button"
class="markstream-angular-enhanced-block__action mode-btn"
[class.is-active]="showSource"
(click)="setMode(true)"
>
Source
</button>
<button
*ngIf="resolvedShowCopyButton"
type="button"
class="markstream-angular-enhanced-block__action d2-action-btn"
[attr.title]="tooltipFor(copied ? 'Copied' : 'Copy')"
(click)="copySource()"
>
{{ copied ? 'Copied' : 'Copy' }}
</button>
<button
*ngIf="resolvedShowExportButton"
type="button"
class="markstream-angular-enhanced-block__action d2-action-btn"
[disabled]="!svgMarkup"
[attr.title]="tooltipFor('Export SVG')"
(click)="exportSvg()"
>
Export
</button>
<button
*ngIf="resolvedShowCollapseButton"
type="button"
class="markstream-angular-enhanced-block__action d2-action-btn"
[attr.title]="tooltipFor(collapsed ? 'Expand' : 'Collapse')"
(click)="toggleCollapsed()"
>
{{ collapsed ? 'Expand' : 'Collapse' }}
</button>
</div>
</div>
<div *ngIf="!collapsed" class="markstream-angular-enhanced-block__body d2-block-body" [style.maxHeight]="resolvedMaxHeight">
<div *ngIf="rendering && !showSource" class="mermaid-loading">
<span class="mermaid-spinner" aria-hidden="true"></span>
<span>Rendering D2...</span>
</div>
<div *ngIf="!showSource" class="d2-render">
<div #previewHost class="d2-svg"></div>
</div>
<div *ngIf="showSource" class="d2-source">
<pre class="markstream-angular-enhanced-block__source d2-code"><code translate="no">{{ code }}</code></pre>
</div>
<div *ngIf="error" class="d2-error">{{ error }}</div>
</div>
<span class="sr-only" aria-live="polite" role="status">{{ copied ? 'Copied' : '' }}</span>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class D2BlockNodeComponent implements AfterViewInit, OnChanges, OnDestroy {
private readonly cdr = inject(ChangeDetectorRef)
@ViewChild('previewHost') private previewHost?: ElementRef<HTMLElement>
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() props?: Record<string, any>
rendering = false
copied = false
collapsed = false
showSource = false
svgMarkup = ''
error = ''
private viewReady = false
private destroyed = false
private renderToken = 0
private copyTimer: number | null = null
get mergedProps() {
return {
...(this.context?.d2Props || {}),
...(this.props || {}),
}
}
get code() {
return getString((this.node as any)?.code)
}
get resolvedIsDark() {
if (typeof this.mergedProps.isDark === 'boolean')
return this.mergedProps.isDark
return this.context?.isDark === true
}
get resolvedShowHeader() {
return this.mergedProps.showHeader !== false
}
get resolvedShowModeToggle() {
return this.mergedProps.showModeToggle !== false
}
get resolvedShowCopyButton() {
return this.mergedProps.showCopyButton !== false
}
get resolvedShowExportButton() {
return this.mergedProps.showExportButton !== false
}
get resolvedShowCollapseButton() {
return this.mergedProps.showCollapseButton !== false
}
get resolvedShowTooltips() {
if (typeof this.mergedProps.showTooltips === 'boolean')
return this.mergedProps.showTooltips
return this.context?.showTooltips !== false
}
get resolvedMaxHeight() {
return resolveCssSize(this.mergedProps.maxHeight, '500px')
}
get resolvedLoading() {
if (typeof this.mergedProps.loading === 'boolean')
return this.mergedProps.loading
return (this.node as any)?.loading === true
}
get resolvedProgressiveRender() {
return this.mergedProps.progressiveRender === true
}
ngAfterViewInit() {
this.viewReady = true
queueMicrotask(() => void this.renderDiagram())
}
ngOnChanges() {
if (!this.viewReady)
return
queueMicrotask(() => void this.renderDiagram())
}
ngOnDestroy() {
this.destroyed = true
this.renderToken += 1
if (this.copyTimer != null && typeof window !== 'undefined')
window.clearTimeout(this.copyTimer)
}
tooltipFor(text: string) {
return this.resolvedShowTooltips ? text : null
}
setMode(nextSource: boolean) {
this.showSource = nextSource
if (!nextSource)
this.scheduleHostSync()
this.cdr.markForCheck()
}
async copySource() {
await copyTextToClipboard(this.code)
this.copied = true
this.context?.events.onCopy?.(this.code)
if (this.copyTimer != null && typeof window !== 'undefined')
window.clearTimeout(this.copyTimer)
if (typeof window !== 'undefined') {
this.copyTimer = window.setTimeout(() => {
this.copied = false
this.cdr.markForCheck()
}, 1000)
}
this.cdr.markForCheck()
}
exportSvg() {
if (!this.svgMarkup)
return
downloadSvgMarkup(this.svgMarkup, `d2-${Date.now()}.svg`)
}
toggleCollapsed() {
this.collapsed = !this.collapsed
if (!this.collapsed)
queueMicrotask(() => void this.renderDiagram())
this.cdr.markForCheck()
}
private async renderDiagram() {
if (this.destroyed || !this.viewReady || this.collapsed)
return
const source = this.code.trim()
if (!source) {
this.svgMarkup = ''
this.error = ''
this.syncSvgHost()
this.cdr.markForCheck()
return
}
if (this.resolvedLoading && !this.resolvedProgressiveRender) {
this.renderToken += 1
this.rendering = true
this.error = ''
this.svgMarkup = ''
this.syncSvgHost()
this.cdr.markForCheck()
return
}
const token = ++this.renderToken
this.rendering = true
this.error = ''
this.cdr.markForCheck()
try {
const D2Ctor = await getD2()
if (!D2Ctor)
throw new Error('D2 renderer is not available.')
const instance = createD2Instance(D2Ctor)
if (!instance || typeof instance.compile !== 'function' || typeof instance.render !== 'function')
throw new TypeError('D2 instance is missing compile/render methods.')
const compileResult = await instance.compile(source)
const diagram = compileResult?.diagram ?? compileResult
const baseRenderOptions = compileResult?.renderOptions ?? compileResult?.options ?? {}
const renderOptions: Record<string, any> = {
...baseRenderOptions,
themeID: this.resolvedIsDark
? (this.mergedProps.darkThemeId ?? baseRenderOptions.darkThemeID ?? baseRenderOptions.themeID)
: (this.mergedProps.themeId ?? baseRenderOptions.themeID),
darkThemeID: null,
darkThemeOverrides: null,
}
if (this.resolvedIsDark) {
renderOptions.themeOverrides = {
...(baseRenderOptions.themeOverrides || {}),
...DARK_THEME_OVERRIDES,
}
}
const renderResult = await instance.render(diagram, renderOptions)
if (this.destroyed || token !== this.renderToken)
return
const safeSvg = toSafeSvgMarkup(extractRenderedSvg(renderResult))
if (!safeSvg)
throw new Error('D2 render returned empty output.')
this.svgMarkup = safeSvg
this.syncSvgHost()
}
catch (error) {
if (this.destroyed || token !== this.renderToken)
return
this.svgMarkup = ''
this.showSource = true
this.error = error instanceof Error ? error.message : 'Failed to render D2 diagram.'
this.syncSvgHost()
}
finally {
if (token === this.renderToken) {
this.rendering = false
this.cdr.markForCheck()
}
}
}
private syncSvgHost() {
setElementHtml(this.previewHost?.nativeElement, this.svgMarkup)
}
private scheduleHostSync() {
if (typeof window !== 'undefined') {
window.setTimeout(() => {
if (this.destroyed)
return
this.syncSvgHost()
this.cdr.markForCheck()
}, 0)
return
}
queueMicrotask(() => this.syncSvgHost())
}
}
function createD2Instance(D2Ctor: any) {
if (typeof D2Ctor === 'function') {
const instance = new D2Ctor()
if (instance && typeof instance.compile === 'function')
return instance
if (typeof D2Ctor.compile === 'function')
return D2Ctor
}
if (D2Ctor?.D2 && typeof D2Ctor.D2 === 'function')
return new D2Ctor.D2()
if (typeof D2Ctor?.compile === 'function')
return D2Ctor
return null
}
@@ -0,0 +1,57 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { getNodeList } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-definition-list-node',
standalone: true,
imports: [CommonModule, forwardRef(() => NestedRendererComponent)],
template: `
<dl>
<ng-container *ngFor="let item of items; let idx = index; trackBy: trackByIndex">
<dt>
<markstream-angular-nested-renderer
[nodes]="termFor(item)"
[context]="context"
[indexPrefix]="itemPrefix(idx, 'term')"
/>
</dt>
<dd>
<markstream-angular-nested-renderer
[nodes]="definitionFor(item)"
[context]="context"
[indexPrefix]="itemPrefix(idx, 'definition')"
/>
</dd>
</ng-container>
</dl>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DefinitionListNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get items() {
return getNodeList((this.node as any)?.items)
}
termFor(item: AngularRenderableNode) {
return getNodeList((item as any)?.term)
}
definitionFor(item: AngularRenderableNode) {
return getNodeList((item as any)?.definition)
}
trackByIndex = (index: number) => {
return `${this.indexKey || 'definition-list'}-${index}`
}
itemPrefix(index: number, part: string) {
return `${this.indexKey || 'definition-list'}-${index}-${part}`
}
}
@@ -0,0 +1,114 @@
import type { ComponentRef, OnChanges, OnDestroy, SimpleChanges, Type } from '@angular/core'
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import {
ChangeDetectionStrategy,
Component,
Input,
ViewChild,
ViewContainerRef,
} from '@angular/core'
@Component({
selector: 'markstream-angular-dynamic-node-host',
standalone: true,
template: '<ng-template #container></ng-template>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DynamicNodeHostComponent implements OnChanges, OnDestroy {
@Input() component?: Type<any> | null
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
@Input() inputs?: Record<string, unknown> | null
@ViewChild('container', { read: ViewContainerRef, static: true })
private containerRef?: ViewContainerRef
private componentRef: ComponentRef<any> | null = null
ngOnChanges(changes: SimpleChanges) {
if (!this.containerRef)
return
if (changes.component)
this.mountComponent()
this.applyInputs()
}
ngOnDestroy() {
this.componentRef?.destroy()
this.componentRef = null
}
private mountComponent() {
this.containerRef?.clear()
this.componentRef?.destroy()
this.componentRef = null
if (!this.component || !this.containerRef)
return
this.componentRef = this.containerRef.createComponent(this.component)
}
private applyInputs() {
const componentRef = this.componentRef
const instance = componentRef?.instance
if (!instance || !componentRef)
return
const setValue = (key: string, value: unknown) => {
if (this.hasDeclaredInput(componentRef, key)) {
try {
componentRef.setInput(key, value)
}
catch {
// Fall back to direct instance assignment for edge-case compiled components.
}
}
instance[key] = value
}
setValue('node', this.node)
setValue('nodes', Array.isArray((this.node as any)?.children) ? (this.node as any).children : undefined)
setValue('content', typeof (this.node as any)?.content === 'string' ? (this.node as any).content : undefined)
setValue('loading', (this.node as any)?.loading)
setValue('final', typeof (this.node as any)?.loading === 'boolean' ? !(this.node as any).loading : this.context?.final)
setValue('context', this.context)
setValue('ctx', this.context)
setValue('customId', this.context?.customId)
setValue('customHtmlTags', this.context?.customHtmlTags)
setValue('parseOptions', this.context?.parseOptions)
setValue('customMarkdownIt', this.context?.customMarkdownIt)
setValue('isDark', this.context?.isDark)
setValue('indexKey', this.indexKey)
setValue('typewriter', this.context?.typewriter)
setValue('showTooltips', this.context?.showTooltips)
setValue('codeBlockStream', this.context?.codeBlockStream)
setValue('renderCodeBlocksAsPre', this.context?.renderCodeBlocksAsPre)
setValue('codeBlockProps', this.context?.codeBlockProps)
setValue('mermaidProps', this.context?.mermaidProps)
setValue('d2Props', this.context?.d2Props)
setValue('infographicProps', this.context?.infographicProps)
if (this.inputs) {
for (const [key, value] of Object.entries(this.inputs))
setValue(key, value)
}
componentRef.changeDetectorRef.detectChanges()
}
private hasDeclaredInput(componentRef: ComponentRef<any>, key: string) {
const inputMap = (componentRef.componentType as any)?.ɵcmp?.inputs
if (!inputMap || typeof inputMap !== 'object')
return false
if (key in inputMap)
return true
return Object.values(inputMap).includes(key)
}
}
@@ -0,0 +1,17 @@
import type { AngularRenderableNode } from '../shared/node-helpers'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { getString } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-emoji-node',
standalone: true,
template: '<span class="markstream-angular-emoji">{{ text }}</span>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EmojiNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
get text() {
return getString((this.node as any)?.raw || (this.node as any)?.markup || (this.node as any)?.content || (this.node as any)?.name)
}
}
@@ -0,0 +1,34 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { getNodeList } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-emphasis-node',
standalone: true,
imports: [CommonModule, forwardRef(() => NestedRendererComponent)],
template: `
<em>
<markstream-angular-nested-renderer
[nodes]="children"
[context]="context"
[indexPrefix]="nestedPrefix"
/>
</em>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EmphasisNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get children() {
return getNodeList((this.node as any)?.children)
}
get nestedPrefix() {
return `${this.indexKey || 'emphasis'}-em`
}
}
@@ -0,0 +1,79 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import {
escapeHtml,
getNodeList,
getString,
sanitizeClassToken,
} from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-fallback-node',
standalone: true,
imports: [CommonModule, forwardRef(() => NestedRendererComponent)],
template: `
<div
class="markstream-nested-custom"
[class]="customClassName"
[attr.data-markstream-custom-tag]="customTag"
>
<markstream-angular-nested-renderer
*ngIf="hasNestedContent; else fallbackBody"
[node]="node"
[context]="context"
[indexPrefix]="nestedPrefix"
[final]="resolvedFinal"
[customHtmlTags]="context?.customHtmlTags"
[parseOptions]="context?.parseOptions"
[customMarkdownIt]="context?.customMarkdownIt"
/>
<ng-template #fallbackBody>
<div [innerHTML]="rawBodyHtml"></div>
</ng-template>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FallbackComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get children() {
return getNodeList((this.node as any)?.children)
}
get hasChildren() {
return this.children.length > 0
}
get hasNestedContent() {
return this.hasChildren || !!getString((this.node as any)?.content)
}
get customTag() {
return getString((this.node as any)?.tag || (this.node as any)?.type)
}
get customClassName() {
const tag = sanitizeClassToken(this.customTag) || 'node'
return `markstream-nested-custom--${tag}`
}
get rawBodyHtml() {
return escapeHtml(getString((this.node as any)?.raw))
}
get nestedPrefix() {
return `${this.indexKey || 'fallback'}-children`
}
get resolvedFinal() {
if (typeof (this.node as any)?.loading === 'boolean')
return !(this.node as any).loading
return this.context?.final
}
}
@@ -0,0 +1,33 @@
import type { AngularRenderableNode } from '../shared/node-helpers'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { getString } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-footnote-anchor-node',
standalone: true,
template: `
<a
class="footnote-anchor text-sm text-[#0366d6] hover:underline cursor-pointer"
[attr.href]="href"
[attr.title]="title"
>
↩︎
</a>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FootnoteAnchorNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
get id() {
return getString((this.node as any)?.id)
}
get href() {
return `#fnref-${this.id}`
}
get title() {
return `Back to reference ${this.id}`
}
}
@@ -0,0 +1,38 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { getNodeList, getString } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-footnote-node',
standalone: true,
imports: [CommonModule, forwardRef(() => NestedRendererComponent)],
template: `
<div [attr.id]="footnoteId" class="markstream-nested-footnote">
<markstream-angular-nested-renderer
[nodes]="children"
[context]="context"
[indexPrefix]="nestedPrefix"
/>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FootnoteNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get footnoteId() {
return `fnref--${getString((this.node as any)?.id)}`
}
get children() {
return getNodeList((this.node as any)?.children)
}
get nestedPrefix() {
return `${this.indexKey || 'footnote'}-children`
}
}
@@ -0,0 +1,25 @@
import type { AngularRenderableNode } from '../shared/node-helpers'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { getString } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-footnote-reference-node',
standalone: true,
template: `
<sup class="markstream-nested-footnote-ref">
<a [attr.href]="href">[{{ id }}]</a>
</sup>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FootnoteReferenceNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
get id() {
return getString((this.node as any)?.id)
}
get href() {
return `#fnref--${this.id}`
}
}
@@ -0,0 +1,9 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
@Component({
selector: 'markstream-angular-hardbreak-node',
standalone: true,
template: '<br>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HardBreakNodeComponent {}
@@ -0,0 +1,55 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { clampHeadingLevel, getNodeList } from '../shared/node-helpers'
import { SafeAttrsDirective } from '../shared/safe-attrs.directive'
@Component({
selector: 'markstream-angular-heading-node',
standalone: true,
imports: [CommonModule, SafeAttrsDirective, forwardRef(() => NestedRendererComponent)],
template: `
<h1 *ngIf="level === 1" dir="auto" class="heading-node heading-1" [markstreamSafeAttrs]="attrs">
<markstream-angular-nested-renderer [nodes]="children" [context]="context" [indexPrefix]="nestedPrefix" />
</h1>
<h2 *ngIf="level === 2" dir="auto" class="heading-node heading-2" [markstreamSafeAttrs]="attrs">
<markstream-angular-nested-renderer [nodes]="children" [context]="context" [indexPrefix]="nestedPrefix" />
</h2>
<h3 *ngIf="level === 3" dir="auto" class="heading-node heading-3" [markstreamSafeAttrs]="attrs">
<markstream-angular-nested-renderer [nodes]="children" [context]="context" [indexPrefix]="nestedPrefix" />
</h3>
<h4 *ngIf="level === 4" dir="auto" class="heading-node heading-4" [markstreamSafeAttrs]="attrs">
<markstream-angular-nested-renderer [nodes]="children" [context]="context" [indexPrefix]="nestedPrefix" />
</h4>
<h5 *ngIf="level === 5" dir="auto" class="heading-node heading-5" [markstreamSafeAttrs]="attrs">
<markstream-angular-nested-renderer [nodes]="children" [context]="context" [indexPrefix]="nestedPrefix" />
</h5>
<h6 *ngIf="level === 6" dir="auto" class="heading-node heading-6" [markstreamSafeAttrs]="attrs">
<markstream-angular-nested-renderer [nodes]="children" [context]="context" [indexPrefix]="nestedPrefix" />
</h6>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HeadingNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get level() {
return clampHeadingLevel((this.node as any)?.level)
}
get attrs() {
const raw = (this.node as any)?.attrs
return raw && typeof raw === 'object' ? raw : null
}
get children() {
return getNodeList((this.node as any)?.children)
}
get nestedPrefix() {
return `${this.indexKey || 'heading'}-${this.level}`
}
}
@@ -0,0 +1,34 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { getNodeList } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-highlight-node',
standalone: true,
imports: [CommonModule, forwardRef(() => NestedRendererComponent)],
template: `
<mark>
<markstream-angular-nested-renderer
[nodes]="children"
[context]="context"
[indexPrefix]="nestedPrefix"
/>
</mark>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HighlightNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get children() {
return getNodeList((this.node as any)?.children)
}
get nestedPrefix() {
return `${this.indexKey || 'highlight'}-highlight`
}
}
@@ -0,0 +1,62 @@
import type { ElementRef } from '@angular/core'
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { ChangeDetectionStrategy, Component, Input, ViewChild } from '@angular/core'
import { NON_STRUCTURING_HTML_TAGS } from 'stream-markdown-parser'
import { renderMarkdownNodeToHtml } from '../../renderMarkdownHtml'
import { sanitizeHtmlContent } from '../../sanitizeHtmlContent'
import { getString } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-html-block-node',
standalone: true,
template: '<div #containerRef class="html-block-node"></div>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HtmlBlockNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@ViewChild('containerRef', { static: true }) private readonly containerRef?: ElementRef<HTMLElement>
ngAfterViewInit() {
this.commitContent()
}
ngOnChanges() {
this.commitContent()
}
ngOnDestroy() {
const container = this.containerRef?.nativeElement
if (container)
container.innerHTML = ''
}
private commitContent() {
const container = this.containerRef?.nativeElement
if (!container)
return
const content = getString((this.node as any)?.content)
const tag = getString((this.node as any)?.tag).trim().toLowerCase()
const children = Array.isArray((this.node as any)?.children) ? (this.node as any).children : []
if (!content) {
container.innerHTML = ''
return
}
if (this.context?.allowHtml === false || ((this.node as any)?.loading && !(this.node as any)?.autoClosed)) {
container.textContent = content
return
}
if (tag && children.length > 0 && !NON_STRUCTURING_HTML_TAGS.has(tag)) {
container.innerHTML = renderMarkdownNodeToHtml(this.node, {
allowHtml: this.context?.allowHtml,
customHtmlTags: this.context?.customHtmlTags,
})
return
}
container.innerHTML = sanitizeHtmlContent(content)
}
}
@@ -0,0 +1,50 @@
import type { ElementRef } from '@angular/core'
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { ChangeDetectionStrategy, Component, Input, ViewChild } from '@angular/core'
import { sanitizeHtmlContent } from '../../sanitizeHtmlContent'
import { getString } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-html-inline-node',
standalone: true,
template: '<span #containerRef class="html-inline-node"></span>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HtmlInlineNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@ViewChild('containerRef', { static: true }) private readonly containerRef?: ElementRef<HTMLElement>
ngAfterViewInit() {
this.commitContent()
}
ngOnChanges() {
this.commitContent()
}
ngOnDestroy() {
const container = this.containerRef?.nativeElement
if (container)
container.innerHTML = ''
}
private commitContent() {
const container = this.containerRef?.nativeElement
if (!container)
return
const content = getString((this.node as any)?.content)
if (!content) {
container.innerHTML = ''
return
}
if (this.context?.allowHtml === false || ((this.node as any)?.loading && !(this.node as any)?.autoClosed)) {
container.textContent = content
return
}
container.innerHTML = sanitizeHtmlContent(content)
}
}
@@ -0,0 +1,34 @@
import type { AngularRenderableNode } from '../shared/node-helpers'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { getString } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-image-node',
standalone: true,
template: `
<img
class="image-node__img"
[attr.src]="src"
[attr.alt]="alt"
[attr.title]="title || null"
[attr.loading]="'lazy'"
decoding="async"
>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ImageNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
get src() {
return getString((this.node as any)?.src)
}
get alt() {
return getString((this.node as any)?.alt)
}
get title() {
return getString((this.node as any)?.title)
}
}
@@ -0,0 +1,487 @@
import type { AfterViewInit, ElementRef, OnChanges, OnDestroy } from '@angular/core'
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
HostListener,
inject,
Input,
ViewChild,
} from '@angular/core'
import { getInfographic } from '../../optional/infographic'
import { clampPreviewHeight, estimateInfographicPreviewHeight, parsePositiveNumber } from '../shared/diagram-height'
import { getString } from '../shared/node-helpers'
import {
clampNumber,
clearElement,
copyTextToClipboard,
downloadSvgMarkup,
resolveCssSize,
setElementHtml,
} from '../shared/rich-block-helpers'
@Component({
selector: 'markstream-angular-infographic-block-node',
standalone: true,
imports: [CommonModule],
template: `
<div
class="markstream-angular-enhanced-block markstream-angular-enhanced-block--infographic"
[attr.data-markstream-infographic]="'1'"
[attr.data-mode]="showSource ? 'source' : 'preview'"
>
<div *ngIf="resolvedShowHeader" class="markstream-angular-enhanced-block__header">
<div class="flex items-center gap-2 min-w-0">
<span class="markstream-angular-enhanced-block__badge">Infographic</span>
<span class="mermaid-title__text truncate">Diagram</span>
</div>
<div class="markstream-angular-enhanced-block__actions">
<button
*ngIf="resolvedShowModeToggle"
type="button"
class="markstream-angular-enhanced-block__action mode-btn"
[class.is-active]="!showSource"
(click)="setMode(false)"
>
Preview
</button>
<button
*ngIf="resolvedShowModeToggle"
type="button"
class="markstream-angular-enhanced-block__action mode-btn"
[class.is-active]="showSource"
(click)="setMode(true)"
>
Source
</button>
<button
*ngIf="resolvedShowCopyButton"
type="button"
class="markstream-angular-enhanced-block__action"
[attr.title]="tooltipFor(copied ? 'Copied' : 'Copy')"
(click)="copySource()"
>
{{ copied ? 'Copied' : 'Copy' }}
</button>
<button
*ngIf="resolvedShowExportButton"
type="button"
class="markstream-angular-enhanced-block__action"
[disabled]="!svgMarkup"
[attr.title]="tooltipFor('Export SVG')"
(click)="exportSvg()"
>
Export
</button>
<button
*ngIf="resolvedShowFullscreenButton"
type="button"
class="markstream-angular-enhanced-block__action"
[disabled]="!svgMarkup"
[attr.title]="tooltipFor('Fullscreen')"
(click)="openModal()"
>
Fullscreen
</button>
<button
*ngIf="resolvedShowCollapseButton"
type="button"
class="markstream-angular-enhanced-block__action"
[attr.title]="tooltipFor(collapsed ? 'Expand' : 'Collapse')"
(click)="toggleCollapsed()"
>
{{ collapsed ? 'Expand' : 'Collapse' }}
</button>
<ng-container *ngIf="resolvedShowZoomControls && modalOpen">
<button type="button" class="markstream-angular-enhanced-block__action" [attr.title]="tooltipFor('Zoom out')" (click)="adjustZoom(-0.1)">-</button>
<button type="button" class="markstream-angular-enhanced-block__action" [attr.title]="tooltipFor('Reset zoom')" (click)="resetZoom()">100%</button>
<button type="button" class="markstream-angular-enhanced-block__action" [attr.title]="tooltipFor('Zoom in')" (click)="adjustZoom(0.1)">+</button>
</ng-container>
</div>
</div>
<div
*ngIf="!collapsed"
class="markstream-angular-enhanced-block__body"
[style.maxHeight]="resolvedMaxHeight"
>
<div *ngIf="rendering && !showSource" class="mermaid-loading">
<span class="mermaid-spinner" aria-hidden="true"></span>
<span>Rendering infographic...</span>
</div>
<div
*ngIf="!showSource"
#previewHost
class="infographic-render"
[style.minHeight]="resolvedContainerMinHeight"
></div>
<pre *ngIf="showSource" class="markstream-angular-enhanced-block__source"><code translate="no">{{ code }}</code></pre>
<div *ngIf="error" class="d2-error">{{ error }}</div>
</div>
<span class="sr-only" aria-live="polite" role="status">{{ copied ? 'Copied' : '' }}</span>
</div>
<div
*ngIf="modalOpen"
class="mermaid-modal-overlay"
role="dialog"
aria-modal="true"
(click)="closeModal()"
>
<div
class="mermaid-modal-panel"
[class.is-dark]="resolvedIsDark"
(click)="$event.stopPropagation()"
>
<div class="mermaid-modal-header">
<span class="mermaid-modal-title">Infographic Preview</span>
<button type="button" class="mermaid-modal-close" (click)="closeModal()">Close</button>
</div>
<div class="mermaid-modal-body">
<div class="mermaid-modal-content" [style.transform]="modalTransform">
<div #modalHost class="fullscreen"></div>
</div>
</div>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InfographicBlockNodeComponent implements AfterViewInit, OnChanges, OnDestroy {
private readonly cdr = inject(ChangeDetectorRef)
@ViewChild('previewHost') private previewHost?: ElementRef<HTMLElement>
@ViewChild('modalHost') private modalHost?: ElementRef<HTMLElement>
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() props?: Record<string, any>
rendering = false
copied = false
collapsed = false
showSource = false
modalOpen = false
zoom = 1
error = ''
containerMinHeight = ''
svgMarkup = ''
private viewReady = false
private destroyed = false
private renderToken = 0
private copyTimer: number | null = null
private infographicInstance: any = null
get mergedProps() {
return {
...(this.context?.infographicProps || {}),
...(this.props || {}),
}
}
get code() {
return getString((this.node as any)?.code)
}
get resolvedIsDark() {
if (typeof this.mergedProps.isDark === 'boolean')
return this.mergedProps.isDark
return this.context?.isDark === true
}
get resolvedShowHeader() {
return this.mergedProps.showHeader !== false
}
get resolvedShowCopyButton() {
return this.mergedProps.showCopyButton !== false
}
get resolvedShowCollapseButton() {
return this.mergedProps.showCollapseButton !== false
}
get resolvedShowModeToggle() {
return this.mergedProps.showModeToggle !== false
}
get resolvedShowExportButton() {
return this.mergedProps.showExportButton !== false
}
get resolvedShowFullscreenButton() {
return this.mergedProps.showFullscreenButton !== false
}
get resolvedShowZoomControls() {
return this.mergedProps.showZoomControls !== false
}
get resolvedShowTooltips() {
if (typeof this.mergedProps.showTooltips === 'boolean')
return this.mergedProps.showTooltips
return this.context?.showTooltips !== false
}
get resolvedMaxHeight() {
return resolveCssSize(this.mergedProps.maxHeight, '500px')
}
get estimatedPreviewHeightPx() {
return clampPreviewHeight(
parsePositiveNumber(this.mergedProps.estimatedPreviewHeightPx) ?? estimateInfographicPreviewHeight(this.code),
undefined,
this.maxPreviewHeight,
)
}
get resolvedContainerMinHeight() {
return this.containerMinHeight || `${this.estimatedPreviewHeightPx}px`
}
private get maxPreviewHeight() {
if (this.mergedProps.maxHeight == null || this.mergedProps.maxHeight === 'none')
return this.mergedProps.maxHeight === 'none' ? null : 500
return parsePositiveNumber(this.mergedProps.maxHeight) ?? 500
}
private resolveContainerMinHeight(actualHeight: number) {
const boundedHeight = Math.max(actualHeight, this.estimatedPreviewHeightPx)
const raw = this.mergedProps.maxHeight
if (raw == null || raw === 'none')
return `${boundedHeight}px`
const maxHeight = this.maxPreviewHeight
if (maxHeight == null)
return `${boundedHeight}px`
return `${Math.min(boundedHeight, maxHeight)}px`
}
get resolvedLoading() {
if (typeof this.mergedProps.loading === 'boolean')
return this.mergedProps.loading
return (this.node as any)?.loading === true
}
get resolvedProgressiveRender() {
return this.mergedProps.progressiveRender === true
}
get modalTransform() {
return `scale(${this.zoom})`
}
ngAfterViewInit() {
this.viewReady = true
queueMicrotask(() => void this.renderInfographic())
}
ngOnChanges() {
if (!this.viewReady)
return
this.containerMinHeight = ''
queueMicrotask(() => void this.renderInfographic())
}
ngOnDestroy() {
this.destroyed = true
this.renderToken += 1
if (this.copyTimer != null && typeof window !== 'undefined')
window.clearTimeout(this.copyTimer)
this.destroyInfographic()
if (typeof document !== 'undefined')
document.body.style.overflow = ''
}
@HostListener('window:keydown', ['$event'])
handleWindowKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && this.modalOpen)
this.closeModal()
}
tooltipFor(text: string) {
return this.resolvedShowTooltips ? text : null
}
setMode(nextSource: boolean) {
this.showSource = nextSource
if (!nextSource)
this.schedulePreviewSync()
this.cdr.markForCheck()
}
async copySource() {
await copyTextToClipboard(this.code)
this.copied = true
this.context?.events.onCopy?.(this.code)
if (this.copyTimer != null && typeof window !== 'undefined')
window.clearTimeout(this.copyTimer)
if (typeof window !== 'undefined') {
this.copyTimer = window.setTimeout(() => {
this.copied = false
this.cdr.markForCheck()
}, 1000)
}
this.cdr.markForCheck()
}
exportSvg() {
if (!this.svgMarkup)
return
downloadSvgMarkup(this.svgMarkup, `infographic-${Date.now()}.svg`)
}
openModal() {
if (!this.svgMarkup)
return
this.modalOpen = true
if (typeof document !== 'undefined')
document.body.style.overflow = 'hidden'
queueMicrotask(() => this.syncModalPreview())
this.cdr.markForCheck()
}
closeModal() {
this.modalOpen = false
if (typeof document !== 'undefined')
document.body.style.overflow = ''
clearElement(this.modalHost?.nativeElement)
this.cdr.markForCheck()
}
toggleCollapsed() {
this.collapsed = !this.collapsed
if (!this.collapsed)
queueMicrotask(() => void this.renderInfographic())
this.cdr.markForCheck()
}
adjustZoom(delta: number) {
this.zoom = clampNumber(Number((this.zoom + delta).toFixed(2)), 0.4, 2.5)
this.cdr.markForCheck()
}
resetZoom() {
this.zoom = 1
this.cdr.markForCheck()
}
private async renderInfographic() {
if (this.destroyed || !this.viewReady || this.collapsed || this.showSource)
return
const source = this.code.trim()
if (!source) {
this.svgMarkup = ''
clearElement(this.previewHost?.nativeElement)
this.cdr.markForCheck()
return
}
const host = this.previewHost?.nativeElement
if (!host)
return
if (this.resolvedLoading && !this.resolvedProgressiveRender) {
this.renderToken += 1
this.rendering = true
this.error = ''
this.svgMarkup = ''
clearElement(host)
this.cdr.markForCheck()
return
}
const token = ++this.renderToken
this.rendering = true
this.error = ''
this.cdr.markForCheck()
try {
const InfographicClass = await getInfographic()
if (!InfographicClass)
throw new Error('Infographic renderer is not available.')
if (this.destroyed || token !== this.renderToken)
return
this.destroyInfographic()
clearElement(host)
const instance = new InfographicClass({
container: host,
width: '100%',
height: '100%',
})
this.infographicInstance = instance
instance.render(source)
if (this.destroyed || token !== this.renderToken)
return
const svg = host.querySelector('svg')
this.svgMarkup = svg ? svg.outerHTML : ''
const measuredHeight = host.scrollHeight
if (measuredHeight > 0)
this.containerMinHeight = this.resolveContainerMinHeight(measuredHeight)
this.syncModalPreview()
}
catch (error) {
if (this.destroyed || token !== this.renderToken)
return
this.destroyInfographic()
this.svgMarkup = ''
this.showSource = true
this.error = error instanceof Error ? error.message : 'Failed to render infographic.'
clearElement(host)
}
finally {
if (token === this.renderToken) {
this.rendering = false
this.cdr.markForCheck()
}
}
}
private destroyInfographic() {
try {
this.infographicInstance?.destroy?.()
}
catch {
// Best-effort cleanup.
}
this.infographicInstance = null
}
private syncModalPreview() {
if (!this.modalOpen || !this.svgMarkup) {
clearElement(this.modalHost?.nativeElement)
return
}
setElementHtml(this.modalHost?.nativeElement, this.svgMarkup)
}
private schedulePreviewSync() {
if (typeof window !== 'undefined') {
window.setTimeout(() => {
if (this.destroyed)
return
setElementHtml(this.previewHost?.nativeElement, this.svgMarkup)
this.syncModalPreview()
this.cdr.markForCheck()
}, 0)
return
}
queueMicrotask(() => {
setElementHtml(this.previewHost?.nativeElement, this.svgMarkup)
this.syncModalPreview()
})
}
}
@@ -0,0 +1,78 @@
import type { OnChanges } from '@angular/core'
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { getString } from '../shared/node-helpers'
import { resolveStreamingTextState } from '../TextNode/streamingTextState'
@Component({
selector: 'markstream-angular-inline-code-node',
standalone: true,
imports: [CommonModule],
template: `
<code>
<span *ngIf="settledCode">{{ settledCode }}</span>
<span
*ngIf="streamedDelta"
[class]="'markstream-angular-text__stream-delta ' + streamedDeltaClass"
(animationend)="settleStreamedDelta()"
>{{ streamedDelta }}</span>
</code>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InlineCodeNodeComponent implements OnChanges {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
@Input() typewriter?: boolean
settledCode = ''
streamedDelta = ''
private streamFadeVersion = 0
ngOnChanges() {
const nextCode = getString((this.node as any)?.code)
const streamStateKey = getString(this.indexKey).trim()
const textStreamState = this.context?.textStreamState
const rendered = `${this.settledCode}${this.streamedDelta}`
const previousPersisted = streamStateKey
? textStreamState?.get(streamStateKey)
: undefined
const previousCode = previousPersisted ?? rendered
const nextState = resolveStreamingTextState({
nextContent: nextCode,
previousContent: previousCode,
typewriterEnabled: this.typewriterEnabled,
})
this.settledCode = nextState.settledContent
this.streamedDelta = nextState.streamedDelta
if (nextState.appended)
this.streamFadeVersion += 1
if (streamStateKey)
textStreamState?.set(streamStateKey, nextCode)
}
get streamedDeltaClass() {
return this.streamFadeVersion % 2 === 0
? 'markstream-angular-text__stream-delta--a'
: 'markstream-angular-text__stream-delta--b'
}
get typewriterEnabled() {
if (typeof this.typewriter === 'boolean')
return this.typewriter
if (typeof this.context?.typewriter === 'boolean')
return this.context.typewriter
return true
}
settleStreamedDelta() {
if (!this.streamedDelta)
return
this.settledCode = `${this.settledCode}${this.streamedDelta}`
this.streamedDelta = ''
}
}
@@ -0,0 +1,34 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { getNodeList } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-insert-node',
standalone: true,
imports: [CommonModule, forwardRef(() => NestedRendererComponent)],
template: `
<ins>
<markstream-angular-nested-renderer
[nodes]="children"
[context]="context"
[indexPrefix]="nestedPrefix"
/>
</ins>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InsertNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get children() {
return getNodeList((this.node as any)?.children)
}
get nestedPrefix() {
return `${this.indexKey || 'insert'}-insert`
}
}
@@ -0,0 +1,156 @@
import type { AfterViewInit, ElementRef, OnChanges, OnDestroy } from '@angular/core'
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
forwardRef,
Input,
ViewChild,
} from '@angular/core'
import { hideTooltip, showTooltipForAnchor } from '../../tooltip/singletonTooltip'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { getNodeList, getString } from '../shared/node-helpers'
import { TextNodeComponent } from '../TextNode/TextNode.component'
@Component({
selector: 'markstream-angular-link-node',
standalone: true,
imports: [CommonModule, forwardRef(() => NestedRendererComponent), forwardRef(() => TextNodeComponent)],
template: `
<a
*ngIf="!loading; else loadingTpl"
#anchorEl
class="link-node"
[attr.href]="href || null"
[attr.title]="resolvedShowTooltip ? '' : title"
[attr.aria-label]="'Link: ' + title"
target="_blank"
rel="noopener noreferrer"
[ngStyle]="cssVars"
(mouseenter)="onAnchorEnter()"
(mouseleave)="onAnchorLeave()"
>
<markstream-angular-nested-renderer
*ngIf="hasChildren; else fallbackText"
[nodes]="children"
[context]="context"
[indexPrefix]="nestedPrefix"
/>
<ng-template #fallbackText>
<markstream-angular-text-node
[node]="{ type: 'text', content: fallbackLabel }"
[context]="context"
[indexKey]="nestedPrefix + '-fallback'"
/>
</ng-template>
</a>
<ng-template #loadingTpl>
<span class="link-loading" [ngStyle]="cssVars" aria-hidden="false">
<span class="link-text-wrapper">
<span class="link-text">
<markstream-angular-text-node
[node]="{ type: 'text', content: fallbackLabel }"
[context]="context"
[indexKey]="nestedPrefix + '-loading'"
/>
</span>
<span class="link-loading-indicator" aria-hidden="true"></span>
</span>
</span>
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LinkNodeComponent implements AfterViewInit, OnChanges, OnDestroy {
@ViewChild('anchorEl') private anchorRef?: ElementRef<HTMLElement>
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
private hovering = false
get href() {
return getString((this.node as any)?.href)
}
get title() {
const rawTitle = getString((this.node as any)?.title)
return rawTitle || this.href
}
get loading() {
return (this.node as any)?.loading === true
}
get resolvedShowTooltip() {
return this.context?.showTooltips !== false
}
get children() {
return getNodeList((this.node as any)?.children)
}
get hasChildren() {
return this.children.length > 0
}
get fallbackLabel() {
return getString((this.node as any)?.text || this.href)
}
get nestedPrefix() {
return `${this.indexKey || 'link'}-inline`
}
get cssVars() {
return {
'--link-color': '#0366d6',
'--underline-height': '2px',
'--underline-bottom': '-3px',
'--underline-opacity': '0.35',
'--underline-rest-opacity': '0.175',
'--underline-duration': '1.6s',
'--underline-timing': 'ease-in-out',
'--underline-iteration': 'infinite',
} as Record<string, string>
}
ngAfterViewInit() {
this.syncTooltip()
}
ngOnChanges() {
this.syncTooltip()
}
ngOnDestroy() {
if (this.hovering)
hideTooltip(true)
}
onAnchorEnter() {
if (!this.resolvedShowTooltip)
return
this.hovering = true
showTooltipForAnchor(this.anchorRef?.nativeElement || null, this.title, 'top', false, this.context?.isDark)
}
onAnchorLeave() {
if (!this.resolvedShowTooltip)
return
this.hovering = false
hideTooltip()
}
private syncTooltip() {
if (!this.hovering || !this.resolvedShowTooltip)
return
showTooltipForAnchor(this.anchorRef?.nativeElement || null, this.title, 'top', true, this.context?.isDark)
}
}
@@ -0,0 +1,35 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { getNodeList } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-list-item-node',
standalone: true,
imports: [CommonModule, forwardRef(() => NestedRendererComponent)],
template: `
<li [attr.value]="value ?? null">
<markstream-angular-nested-renderer
[nodes]="children"
[context]="context"
[indexPrefix]="nestedPrefix"
/>
</li>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ListItemNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
@Input() value?: number
get children() {
return getNodeList((this.node as any)?.children)
}
get nestedPrefix() {
return `${this.indexKey || 'list-item'}-item`
}
}
@@ -0,0 +1,59 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { ListItemNodeComponent } from '../ListItemNode/ListItemNode.component'
import { getNodeList } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-list-node',
standalone: true,
imports: [CommonModule, forwardRef(() => ListItemNodeComponent)],
template: `
<ol *ngIf="ordered; else unorderedList" class="list-node my-5 pl-[calc(13/8*1em)]" [attr.start]="startValue || null">
<markstream-angular-list-item-node
*ngFor="let item of items; let idx = index; trackBy: trackByIndex"
[node]="item"
[value]="startValue + idx"
[context]="context"
[indexKey]="itemPrefix(idx)"
/>
</ol>
<ng-template #unorderedList>
<ul class="list-node my-5 pl-[calc(13/8*1em)] list-disc max-lg:my-[calc(4/3*1em)] max-lg:pl-[calc(14/9*1em)]">
<markstream-angular-list-item-node
*ngFor="let item of items; let idx = index; trackBy: trackByIndex"
[node]="item"
[context]="context"
[indexKey]="itemPrefix(idx)"
/>
</ul>
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ListNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get ordered() {
return !!(this.node as any)?.ordered
}
get startValue() {
return Number((this.node as any)?.start) || 1
}
get items() {
return getNodeList((this.node as any)?.items)
}
trackByIndex = (index: number) => {
return this.itemPrefix(index)
}
itemPrefix(index: number) {
return `${this.indexKey || 'list'}-${index}`
}
}
@@ -0,0 +1,126 @@
import type { AfterViewInit, ElementRef, OnChanges, OnDestroy } from '@angular/core'
import type { AngularRenderableNode } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
inject,
Input,
ViewChild,
} from '@angular/core'
import { getKatex } from '../../optional/katex'
import { normalizeKaTeXRenderInput } from '../../utils/normalizeKaTeXRenderInput'
import { renderKaTeXWithBackpressure, setKaTeXCache, WORKER_BUSY_CODE } from '../../workers/katexWorkerClient'
import { getString } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-math-block-node',
standalone: true,
imports: [CommonModule],
template: `
<div class="math-block markstream-nested-math-block" data-markstream-katex-managed="1">
<div #mathRef class="markstream-nested-math-block__render" [class.math-rendering]="rendering"></div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MathBlockNodeComponent implements AfterViewInit, OnChanges, OnDestroy {
private readonly cdr = inject(ChangeDetectorRef)
@ViewChild('mathRef') private mathRef?: ElementRef<HTMLDivElement>
@Input({ required: true }) node!: AngularRenderableNode
rendering = true
private destroyed = false
private renderVersion = 0
private hasRenderedOnce = false
get content() {
return getString((this.node as any)?.content || (this.node as any)?.markup || (this.node as any)?.raw)
}
ngAfterViewInit() {
void this.renderMath()
}
ngOnChanges() {
if (!this.mathRef)
return
void this.renderMath()
}
ngOnDestroy() {
this.destroyed = true
this.renderVersion += 1
}
private async renderMath() {
const target = this.mathRef?.nativeElement
if (!target)
return
const content = normalizeKaTeXRenderInput(this.content)
const version = ++this.renderVersion
if (!content) {
target.textContent = ''
this.rendering = false
this.cdr.markForCheck()
return
}
if (!this.hasRenderedOnce)
this.rendering = true
const html = await this.resolveKatexMarkup(content)
if (this.destroyed || version !== this.renderVersion)
return
if (html) {
target.innerHTML = html
this.hasRenderedOnce = true
this.rendering = false
}
else if (!(this.node as any)?.loading) {
target.textContent = getString((this.node as any)?.raw || content)
this.rendering = false
}
this.cdr.markForCheck()
}
private async resolveKatexMarkup(content: string) {
try {
return await renderKaTeXWithBackpressure(content, true, {
timeout: 3000,
waitTimeout: 2000,
maxRetries: 1,
})
}
catch (error: any) {
const code = error?.code || error?.name
const isWorkerInitFailure = code === 'WORKER_INIT_ERROR' || error?.fallbackToRenderer
const isBusyOrTimeout = code === WORKER_BUSY_CODE || code === 'WORKER_TIMEOUT'
if (!isWorkerInitFailure && !isBusyOrTimeout)
return null
}
const katex = await getKatex()
if (!katex)
return null
try {
const html = katex.renderToString(content, {
throwOnError: (this.node as any)?.loading === true,
displayMode: true,
})
setKaTeXCache(content, true, html)
return html
}
catch {
return null
}
}
}
@@ -0,0 +1,134 @@
import type { AfterViewInit, ElementRef, OnChanges, OnDestroy } from '@angular/core'
import type { AngularRenderableNode } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
inject,
Input,
ViewChild,
} from '@angular/core'
import { getKatex } from '../../optional/katex'
import { normalizeKaTeXRenderInput } from '../../utils/normalizeKaTeXRenderInput'
import { renderKaTeXWithBackpressure, setKaTeXCache, WORKER_BUSY_CODE } from '../../workers/katexWorkerClient'
import { getString } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-math-inline-node',
standalone: true,
imports: [CommonModule],
template: `
<span class="math-inline-wrapper markstream-nested-math" data-display="inline" data-markstream-katex-managed="1">
<span #mathRef class="math-inline" [class.math-inline--hidden]="loading"></span>
<span *ngIf="loading" class="math-inline__loading" role="status" aria-live="polite">
<span class="math-inline__spinner" aria-hidden="true"></span>
<span class="sr-only">Loading</span>
</span>
</span>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MathInlineNodeComponent implements AfterViewInit, OnChanges, OnDestroy {
private readonly cdr = inject(ChangeDetectorRef)
@ViewChild('mathRef') private mathRef?: ElementRef<HTMLSpanElement>
@Input({ required: true }) node!: AngularRenderableNode
loading = true
private destroyed = false
private renderVersion = 0
private hasRenderedOnce = false
get content() {
return getString((this.node as any)?.content || (this.node as any)?.markup || (this.node as any)?.raw)
}
get displayMode() {
return String((this.node as any)?.markup || '') === '$$'
}
ngAfterViewInit() {
void this.renderMath()
}
ngOnChanges() {
if (!this.mathRef)
return
void this.renderMath()
}
ngOnDestroy() {
this.destroyed = true
this.renderVersion += 1
}
private async renderMath() {
const target = this.mathRef?.nativeElement
if (!target)
return
const content = normalizeKaTeXRenderInput(this.content)
const version = ++this.renderVersion
if (!content) {
target.textContent = ''
this.loading = false
this.cdr.markForCheck()
return
}
if (!this.hasRenderedOnce)
this.loading = true
const html = await this.resolveKatexMarkup(content, this.displayMode)
if (this.destroyed || version !== this.renderVersion)
return
if (html) {
target.innerHTML = html
this.hasRenderedOnce = true
this.loading = false
}
else if (!(this.node as any)?.loading) {
target.textContent = getString((this.node as any)?.raw || content)
this.loading = false
}
this.cdr.markForCheck()
}
private async resolveKatexMarkup(content: string, displayMode: boolean) {
try {
return await renderKaTeXWithBackpressure(content, displayMode, {
timeout: 1500,
waitTimeout: 0,
maxRetries: 0,
})
}
catch (error: any) {
const code = error?.code || error?.name
const isWorkerInitFailure = code === 'WORKER_INIT_ERROR' || error?.fallbackToRenderer
const isBusyOrTimeout = code === WORKER_BUSY_CODE || code === 'WORKER_TIMEOUT'
if (!isWorkerInitFailure && !isBusyOrTimeout)
return null
}
const katex = await getKatex()
if (!katex)
return null
try {
const html = katex.renderToString(content, {
throwOnError: (this.node as any)?.loading === true,
displayMode,
})
setKaTeXCache(content, displayMode, html)
return html
}
catch {
return null
}
}
}
@@ -0,0 +1,761 @@
import type { AfterViewInit, ElementRef, OnChanges, OnDestroy } from '@angular/core'
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
HostListener,
inject,
Input,
ViewChild,
} from '@angular/core'
import { getMermaid } from '../../optional/mermaid'
import { toSafeSvgMarkup } from '../../sanitizeSvg'
import { canParseOffthread, findPrefixOffthread } from '../../workers/mermaidWorkerClient'
import { clampPreviewHeight, estimateMermaidPreviewHeight, parsePositiveNumber } from '../shared/diagram-height'
import { getString } from '../shared/node-helpers'
import {
clampNumber,
copyTextToClipboard,
downloadSvgMarkup,
resolveCssSize,
setElementHtml,
} from '../shared/rich-block-helpers'
let mermaidRenderSequence = 0
let mermaidRenderQueue: Promise<void> = Promise.resolve()
type MermaidTheme = 'light' | 'dark'
function enqueueMermaidRender<T>(run: () => Promise<T>) {
const next = mermaidRenderQueue.then(run, run)
mermaidRenderQueue = next.then(() => undefined, () => undefined)
return next
}
@Component({
selector: 'markstream-angular-mermaid-block-node',
standalone: true,
imports: [CommonModule],
template: `
<div
class="mermaid-block"
[class.dark]="resolvedIsDark"
[attr.data-markstream-mermaid]="'1'"
[attr.data-mode]="showSource ? 'source' : 'preview'"
>
<div *ngIf="resolvedShowHeader" class="mermaid-header">
<div class="flex items-center gap-2 min-w-0">
<span class="markstream-angular-enhanced-block__badge">Mermaid</span>
<span class="mermaid-title__text truncate">Diagram</span>
</div>
<div class="mermaid-actions">
<div *ngIf="resolvedShowModeToggle" class="mermaid-toggle" role="tablist" aria-label="Mermaid mode">
<button
type="button"
class="mermaid-toggle-btn"
[class.mermaid-toggle-btn--active]="!showSource"
[attr.aria-pressed]="!showSource"
(click)="setMode(false)"
>
Preview
</button>
<button
type="button"
class="mermaid-toggle-btn"
[class.mermaid-toggle-btn--active]="showSource"
[attr.aria-pressed]="showSource"
(click)="setMode(true)"
>
Source
</button>
</div>
<button
*ngIf="resolvedShowCopyButton"
type="button"
class="mermaid-btn"
[attr.title]="tooltipFor(copied ? 'Copied' : 'Copy')"
(click)="copySource()"
>
{{ copied ? 'Copied' : 'Copy' }}
</button>
<button
*ngIf="resolvedShowExportButton"
type="button"
class="mermaid-btn"
[disabled]="!svgMarkup"
[attr.title]="tooltipFor('Export SVG')"
(click)="exportSvg()"
>
Export
</button>
<button
*ngIf="resolvedShowFullscreenButton"
type="button"
class="mermaid-btn"
[disabled]="!svgMarkup"
[attr.title]="tooltipFor('Fullscreen')"
(click)="openModal()"
>
Fullscreen
</button>
<button
*ngIf="resolvedShowCollapseButton"
type="button"
class="mermaid-btn"
[attr.title]="tooltipFor(collapsed ? 'Expand' : 'Collapse')"
(click)="toggleCollapsed()"
>
{{ collapsed ? 'Expand' : 'Collapse' }}
</button>
<ng-container *ngIf="resolvedShowZoomControls && !showSource && !collapsed">
<button type="button" class="mermaid-btn" [attr.title]="tooltipFor('Zoom out')" (click)="adjustZoom(-0.1)">-</button>
<button type="button" class="mermaid-btn" [attr.title]="tooltipFor('Reset zoom')" (click)="resetZoom()">100%</button>
<button type="button" class="mermaid-btn" [attr.title]="tooltipFor('Zoom in')" (click)="adjustZoom(0.1)">+</button>
</ng-container>
</div>
</div>
<div *ngIf="!collapsed" class="mermaid-body">
<div *ngIf="rendering && !showSource" class="mermaid-loading">
<span class="mermaid-spinner" aria-hidden="true"></span>
<span>Rendering Mermaid...</span>
</div>
<div
*ngIf="!showSource"
class="mermaid-preview"
[style.maxHeight]="resolvedMaxHeight"
[style.minHeight.px]="estimatedPreviewHeightPx"
[style.height.px]="estimatedPreviewHeightPx"
[style.transform]="previewTransform"
>
<div #previewHost class="markstream-angular-mermaid" [class.is-empty]="!svgMarkup"></div>
</div>
<pre *ngIf="showSource" class="mermaid-source"><code translate="no">{{ code }}</code></pre>
<div *ngIf="error" class="mermaid-error">{{ error }}</div>
</div>
<span class="sr-only" aria-live="polite" role="status">{{ copied ? 'Copied' : '' }}</span>
</div>
<div
*ngIf="modalOpen"
class="mermaid-modal-overlay"
role="dialog"
aria-modal="true"
(click)="closeModal()"
>
<div
class="mermaid-modal-panel"
[class.is-dark]="resolvedIsDark"
(click)="$event.stopPropagation()"
>
<div class="mermaid-modal-header">
<span class="mermaid-modal-title">Mermaid Preview</span>
<button type="button" class="mermaid-modal-close" (click)="closeModal()">Close</button>
</div>
<div class="mermaid-modal-body">
<div
class="mermaid-modal-content"
[style.transform]="modalTransform"
(wheel)="handleModalWheel($event)"
>
<div #modalHost class="markstream-angular-mermaid fullscreen"></div>
</div>
</div>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MermaidBlockNodeComponent implements AfterViewInit, OnChanges, OnDestroy {
private readonly cdr = inject(ChangeDetectorRef)
@ViewChild('previewHost') private previewHost?: ElementRef<HTMLElement>
@ViewChild('modalHost') private modalHost?: ElementRef<HTMLElement>
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() props?: Record<string, any>
rendering = false
copied = false
collapsed = false
showSource = false
modalOpen = false
zoom = 1
svgMarkup = ''
error = ''
private viewReady = false
private destroyed = false
private renderToken = 0
private copyTimer: number | null = null
get mergedProps() {
return {
...(this.context?.mermaidProps || {}),
...(this.props || {}),
}
}
get code() {
return getString((this.node as any)?.code)
.replace(/\]::([^:])/g, ']:::$1')
.replace(/:::subgraphNode$/gm, '::subgraphNode')
}
get resolvedIsDark() {
if (typeof this.mergedProps.isDark === 'boolean')
return this.mergedProps.isDark
return this.context?.isDark === true
}
get resolvedShowHeader() {
return this.mergedProps.showHeader !== false
}
get resolvedShowModeToggle() {
return this.mergedProps.showModeToggle !== false
}
get resolvedShowCopyButton() {
return this.mergedProps.showCopyButton !== false
}
get resolvedShowExportButton() {
return this.mergedProps.showExportButton !== false
}
get resolvedShowFullscreenButton() {
return this.mergedProps.showFullscreenButton !== false
}
get resolvedShowCollapseButton() {
return this.mergedProps.showCollapseButton !== false
}
get resolvedShowZoomControls() {
return this.mergedProps.showZoomControls !== false
}
get resolvedShowTooltips() {
if (typeof this.mergedProps.showTooltips === 'boolean')
return this.mergedProps.showTooltips
return this.context?.showTooltips !== false
}
get resolvedMaxHeight() {
return resolveCssSize(this.mergedProps.maxHeight, '500px')
}
get estimatedPreviewHeightPx() {
return clampPreviewHeight(
parsePositiveNumber(this.mergedProps.estimatedPreviewHeightPx) ?? estimateMermaidPreviewHeight(this.code),
undefined,
this.maxPreviewHeight,
)
}
private get maxPreviewHeight() {
if (this.mergedProps.maxHeight == null || this.mergedProps.maxHeight === 'none')
return this.mergedProps.maxHeight === 'none' ? null : 500
return parsePositiveNumber(this.mergedProps.maxHeight) ?? 500
}
get resolvedWorkerTimeout() {
return clampNumber(Number(this.mergedProps.workerTimeoutMs ?? 1400), 200, 10_000)
}
get resolvedParseTimeout() {
return clampNumber(Number(this.mergedProps.parseTimeoutMs ?? 1800), 200, 10_000)
}
get resolvedRenderTimeout() {
return clampNumber(Number(this.mergedProps.renderTimeoutMs ?? 2500), 200, 15_000)
}
get resolvedFullRenderTimeout() {
return clampNumber(Number(this.mergedProps.fullRenderTimeoutMs ?? 4000), 200, 20_000)
}
get strictMode() {
return this.mergedProps.isStrict === true
}
get resolvedLoading() {
if (typeof this.mergedProps.loading === 'boolean')
return this.mergedProps.loading
return (this.node as any)?.loading === true
}
get resolvedProgressiveRender() {
return this.mergedProps.progressiveRender === true
}
get previewTransform() {
return `scale(${this.zoom})`
}
get modalTransform() {
return `scale(${Math.max(1, this.zoom)})`
}
ngAfterViewInit() {
this.viewReady = true
queueMicrotask(() => void this.renderDiagram())
}
ngOnChanges() {
if (!this.viewReady)
return
queueMicrotask(() => void this.renderDiagram())
}
ngOnDestroy() {
this.destroyed = true
this.renderToken += 1
if (this.copyTimer != null && typeof window !== 'undefined')
window.clearTimeout(this.copyTimer)
}
@HostListener('window:keydown', ['$event'])
handleWindowKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && this.modalOpen)
this.closeModal()
}
tooltipFor(text: string) {
return this.resolvedShowTooltips ? text : null
}
setMode(nextSource: boolean) {
this.showSource = nextSource
if (!nextSource)
this.scheduleHostSync()
this.cdr.markForCheck()
}
async copySource() {
await copyTextToClipboard(this.code)
this.copied = true
this.context?.events.onCopy?.(this.code)
if (this.copyTimer != null && typeof window !== 'undefined')
window.clearTimeout(this.copyTimer)
if (typeof window !== 'undefined') {
this.copyTimer = window.setTimeout(() => {
this.copied = false
this.cdr.markForCheck()
}, 1000)
}
this.cdr.markForCheck()
}
exportSvg() {
if (!this.svgMarkup)
return
downloadSvgMarkup(this.svgMarkup, `mermaid-${Date.now()}.svg`)
}
openModal() {
if (!this.svgMarkup)
return
this.modalOpen = true
if (typeof document !== 'undefined')
document.body.style.overflow = 'hidden'
queueMicrotask(() => this.syncSvgHosts())
this.cdr.markForCheck()
}
closeModal() {
this.modalOpen = false
if (typeof document !== 'undefined')
document.body.style.overflow = ''
this.cdr.markForCheck()
}
toggleCollapsed() {
this.collapsed = !this.collapsed
if (!this.collapsed)
queueMicrotask(() => void this.renderDiagram())
this.cdr.markForCheck()
}
adjustZoom(delta: number) {
this.zoom = clampNumber(Number((this.zoom + delta).toFixed(2)), 0.4, 2.5)
this.cdr.markForCheck()
}
resetZoom() {
this.zoom = 1
this.cdr.markForCheck()
}
handleModalWheel(event: WheelEvent) {
if (this.mergedProps.enableWheelZoom !== true)
return
event.preventDefault()
this.adjustZoom(event.deltaY > 0 ? -0.08 : 0.08)
}
private async renderDiagram() {
if (this.destroyed || !this.viewReady || this.collapsed)
return
const source = this.code.trim()
if (!source) {
this.svgMarkup = ''
this.error = ''
this.syncSvgHosts()
this.cdr.markForCheck()
return
}
if (this.resolvedLoading && !this.resolvedProgressiveRender) {
this.renderToken += 1
this.rendering = true
this.error = ''
this.svgMarkup = ''
this.syncSvgHosts()
this.cdr.markForCheck()
return
}
const token = ++this.renderToken
this.rendering = true
this.error = ''
this.cdr.markForCheck()
try {
const mermaid = await getMermaid(this.strictMode
? {
startOnLoad: false,
securityLevel: 'strict',
suppressErrorRendering: true,
flowchart: { htmlLabels: false },
}
: {
startOnLoad: false,
securityLevel: 'loose',
suppressErrorRendering: true,
})
if (!mermaid)
throw new Error('Mermaid renderer is not available.')
const theme: MermaidTheme = this.resolvedIsDark ? 'dark' : 'light'
let renderSource = source
if (this.resolvedLoading && this.resolvedProgressiveRender) {
try {
const res = await this.canParseOrPrefix(source, theme, mermaid, token)
if (this.destroyed || token !== this.renderToken)
return
if (!res.fullOk && !res.prefix) {
this.rendering = false
this.cdr.markForCheck()
return
}
if (res.prefix)
renderSource = res.prefix
}
catch (error: any) {
if (error?.name === 'AbortError')
return
this.rendering = false
this.cdr.markForCheck()
return
}
}
const renderId = `markstream-angular-mermaid-${++mermaidRenderSequence}`
const themedSource = this.applyThemeTo(renderSource, theme)
const rendered = await enqueueMermaidRender(() => this.withTimeout(
() => Promise.resolve(mermaid.render(renderId, themedSource)),
this.resolvedLoading && this.resolvedProgressiveRender
? this.resolvedRenderTimeout
: this.resolvedFullRenderTimeout,
))
if (this.destroyed || token !== this.renderToken)
return
const svg = typeof rendered === 'string' ? rendered : rendered?.svg
if (this.isBrokenMermaidSvg(svg))
throw new Error('Mermaid produced invalid SVG during preview')
const safeSvg = toSafeSvgMarkup(svg)
if (!safeSvg)
throw new Error('Mermaid returned empty output.')
this.svgMarkup = safeSvg
if (!this.svgMarkup)
this.showSource = true
this.syncSvgHosts()
}
catch (error) {
if (this.destroyed || token !== this.renderToken)
return
if (this.resolvedLoading && this.resolvedProgressiveRender) {
this.svgMarkup = ''
this.syncSvgHosts()
return
}
// Allow consumer to handle the error via onRenderError callback
const onRenderError = this.mergedProps.onRenderError
if (typeof onRenderError === 'function' && this.previewHost?.nativeElement) {
const handled = onRenderError(error, this.code, this.previewHost.nativeElement)
if (handled === true) {
this.svgMarkup = ''
return
}
}
this.svgMarkup = ''
this.showSource = true
this.error = error instanceof Error ? error.message : 'Failed to render Mermaid diagram.'
this.syncSvgHosts()
}
finally {
if (token === this.renderToken) {
this.rendering = false
this.cdr.markForCheck()
}
}
}
private syncSvgHosts() {
setElementHtml(this.previewHost?.nativeElement, this.svgMarkup)
if (this.modalOpen)
setElementHtml(this.modalHost?.nativeElement, this.svgMarkup)
else
setElementHtml(this.modalHost?.nativeElement, '')
}
private scheduleHostSync() {
if (typeof window !== 'undefined') {
window.setTimeout(() => {
if (this.destroyed)
return
this.syncSvgHosts()
this.cdr.markForCheck()
}, 0)
return
}
queueMicrotask(() => this.syncSvgHosts())
}
private async canParseWithFallback(source: string, theme: MermaidTheme, mermaid: any, token: number) {
try {
const canParse = await canParseOffthread(source, theme, this.resolvedWorkerTimeout)
if (this.destroyed || token !== this.renderToken)
throw new DOMException('Aborted', 'AbortError')
if (canParse)
return true
}
catch (error: any) {
if (error?.name === 'AbortError')
throw error
}
const themedSource = this.applyThemeTo(source, theme)
const anyMermaid = mermaid as any
if (typeof anyMermaid.parse === 'function') {
await this.withTimeout(() => Promise.resolve(anyMermaid.parse(themedSource)), this.resolvedParseTimeout)
if (this.destroyed || token !== this.renderToken)
throw new DOMException('Aborted', 'AbortError')
return true
}
const renderId = `markstream-angular-mermaid-parse-${++mermaidRenderSequence}`
await this.withTimeout(() => Promise.resolve(anyMermaid.render(renderId, themedSource)), this.resolvedParseTimeout)
if (this.destroyed || token !== this.renderToken)
throw new DOMException('Aborted', 'AbortError')
return true
}
private async canParseOrPrefix(source: string, theme: MermaidTheme, mermaid: any, token: number) {
if (this.getMermaidDiagramKind(source) === 'gantt') {
const prefix = this.getSafePrefixCandidate(source)
if (!prefix.trim())
return { fullOk: false, prefix: '' }
await this.canParseWithFallback(prefix, theme, mermaid, token)
if (this.destroyed || token !== this.renderToken)
throw new DOMException('Aborted', 'AbortError')
return prefix === source ? { fullOk: true, prefix: '' } : { fullOk: false, prefix }
}
try {
await this.canParseWithFallback(source, theme, mermaid, token)
return { fullOk: true, prefix: '' }
}
catch (error: any) {
if (error?.name === 'AbortError')
throw error
}
const prefix = await this.findPrefixCandidate(source, theme, token)
if (!prefix)
return { fullOk: false, prefix: '' }
await this.canParseWithFallback(prefix, theme, mermaid, token)
if (this.destroyed || token !== this.renderToken)
throw new DOMException('Aborted', 'AbortError')
return { fullOk: false, prefix }
}
private async findPrefixCandidate(source: string, theme: MermaidTheme, token: number) {
try {
const prefix = await findPrefixOffthread(source, theme, this.resolvedWorkerTimeout)
if (this.destroyed || token !== this.renderToken)
throw new DOMException('Aborted', 'AbortError')
if (prefix)
return prefix
}
catch (error: any) {
if (error?.name === 'AbortError')
throw error
}
return this.getSafePrefixCandidate(source)
}
private applyThemeTo(source: string, theme: MermaidTheme) {
const trimmed = source.trimStart()
if (trimmed.startsWith('%%{'))
return source
const themeValue = theme === 'dark' ? 'dark' : 'default'
return `%%{init: {"theme": "${themeValue}"}}%%\n${source}`
}
private getMermaidDiagramKind(source: string) {
for (const rawLine of source.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || line.startsWith('%%'))
continue
const match = line.match(/^([A-Z][\w-]*)\b/i)
return match?.[1]?.toLowerCase() || ''
}
return ''
}
private isGanttTaskLine(rawLine: string) {
const line = rawLine.trim()
if (!line || line.startsWith('%%'))
return false
if (/^(?:gantt|title|dateformat|axisformat|tickinterval|excludes|section|todaymarker|topaxis|weekday|weekend|acctitle|accdescr|accdescrmultiline)\b/i.test(line))
return false
return line.includes(':')
}
private getSafeGanttPreviewCandidate(source: string) {
const lines = source.split(/\r?\n/)
if (!/\r?\n$/.test(source) && lines.length > 0)
lines.pop()
while (lines.length > 0) {
const last = lines[lines.length - 1]?.trim()
if (!last || last.startsWith('%%')) {
lines.pop()
continue
}
if (this.isGanttTaskLine(last))
break
lines.pop()
}
return lines.some(line => this.isGanttTaskLine(line)) ? lines.join('\n') : ''
}
private getSafePrefixCandidate(source: string) {
if (this.getMermaidDiagramKind(source) === 'gantt')
return this.getSafeGanttPreviewCandidate(source)
const lines = source.split('\n')
while (lines.length > 0) {
const lastRaw = lines[lines.length - 1]
const last = lastRaw.trimEnd()
if (!last) {
lines.pop()
continue
}
const looksDangling = /^[-=~>|<\s]+$/.test(last.trim())
|| /(?:--|==|~~|->|<-|-\||-\)|-x|o-|\|-|\.-)\s*$/.test(last)
|| /[-|><]$/.test(last)
|| /(?:graph|flowchart|sequenceDiagram|classDiagram|stateDiagram|erDiagram|gantt)\s*$/i.test(last)
if (!looksDangling)
break
lines.pop()
}
return lines.join('\n')
}
private isBrokenMermaidSvg(svg: string | null | undefined) {
if (!svg || typeof DOMParser === 'undefined')
return !svg
const parsed = new DOMParser().parseFromString(svg, 'image/svg+xml')
const svgEl = parsed.documentElement
if (!svgEl || svgEl.nodeName.toLowerCase() !== 'svg')
return true
const viewBox = svgEl.getAttribute('viewBox')
if (viewBox) {
const parts = viewBox.trim().split(/[\s,]+/)
if (parts.length === 4) {
const width = Number.parseFloat(parts[2] || '')
const height = Number.parseFloat(parts[3] || '')
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0)
return true
}
}
const nodes = [svgEl, ...Array.from(svgEl.querySelectorAll('*'))]
for (const node of nodes) {
for (const attr of Array.from(node.attributes)) {
if (/\bNaN\b/i.test(attr.value))
return true
if (attr.name === 'style' && /max-width:\s*0(?:px)?/i.test(attr.value))
return true
}
}
return false
}
private withTimeout<T>(run: () => Promise<T>, timeoutMs: number) {
return new Promise<T>((resolve, reject) => {
let settled = false
const timer = typeof window !== 'undefined'
? window.setTimeout(() => {
if (settled)
return
settled = true
reject(new Error('Operation timed out'))
}, timeoutMs)
: null
run()
.then((value) => {
if (settled)
return
settled = true
if (timer != null && typeof window !== 'undefined')
window.clearTimeout(timer)
resolve(value)
})
.catch((error) => {
if (settled)
return
settled = true
if (timer != null && typeof window !== 'undefined')
window.clearTimeout(timer)
reject(error)
})
})
}
}
@@ -0,0 +1,141 @@
import type { OnChanges } from '@angular/core'
import type { MarkdownIt, ParseOptions } from 'stream-markdown-parser'
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { mergeCustomHtmlTags } from 'stream-markdown-parser'
import { parseNestedMarkdownToNodes } from '../../parseNestedMarkdownToNodes'
import { NodeOutletComponent } from '../NodeOutlet/NodeOutlet.component'
import { normalizeTokenAttrs } from '../shared/node-helpers'
import { SafeAttrsDirective } from '../shared/safe-attrs.directive'
type NestedRenderItem
= | { kind: 'label', attrs: Record<string, string | true> | null, children: AngularRenderableNode[] }
| { kind: 'node', node: AngularRenderableNode }
@Component({
selector: 'markstream-angular-nested-renderer',
standalone: true,
imports: [
CommonModule,
SafeAttrsDirective,
forwardRef(() => NestedRendererComponent),
forwardRef(() => NodeOutletComponent),
],
template: `
<ng-container *ngFor="let item of items; let idx = index; trackBy: trackByIndex">
<label *ngIf="item.kind === 'label'; else nodeTemplate" [markstreamSafeAttrs]="item.attrs">
<markstream-angular-nested-renderer
[nodes]="item.children"
[context]="context"
[indexPrefix]="childPrefix(idx)"
/>
</label>
<ng-template #nodeTemplate>
<markstream-angular-node-outlet
[node]="$any(item).node"
[context]="context"
[indexKey]="childPrefix(idx)"
/>
</ng-template>
</ng-container>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NestedRendererComponent implements OnChanges {
@Input() node?: AngularRenderableNode | null
@Input() nodes: readonly AngularRenderableNode[] | null | undefined
@Input() content?: string | null
@Input() loading?: boolean
@Input() final?: boolean
@Input() cacheKey?: string
@Input() parseOptions?: ParseOptions
@Input() customHtmlTags?: readonly string[]
@Input() customMarkdownIt?: (markdown: MarkdownIt) => MarkdownIt
@Input() context?: AngularRenderContext
@Input() indexPrefix = 'nested'
items: NestedRenderItem[] = []
ngOnChanges() {
this.items = this.buildItems(this.resolvedNodes)
}
trackByIndex = (index: number) => {
return `${this.indexPrefix}-${index}`
}
childPrefix(index: number) {
return `${this.indexPrefix}-${index}`
}
private get resolvedNodes() {
return parseNestedMarkdownToNodes(
{
node: this.node as any,
nodes: this.nodes as any,
content: this.content,
},
{
cacheKey: this.cacheKey,
final: this.resolveFinal(),
parseOptions: this.parseOptions ?? this.context?.parseOptions,
customHtmlTags: this.resolveCustomHtmlTags(),
customMarkdownIt: this.customMarkdownIt ?? this.context?.customMarkdownIt,
},
) as AngularRenderableNode[]
}
private buildItems(nodes: readonly AngularRenderableNode[] | null | undefined) {
const source = Array.isArray(nodes) ? nodes : []
const items: NestedRenderItem[] = []
for (let index = 0; index < source.length; index += 1) {
const node = source[index]
if (!node)
continue
if (node.type === 'label_open') {
const children: AngularRenderableNode[] = []
let cursor = index + 1
for (; cursor < source.length; cursor += 1) {
const segment = source[cursor]
if (segment?.type === 'label_close')
break
if (segment)
children.push(segment)
}
items.push({
kind: 'label',
attrs: normalizeTokenAttrs((node as any)?.attrs),
children,
})
index = cursor
continue
}
if (node.type === 'label_close')
continue
items.push({ kind: 'node', node })
}
return items
}
private resolveFinal() {
if (typeof this.final === 'boolean')
return this.final
if (typeof this.loading === 'boolean')
return !this.loading
if (typeof (this.node as any)?.loading === 'boolean')
return !(this.node as any).loading
return this.context?.final
}
private resolveCustomHtmlTags() {
return mergeCustomHtmlTags(this.context?.customHtmlTags, this.customHtmlTags)
}
}
@@ -0,0 +1,282 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { STANDARD_HTML_TAGS } from 'stream-markdown-parser'
import { getCustomNodeComponents } from '../../customComponents'
import { AdmonitionNodeComponent } from '../AdmonitionNode/AdmonitionNode.component'
import { BlockquoteNodeComponent } from '../BlockquoteNode/BlockquoteNode.component'
import { CheckboxNodeComponent } from '../CheckboxNode/CheckboxNode.component'
import { CodeBlockNodeComponent } from '../CodeBlockNode/CodeBlockNode.component'
import { D2BlockNodeComponent } from '../D2BlockNode/D2BlockNode.component'
import { DefinitionListNodeComponent } from '../DefinitionListNode/DefinitionListNode.component'
import { DynamicNodeHostComponent } from '../DynamicNodeHost/DynamicNodeHost.component'
import { EmojiNodeComponent } from '../EmojiNode/EmojiNode.component'
import { EmphasisNodeComponent } from '../EmphasisNode/EmphasisNode.component'
import { FallbackComponent } from '../FallbackComponent/FallbackComponent.component'
import { FootnoteAnchorNodeComponent } from '../FootnoteAnchorNode/FootnoteAnchorNode.component'
import { FootnoteNodeComponent } from '../FootnoteNode/FootnoteNode.component'
import { FootnoteReferenceNodeComponent } from '../FootnoteReferenceNode/FootnoteReferenceNode.component'
import { HardBreakNodeComponent } from '../HardBreakNode/HardBreakNode.component'
import { HeadingNodeComponent } from '../HeadingNode/HeadingNode.component'
import { HighlightNodeComponent } from '../HighlightNode/HighlightNode.component'
import { HtmlBlockNodeComponent } from '../HtmlBlockNode/HtmlBlockNode.component'
import { HtmlInlineNodeComponent } from '../HtmlInlineNode/HtmlInlineNode.component'
import { ImageNodeComponent } from '../ImageNode/ImageNode.component'
import { InfographicBlockNodeComponent } from '../InfographicBlockNode/InfographicBlockNode.component'
import { InlineCodeNodeComponent } from '../InlineCodeNode/InlineCodeNode.component'
import { InsertNodeComponent } from '../InsertNode/InsertNode.component'
import { LinkNodeComponent } from '../LinkNode/LinkNode.component'
import { ListItemNodeComponent } from '../ListItemNode/ListItemNode.component'
import { ListNodeComponent } from '../ListNode/ListNode.component'
import { MathBlockNodeComponent } from '../MathBlockNode/MathBlockNode.component'
import { MathInlineNodeComponent } from '../MathInlineNode/MathInlineNode.component'
import { MermaidBlockNodeComponent } from '../MermaidBlockNode/MermaidBlockNode.component'
import { ParagraphNodeComponent } from '../ParagraphNode/ParagraphNode.component'
import { PreCodeNodeComponent } from '../PreCodeNode/PreCodeNode.component'
import { ReferenceNodeComponent } from '../ReferenceNode/ReferenceNode.component'
import { hasCompleteHtmlTagContent } from '../shared/node-helpers'
import {
coerceBuiltinHtmlNode,
coerceCustomHtmlNode,
resolveHtmlTag,
resolveNodeOutletCodeMode,
resolveNodeOutletCustomComponent,
resolveNodeOutletCustomInputs,
} from '../shared/node-outlet-helpers'
import { StrikethroughNodeComponent } from '../StrikethroughNode/StrikethroughNode.component'
import { StrongNodeComponent } from '../StrongNode/StrongNode.component'
import { SubscriptNodeComponent } from '../SubscriptNode/SubscriptNode.component'
import { SuperscriptNodeComponent } from '../SuperscriptNode/SuperscriptNode.component'
import { TableNodeComponent } from '../TableNode/TableNode.component'
import { TextNodeComponent } from '../TextNode/TextNode.component'
import { ThematicBreakNodeComponent } from '../ThematicBreakNode/ThematicBreakNode.component'
import { VmrContainerNodeComponent } from '../VmrContainerNode/VmrContainerNode.component'
@Component({
selector: 'markstream-angular-node-outlet',
standalone: true,
imports: [
CommonModule,
forwardRef(() => AdmonitionNodeComponent),
forwardRef(() => BlockquoteNodeComponent),
forwardRef(() => CheckboxNodeComponent),
forwardRef(() => CodeBlockNodeComponent),
forwardRef(() => D2BlockNodeComponent),
forwardRef(() => DefinitionListNodeComponent),
forwardRef(() => DynamicNodeHostComponent),
forwardRef(() => EmojiNodeComponent),
forwardRef(() => EmphasisNodeComponent),
forwardRef(() => FallbackComponent),
forwardRef(() => FootnoteAnchorNodeComponent),
forwardRef(() => FootnoteNodeComponent),
forwardRef(() => FootnoteReferenceNodeComponent),
forwardRef(() => HardBreakNodeComponent),
forwardRef(() => HeadingNodeComponent),
forwardRef(() => HighlightNodeComponent),
forwardRef(() => HtmlBlockNodeComponent),
forwardRef(() => HtmlInlineNodeComponent),
forwardRef(() => ImageNodeComponent),
forwardRef(() => InfographicBlockNodeComponent),
forwardRef(() => InlineCodeNodeComponent),
forwardRef(() => InsertNodeComponent),
forwardRef(() => LinkNodeComponent),
forwardRef(() => ListItemNodeComponent),
forwardRef(() => ListNodeComponent),
forwardRef(() => MathBlockNodeComponent),
forwardRef(() => MathInlineNodeComponent),
forwardRef(() => MermaidBlockNodeComponent),
forwardRef(() => ParagraphNodeComponent),
forwardRef(() => PreCodeNodeComponent),
forwardRef(() => ReferenceNodeComponent),
forwardRef(() => StrikethroughNodeComponent),
forwardRef(() => StrongNodeComponent),
forwardRef(() => SubscriptNodeComponent),
forwardRef(() => SuperscriptNodeComponent),
forwardRef(() => TableNodeComponent),
forwardRef(() => TextNodeComponent),
forwardRef(() => ThematicBreakNodeComponent),
forwardRef(() => VmrContainerNodeComponent),
],
template: `
<ng-container *ngIf="resolvedCustomComponent as customComponent; else builtinTemplate">
<markstream-angular-dynamic-node-host
[component]="customComponent"
[node]="customNode"
[context]="context"
[indexKey]="indexKey"
[inputs]="resolvedComponentInputs"
/>
</ng-container>
<ng-template #builtinTemplate>
<ng-container [ngSwitch]="resolvedType">
<markstream-angular-text-node *ngSwitchCase="'text'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-text-node *ngSwitchCase="'text_special'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-paragraph-node *ngSwitchCase="'paragraph'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-heading-node *ngSwitchCase="'heading'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-blockquote-node *ngSwitchCase="'blockquote'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-list-node *ngSwitchCase="'list'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-list-item-node *ngSwitchCase="'list_item'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-table-node *ngSwitchCase="'table'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-definition-list-node *ngSwitchCase="'definition_list'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-footnote-node *ngSwitchCase="'footnote'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-footnote-reference-node *ngSwitchCase="'footnote_reference'" [node]="node" />
<markstream-angular-footnote-anchor-node *ngSwitchCase="'footnote_anchor'" [node]="node" />
<markstream-angular-admonition-node *ngSwitchCase="'admonition'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-hardbreak-node *ngSwitchCase="'hardbreak'" />
<markstream-angular-link-node *ngSwitchCase="'link'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-image-node *ngSwitchCase="'image'" [node]="node" />
<markstream-angular-inline-code-node *ngSwitchCase="'inline_code'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-strong-node *ngSwitchCase="'strong'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-emphasis-node *ngSwitchCase="'emphasis'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-strikethrough-node *ngSwitchCase="'strikethrough'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-highlight-node *ngSwitchCase="'highlight'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-insert-node *ngSwitchCase="'insert'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-subscript-node *ngSwitchCase="'subscript'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-superscript-node *ngSwitchCase="'superscript'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-checkbox-node *ngSwitchCase="'checkbox'" [node]="node" />
<markstream-angular-checkbox-node *ngSwitchCase="'checkbox_input'" [node]="node" />
<markstream-angular-emoji-node *ngSwitchCase="'emoji'" [node]="node" />
<markstream-angular-reference-node *ngSwitchCase="'reference'" [node]="node" />
<ng-container *ngSwitchCase="'html_block'">
<markstream-angular-text-node *ngIf="shouldEscapeHtmlTag; else htmlBlockNode" [node]="escapedTextNode" [context]="context" [indexKey]="indexKey" />
<ng-template #htmlBlockNode>
<markstream-angular-html-block-node [node]="htmlRenderNode" [context]="context" />
</ng-template>
</ng-container>
<ng-container *ngSwitchCase="'html_inline'">
<markstream-angular-text-node *ngIf="shouldEscapeHtmlTag; else htmlInlineNode" [node]="escapedTextNode" [context]="context" [indexKey]="indexKey" />
<ng-template #htmlInlineNode>
<markstream-angular-html-inline-node [node]="htmlRenderNode" [context]="context" />
</ng-template>
</ng-container>
<markstream-angular-vmr-container-node *ngSwitchCase="'vmr_container'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-thematic-break-node *ngSwitchCase="'thematic_break'" />
<markstream-angular-math-inline-node *ngSwitchCase="'math_inline'" [node]="node" />
<markstream-angular-math-block-node *ngSwitchCase="'math_block'" [node]="node" />
<ng-container *ngSwitchCase="'code_block'">
<markstream-angular-mermaid-block-node
*ngIf="codeMode === 'mermaid'; else nonMermaidCode"
[node]="node"
[context]="context"
/>
<ng-template #nonMermaidCode>
<markstream-angular-d2-block-node
*ngIf="codeMode === 'd2'; else nonD2Code"
[node]="node"
[context]="context"
/>
</ng-template>
<ng-template #nonD2Code>
<markstream-angular-infographic-block-node
*ngIf="codeMode === 'infographic'; else genericCode"
[node]="node"
[context]="context"
/>
</ng-template>
<ng-template #genericCode>
<markstream-angular-pre-code-node
*ngIf="codeMode === 'pre'; else enhancedCode"
[node]="node"
/>
</ng-template>
<ng-template #enhancedCode>
<markstream-angular-code-block-node [node]="node" [context]="context" />
</ng-template>
</ng-container>
<markstream-angular-fallback-node *ngSwitchDefault [node]="fallbackNode" [context]="context" [indexKey]="indexKey" />
</ng-container>
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NodeOutletComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get resolvedType() {
return String((this.node as any)?.type || '')
}
get customComponentMap() {
return this.context?.customComponents || getCustomNodeComponents(this.context?.customId)
}
get resolvedCustomComponent() {
return resolveNodeOutletCustomComponent(this.node, this.context, this.customComponentMap)
}
get customNode() {
return coerceCustomHtmlNode(this.node)
}
get codeMode() {
return resolveNodeOutletCodeMode(this.node, this.context)
}
get htmlTag() {
return resolveHtmlTag(this.node)
}
/**
* Check if this HTML tag should be escaped (rendered as text) instead of being parsed as HTML.
* This happens when:
* 1. The node is html_block or html_inline
* 2. The tag is NOT in the customHtmlTags whitelist
* 3. The tag is NOT a standard HTML tag
* 4. The tag is incomplete/malformed, so rendering it as HTML would swallow
* surrounding content.
*/
get shouldEscapeHtmlTag() {
const type = this.resolvedType
if (type !== 'html_block' && type !== 'html_inline')
return false
const tag = this.htmlTag
if (!tag)
return false
// Check if tag is in whitelist
const customHtmlTags = this.context?.customHtmlTags ?? []
const isWhitelisted = customHtmlTags.some(t => String(t).toLowerCase() === tag)
if (isWhitelisted)
return false
// Check if tag is a standard HTML tag
if (STANDARD_HTML_TAGS.has(tag))
return false
if (hasCompleteHtmlTagContent((this.node as any)?.content ?? (this.node as any)?.raw, tag))
return false
return true
}
/**
* Returns a text node for incomplete non-whitelisted, non-standard HTML tags.
*/
get escapedTextNode(): AngularRenderableNode {
const rawContent = String((this.node as any)?.content ?? (this.node as any)?.raw ?? '')
return {
type: 'text',
content: rawContent,
raw: rawContent,
} as AngularRenderableNode
}
get htmlRenderNode() {
return coerceBuiltinHtmlNode(this.node, this.resolvedType)
}
get fallbackNode() {
return this.node
}
get resolvedComponentInputs() {
return resolveNodeOutletCustomInputs(this.node, this.context)
}
}
@@ -0,0 +1,762 @@
import type { AfterViewInit, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core'
import type { RenderedHtmlEnhancementHandle } from '../../enhanceRenderedHtml'
import type {
AngularRenderableNode,
AngularRenderContext,
NodeRendererEvents,
NodeRendererProps,
} from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
inject,
Input,
Output,
ViewChild,
} from '@angular/core'
import { getCustomNodeComponents, subscribeCustomComponents } from '../../customComponents'
import { disposeRenderedHtmlEnhancements, enhanceRenderedHtml } from '../../enhanceRenderedHtml'
import { NodeOutletComponent } from '../NodeOutlet/NodeOutlet.component'
import {
buildRenderContext,
resolveParsedNodes,
} from '../shared/node-helpers'
import {
computeLiveRange,
estimateHeightRange,
resolveAverageNodeHeight,
resolveDeferNodes,
resolveVirtualizationEnabled,
} from '../shared/render-window'
interface VisibleNodeEntry {
node: AngularRenderableNode
index: number
render: boolean
animate: boolean
placeholderHeight: number
}
interface ObserverConfig {
root: Element | null
rootMargin: string
threshold: number
}
interface IdleDeadlineLike {
timeRemaining?: () => number
}
const SCROLL_PARENT_OVERFLOW_RE = /auto|scroll|overlay/i
@Component({
selector: 'markstream-angular',
standalone: true,
imports: [CommonModule, NodeOutletComponent],
template: `
<div
#root
class="markstream-angular markdown-renderer"
[class.dark]="isDark"
[class.virtualized]="virtualizationEnabled"
(click)="click.emit($event)"
(mouseover)="handleMouseOver($event)"
(mouseout)="handleMouseOut($event)"
>
<div
*ngIf="topSpacerHeight > 0"
class="node-spacer"
[style.height.px]="topSpacerHeight"
aria-hidden="true"
></div>
<div
*ngFor="let entry of visibleEntries; trackBy: trackByEntry"
class="node-slot"
[attr.data-node-index]="entry.index"
[attr.data-node-type]="entry.node.type"
>
<div
*ngIf="entry.render; else nodePlaceholder"
class="node-content"
[class.typewriter-node]="entry.animate"
[attr.data-node-index]="entry.index"
>
<markstream-angular-node-outlet
[node]="entry.node"
[context]="renderContext"
[indexKey]="entryIndexKey(entry.index)"
/>
</div>
<ng-template #nodePlaceholder>
<div class="node-placeholder" [style.height.px]="entry.placeholderHeight"></div>
</ng-template>
</div>
<div
*ngIf="bottomSpacerHeight > 0"
class="node-spacer"
[style.height.px]="bottomSpacerHeight"
aria-hidden="true"
></div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NodeRendererComponent implements NodeRendererProps, OnChanges, OnInit, AfterViewInit, OnDestroy {
private readonly cdr = inject(ChangeDetectorRef)
private readonly hostRef = inject(ElementRef<HTMLElement>)
@ViewChild('root') private rootRef?: ElementRef<HTMLElement>
@Input() content = ''
@Input() nodes?: readonly AngularRenderableNode[] | null
@Input() final?: boolean
@Input() parseOptions?: NodeRendererProps['parseOptions']
@Input() customMarkdownIt?: NodeRendererProps['customMarkdownIt']
@Input() debugPerformance?: boolean
@Input() customHtmlTags?: readonly string[]
@Input() viewportPriority?: boolean
@Input() codeBlockStream = true
@Input() codeBlockDarkTheme?: any
@Input() codeBlockLightTheme?: any
@Input() codeBlockMonacoOptions?: Record<string, any>
@Input() renderCodeBlocksAsPre = false
@Input() codeBlockMinWidth?: string | number
@Input() codeBlockMaxWidth?: string | number
@Input() codeBlockProps?: Record<string, any>
@Input() mermaidProps?: Record<string, any>
@Input() d2Props?: Record<string, any>
@Input() infographicProps?: Record<string, any>
@Input() customComponents?: NodeRendererProps['customComponents']
@Input() showTooltips = true
@Input() themes?: string[]
@Input() isDark = false
@Input() customId?: string
@Input() indexKey?: number | string
@Input() typewriter = true
@Input() batchRendering = true
@Input() initialRenderBatchSize = 40
@Input() renderBatchSize = 80
@Input() renderBatchDelay = 16
@Input() renderBatchBudgetMs = 6
@Input() renderBatchIdleTimeoutMs = 120
@Input() deferNodesUntilVisible = true
@Input() maxLiveNodes = 320
@Input() liveNodeBuffer = 60
@Input() allowHtml = true
@Output() copy = new EventEmitter<string>()
@Output() handleArtifactClick = new EventEmitter<any>()
@Output() click = new EventEmitter<MouseEvent>()
@Output() mouseover = new EventEmitter<MouseEvent>()
@Output() mouseout = new EventEmitter<MouseEvent>()
parsedNodes: AngularRenderableNode[] = []
visibleEntries: VisibleNodeEntry[] = []
renderContext: AngularRenderContext = { events: {} }
topSpacerHeight = 0
bottomSpacerHeight = 0
private renderedCount = 0
private focusIndex = 0
private liveRange = { start: 0, end: 0 }
private readonly nodeHeights = new Map<number, number>()
private readonly nodeVisibility = new Set<number>()
private readonly nodeSeen = new Set<number>()
private readonly textStreamState = new Map<string, string>()
private streamRenderVersion = 0
private readonly slotElements = new Map<number, HTMLElement>()
private readonly observedElements = new Map<number, HTMLElement>()
private observer: IntersectionObserver | null = null
private observerConfig: ObserverConfig | null = null
private readonly observerIndexByElement = new Map<Element, number>()
private batchTimer: number | null = null
private batchRaf: number | null = null
private batchIdle: number | null = null
private batchPending = false
private enhancementToken = 0
private enhancementTimer: number | null = null
private enhancementHandle: RenderedHtmlEnhancementHandle | null = null
private postRenderTimer: number | null = null
private unsubscribeCustomComponents?: () => void
get resolvedIndexPrefix() {
return this.indexKey != null ? String(this.indexKey) : 'renderer'
}
get resolvedInitialBatchSize() {
return Math.max(1, Math.trunc(this.initialRenderBatchSize || 40))
}
get resolvedRenderBatchSize() {
return Math.max(1, Math.trunc(this.renderBatchSize || 80))
}
get resolvedMaxLiveNodes() {
return Math.max(1, Math.trunc(this.maxLiveNodes ?? 320))
}
get resolvedLiveNodeBuffer() {
return Math.max(0, Math.trunc(this.liveNodeBuffer ?? 60))
}
get liveWindowEnabled() {
const configuredMaxLiveNodes = Math.trunc(this.maxLiveNodes ?? 320)
return Number.isFinite(configuredMaxLiveNodes) && configuredMaxLiveNodes > 0
}
get viewportPriorityEnabled() {
return this.viewportPriority !== false
}
get virtualizationEnabled() {
return resolveVirtualizationEnabled(this.parsedNodes.length, this.maxLiveNodes)
}
get incrementalRenderingActive() {
return this.batchRendering !== false
&& typeof window !== 'undefined'
&& !this.liveWindowEnabled
&& !this.virtualizationEnabled
&& this.parsedNodes.length > this.resolvedInitialBatchSize
}
get deferNodesActive() {
return resolveDeferNodes({
deferNodesUntilVisible: this.deferNodesUntilVisible,
maxLiveNodes: this.maxLiveNodes,
parsedNodeCount: this.parsedNodes.length,
viewportPriority: this.viewportPriorityEnabled,
virtualizationEnabled: this.virtualizationEnabled,
})
}
get shouldObserveSlots() {
return this.deferNodesActive || this.virtualizationEnabled
}
ngOnChanges(changes: SimpleChanges) {
if (changes.content || changes.nodes)
this.streamRenderVersion += 1
this.rebuild()
}
ngOnInit() {
this.unsubscribeCustomComponents = subscribeCustomComponents(() => {
this.rebuild()
})
}
ngAfterViewInit() {
this.schedulePostRenderWork()
}
ngOnDestroy() {
this.stopBatching()
this.clearPostRenderWork()
this.enhancementToken += 1
this.disposeEnhancements()
this.unsubscribeCustomComponents?.()
this.unsubscribeCustomComponents = undefined
this.disconnectObserver()
if (this.enhancementTimer != null && typeof window !== 'undefined') {
window.clearTimeout(this.enhancementTimer)
this.enhancementTimer = null
}
}
trackByEntry = (_index: number, entry: VisibleNodeEntry) => {
return `${this.resolvedIndexPrefix}-${entry.index}`
}
entryIndexKey(index: number) {
return `${this.resolvedIndexPrefix}-${index}`
}
handleMouseOver(event: MouseEvent) {
const target = event.target as HTMLElement | null
if (target?.closest('[data-node-index]'))
this.mouseover.emit(event)
}
handleMouseOut(event: MouseEvent) {
const target = event.target as HTMLElement | null
if (target?.closest('[data-node-index]'))
this.mouseout.emit(event)
}
private rebuild() {
this.stopBatching()
this.disposeEnhancements()
this.cleanupTransientState()
const scopedCustomComponents = getCustomNodeComponents(this.customId)
const mergedCustomComponents = this.customComponents
? {
...scopedCustomComponents,
...this.customComponents,
}
: scopedCustomComponents
const events: NodeRendererEvents = {
onCopy: code => this.copy.emit(code),
onHandleArtifactClick: payload => this.handleArtifactClick.emit(payload),
}
this.renderContext = buildRenderContext(
{
...this,
customComponents: mergedCustomComponents,
},
events,
this.textStreamState,
this.streamRenderVersion,
)
this.parsedNodes = resolveParsedNodes(this)
this.trimMeasuredState()
const total = this.parsedNodes.length
this.focusIndex = Math.max(0, Math.min(this.focusIndex, Math.max(0, total - 1)))
if (this.virtualizationEnabled) {
this.renderedCount = total
this.liveRange = computeLiveRange(total, this.focusIndex, this.resolvedMaxLiveNodes, this.resolvedLiveNodeBuffer)
}
else {
this.liveRange = { start: 0, end: total }
this.renderedCount = this.incrementalRenderingActive
? Math.min(total, this.resolvedInitialBatchSize)
: total
}
if (!this.deferNodesActive)
this.nodeVisibility.clear()
this.refreshVisibleEntries()
this.cdr.markForCheck()
this.schedulePostRenderWork()
if (this.incrementalRenderingActive && this.renderedCount < total)
this.scheduleBatchStep()
}
private cleanupTransientState() {
this.slotElements.clear()
this.disconnectObserver()
}
private trimMeasuredState() {
const total = this.parsedNodes.length
for (const index of Array.from(this.nodeHeights.keys())) {
if (index >= total)
this.nodeHeights.delete(index)
}
for (const index of Array.from(this.nodeSeen)) {
if (index >= total)
this.nodeSeen.delete(index)
}
for (const index of Array.from(this.nodeVisibility)) {
if (index >= total)
this.nodeVisibility.delete(index)
}
}
private refreshVisibleEntries() {
const averageNodeHeight = resolveAverageNodeHeight(this.nodeHeights)
const total = this.parsedNodes.length
const source = this.virtualizationEnabled
? this.parsedNodes.slice(this.liveRange.start, this.liveRange.end).map((node, offset) => ({
node,
index: this.liveRange.start + offset,
}))
: this.parsedNodes.map((node, index) => ({ node, index }))
this.topSpacerHeight = this.virtualizationEnabled
? estimateHeightRange(0, Math.min(this.liveRange.start, total), this.nodeHeights, averageNodeHeight)
: 0
this.bottomSpacerHeight = this.virtualizationEnabled
? estimateHeightRange(Math.min(this.liveRange.end, total), total, this.nodeHeights, averageNodeHeight)
: 0
this.visibleEntries = source.map(({ node, index }) => {
const render = this.shouldRenderNode(index)
let animate = false
if (
render
&& this.typewriter !== false
&& String((node as any)?.type || '') !== 'code_block'
&& !this.nodeSeen.has(index)
) {
animate = true
this.nodeSeen.add(index)
}
return {
node,
index,
render,
animate,
placeholderHeight: this.nodeHeights.get(index) ?? averageNodeHeight,
}
})
}
private shouldRenderNode(index: number) {
if (this.incrementalRenderingActive && index >= this.renderedCount)
return false
if (!this.deferNodesActive)
return true
if (index < this.resolvedInitialBatchSize)
return true
return this.nodeVisibility.has(index)
}
private scheduleBatchStep() {
if (!this.incrementalRenderingActive || typeof window === 'undefined')
return
if (this.batchPending || this.renderedCount >= this.parsedNodes.length)
return
this.batchPending = true
const run = (deadline?: IdleDeadlineLike) => {
this.batchPending = false
this.batchTimer = null
this.batchRaf = null
this.batchIdle = null
const budget = Math.max(2, Math.trunc(this.renderBatchBudgetMs || 6))
let nextCount = this.renderedCount
do {
nextCount = Math.min(this.parsedNodes.length, nextCount + this.resolvedRenderBatchSize)
if (!deadline)
break
const remaining = typeof deadline.timeRemaining === 'function' ? deadline.timeRemaining() : 0
if (remaining <= budget * 0.5)
break
} while (nextCount < this.parsedNodes.length)
if (nextCount !== this.renderedCount) {
this.renderedCount = nextCount
this.refreshVisibleEntries()
this.cdr.markForCheck()
this.schedulePostRenderWork()
}
if (this.renderedCount < this.parsedNodes.length)
this.scheduleBatchStep()
}
const idleCallback = (window as any).requestIdleCallback as ((cb: (deadline: IdleDeadlineLike) => void, opts?: { timeout?: number }) => number) | undefined
const delay = Math.max(0, Math.trunc(this.renderBatchDelay || 0))
if (idleCallback) {
this.batchIdle = idleCallback(run, { timeout: Math.max(0, Math.trunc(this.renderBatchIdleTimeoutMs || 120)) })
return
}
if (typeof window.requestAnimationFrame === 'function') {
this.batchRaf = window.requestAnimationFrame(() => {
if (delay === 0) {
run()
return
}
this.batchTimer = window.setTimeout(() => run(), delay)
})
return
}
this.batchTimer = window.setTimeout(() => run(), delay)
}
private stopBatching() {
this.batchPending = false
if (this.batchTimer != null && typeof window !== 'undefined') {
window.clearTimeout(this.batchTimer)
this.batchTimer = null
}
if (this.batchRaf != null && typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') {
window.cancelAnimationFrame(this.batchRaf)
this.batchRaf = null
}
const cancelIdle = (typeof window !== 'undefined' ? (window as any).cancelIdleCallback : null) as ((id: number) => void) | null
if (this.batchIdle != null && cancelIdle) {
cancelIdle(this.batchIdle)
this.batchIdle = null
}
}
private schedulePostRenderWork() {
if (typeof window === 'undefined')
return
this.clearPostRenderWork()
this.postRenderTimer = window.setTimeout(() => {
this.postRenderTimer = null
this.syncSlotObservers()
this.measureNodeHeights()
this.scheduleEnhancements()
}, 0)
}
private clearPostRenderWork() {
if (this.postRenderTimer != null && typeof window !== 'undefined') {
window.clearTimeout(this.postRenderTimer)
this.postRenderTimer = null
}
}
private syncSlotObservers() {
const root = this.rootRef?.nativeElement
if (!root)
return
const currentIndices = new Set<number>()
const slots = Array.from(root.querySelectorAll<HTMLElement>('.node-slot[data-node-index]'))
for (const slot of slots) {
const index = Number(slot.dataset.nodeIndex)
if (!Number.isFinite(index))
continue
currentIndices.add(index)
this.slotElements.set(index, slot)
this.observeSlot(index, slot)
}
for (const [index] of Array.from(this.slotElements.entries())) {
if (currentIndices.has(index))
continue
this.slotElements.delete(index)
this.unobserveIndex(index)
}
}
private observeSlot(index: number, slot: HTMLElement) {
if (!this.shouldObserveSlots) {
this.unobserveIndex(index)
this.markNodeVisible(index)
return
}
if (index < this.resolvedInitialBatchSize && !this.virtualizationEnabled) {
this.unobserveIndex(index)
this.markNodeVisible(index)
return
}
const existing = this.observedElements.get(index)
if (existing === slot)
return
this.unobserveIndex(index)
const observer = this.ensureObserver('400px', 0)
if (!observer) {
this.markNodeVisible(index)
return
}
this.observedElements.set(index, slot)
this.observerIndexByElement.set(slot, index)
observer.observe(slot)
}
private ensureObserver(rootMargin: string, threshold: number) {
if (typeof window === 'undefined')
return null
if (typeof IntersectionObserver === 'undefined')
return null
if (!this.viewportPriorityEnabled && !this.virtualizationEnabled)
return null
const nextConfig: ObserverConfig = {
root: this.resolveViewportRoot(),
rootMargin,
threshold,
}
if (
this.observer
&& this.observerConfig
&& this.observerConfig.root === nextConfig.root
&& this.observerConfig.rootMargin === nextConfig.rootMargin
&& this.observerConfig.threshold === nextConfig.threshold
) {
return this.observer
}
this.disconnectObserver()
this.observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!(entry.isIntersecting || entry.intersectionRatio > 0))
continue
const index = this.observerIndexByElement.get(entry.target)
if (index == null)
continue
this.unobserveIndex(index)
this.markNodeVisible(index)
}
}, nextConfig)
this.observerConfig = nextConfig
return this.observer
}
private resolveViewportRoot() {
const base = this.rootRef?.nativeElement || this.hostRef.nativeElement
if (!base || typeof window === 'undefined')
return null
const doc = base.ownerDocument || document
const rootScrollable = doc.scrollingElement || doc.documentElement
let current = base.parentElement
while (current) {
if (current === doc.body || current === rootScrollable)
break
const style = window.getComputedStyle(current)
const overflowY = (style.overflowY || '').toLowerCase()
const overflow = (style.overflow || '').toLowerCase()
if (SCROLL_PARENT_OVERFLOW_RE.test(overflowY) || SCROLL_PARENT_OVERFLOW_RE.test(overflow))
return current
current = current.parentElement
}
return null
}
private markNodeVisible(index: number) {
let changed = false
if (this.deferNodesActive && !this.nodeVisibility.has(index)) {
this.nodeVisibility.add(index)
changed = true
}
if (this.virtualizationEnabled && index > this.focusIndex) {
this.focusIndex = index
this.liveRange = computeLiveRange(
this.parsedNodes.length,
this.focusIndex,
this.resolvedMaxLiveNodes,
this.resolvedLiveNodeBuffer,
)
changed = true
}
if (!changed)
return
this.refreshVisibleEntries()
this.cdr.markForCheck()
this.schedulePostRenderWork()
}
private unobserveIndex(index: number) {
const element = this.observedElements.get(index)
if (!element)
return
try {
this.observer?.unobserve(element)
}
catch {
// Ignore observer cleanup failures.
}
this.observerIndexByElement.delete(element)
this.observedElements.delete(index)
}
private disconnectObserver() {
try {
this.observer?.disconnect()
}
catch {
// Ignore observer cleanup failures.
}
this.observer = null
this.observerConfig = null
this.observerIndexByElement.clear()
this.observedElements.clear()
}
private measureNodeHeights() {
const root = this.rootRef?.nativeElement
if (!root)
return
let changed = false
const contentNodes = Array.from(root.querySelectorAll<HTMLElement>('.node-content[data-node-index]'))
for (const element of contentNodes) {
const index = Number(element.dataset.nodeIndex)
if (!Number.isFinite(index))
continue
const height = element.offsetHeight
if (!height)
continue
if (this.nodeHeights.get(index) !== height) {
this.nodeHeights.set(index, height)
changed = true
}
}
if (!changed)
return
this.refreshVisibleEntries()
this.cdr.markForCheck()
}
private scheduleEnhancements() {
if (typeof window === 'undefined')
return
const token = ++this.enhancementToken
if (this.enhancementTimer != null)
window.clearTimeout(this.enhancementTimer)
this.enhancementTimer = window.setTimeout(() => {
this.enhancementTimer = null
void this.applyEnhancements(token)
}, 0)
}
private async applyEnhancements(token: number) {
if (token !== this.enhancementToken)
return
const root = this.rootRef?.nativeElement || this.hostRef.nativeElement.querySelector('.markstream-angular') as HTMLElement | null
if (!root)
return
const handle = await enhanceRenderedHtml(root, {
final: this.final,
isDark: this.isDark,
renderCodeBlocksAsPre: this.renderCodeBlocksAsPre,
monacoOptions: this.codeBlockMonacoOptions,
d2ThemeId: this.d2Props?.themeId ?? null,
d2DarkThemeId: this.d2Props?.darkThemeId ?? null,
showTooltips: this.showTooltips,
codeBlockProps: this.codeBlockProps,
mermaidProps: this.mermaidProps,
d2Props: this.d2Props,
infographicProps: this.infographicProps,
onCopy: code => this.copy.emit(code),
isCancelled: () => token !== this.enhancementToken,
})
if (token !== this.enhancementToken) {
handle.dispose()
return
}
this.enhancementHandle = handle
this.measureNodeHeights()
}
private disposeEnhancements() {
this.enhancementHandle?.dispose()
this.enhancementHandle = null
const root = this.rootRef?.nativeElement || this.hostRef.nativeElement.querySelector('.markstream-angular') as HTMLElement | null
if (root)
disposeRenderedHtmlEnhancements(root)
}
}
@@ -0,0 +1,125 @@
import type { OnChanges } from '@angular/core'
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { NodeOutletComponent } from '../NodeOutlet/NodeOutlet.component'
import {
getNodeList,
getString,
isMediaOnlyParagraphNodes,
isWhitespaceTextNode,
normalizeMediaOnlyParagraphNodes,
splitParagraphChildren,
} from '../shared/node-helpers'
type ParagraphSegment
= | { kind: 'inline', nodes: AngularRenderableNode[], mediaOnly: boolean }
| { kind: 'block', node: AngularRenderableNode }
@Component({
selector: 'markstream-angular-paragraph-node',
standalone: true,
imports: [
CommonModule,
forwardRef(() => NestedRendererComponent),
forwardRef(() => NodeOutletComponent),
],
template: `
<ng-container *ngIf="segments.length > 0; else simpleParagraph">
<ng-container *ngFor="let segment of segments; let idx = index; trackBy: trackByIndex">
<p *ngIf="segment.kind === 'inline'; else blockSegment" dir="auto" class="paragraph-node">
<ng-container *ngIf="$any(segment).mediaOnly; else nestedInline">
<ng-container *ngFor="let child of $any(segment).nodes; let childIdx = index; trackBy: trackByChildIndex">
<ng-container *ngIf="isWhitespaceText(child); else inlineNode">{{ getTextContent(child) }}</ng-container>
<ng-template #inlineNode>
<markstream-angular-node-outlet
[node]="child"
[context]="context"
[indexKey]="childPrefix(idx, childIdx)"
/>
</ng-template>
</ng-container>
</ng-container>
<ng-template #nestedInline>
<markstream-angular-nested-renderer
[nodes]="$any(segment).nodes"
[context]="context"
[indexPrefix]="segmentPrefix(idx)"
/>
</ng-template>
</p>
<ng-template #blockSegment>
<markstream-angular-node-outlet
[node]="$any(segment).node"
[context]="context"
[indexKey]="segmentPrefix(idx)"
/>
</ng-template>
</ng-container>
</ng-container>
<ng-template #simpleParagraph>
<p dir="auto" class="paragraph-node">
<markstream-angular-nested-renderer
[nodes]="children"
[context]="context"
[indexPrefix]="indexKey || 'paragraph'"
/>
</p>
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ParagraphNodeComponent implements OnChanges {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
segments: ParagraphSegment[] = []
get children() {
return getNodeList((this.node as any)?.children)
}
ngOnChanges() {
this.segments = splitParagraphChildren(this.children).map((segment) => {
if (segment.kind === 'block')
return segment
const mediaOnly = isMediaOnlyParagraphNodes(segment.nodes)
return {
kind: 'inline',
nodes: mediaOnly ? normalizeMediaOnlyParagraphNodes(segment.nodes) : segment.nodes,
mediaOnly,
}
})
}
trackByIndex = (index: number) => {
return `${this.indexKey || 'paragraph'}-${index}`
}
trackByChildIndex = (index: number) => {
return index
}
segmentPrefix(index: number) {
return `${this.indexKey || 'paragraph'}-${index}`
}
childPrefix(segmentIndex: number, childIndex: number) {
return `${this.segmentPrefix(segmentIndex)}-${childIndex}`
}
isWhitespaceText(node: AngularRenderableNode) {
return isWhitespaceTextNode(node)
}
getTextContent(node: AngularRenderableNode) {
return getString((node as any)?.content)
}
}
@@ -0,0 +1,33 @@
import type { AngularRenderableNode } from '../shared/node-helpers'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { getString, normalizeCodeLanguage } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-pre-code-node',
standalone: true,
template: `<pre [class]="languageClass" [attr.aria-busy]="loading" [attr.aria-label]="ariaLabel" [attr.data-language]="language" tabindex="0"><code translate="no" [textContent]="code"></code></pre>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PreCodeNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
get language() {
return normalizeCodeLanguage((this.node as any)?.language)
}
get languageClass() {
return `language-${this.language}`
}
get ariaLabel() {
return this.language ? `Code block: ${this.language}` : 'Code block'
}
get code() {
return getString((this.node as any)?.code)
}
get loading() {
return (this.node as any)?.loading === true
}
}
@@ -0,0 +1,17 @@
import type { AngularRenderableNode } from '../shared/node-helpers'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { getString } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-reference-node',
standalone: true,
template: '<span class="markstream-nested-reference">{{ id }}</span>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ReferenceNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
get id() {
return getString((this.node as any)?.id)
}
}
@@ -0,0 +1,34 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { getNodeList } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-strikethrough-node',
standalone: true,
imports: [CommonModule, forwardRef(() => NestedRendererComponent)],
template: `
<del>
<markstream-angular-nested-renderer
[nodes]="children"
[context]="context"
[indexPrefix]="nestedPrefix"
/>
</del>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StrikethroughNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get children() {
return getNodeList((this.node as any)?.children)
}
get nestedPrefix() {
return `${this.indexKey || 'strikethrough'}-strike`
}
}
@@ -0,0 +1,34 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { getNodeList } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-strong-node',
standalone: true,
imports: [CommonModule, forwardRef(() => NestedRendererComponent)],
template: `
<strong>
<markstream-angular-nested-renderer
[nodes]="children"
[context]="context"
[indexPrefix]="nestedPrefix"
/>
</strong>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StrongNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get children() {
return getNodeList((this.node as any)?.children)
}
get nestedPrefix() {
return `${this.indexKey || 'strong'}-strong`
}
}
@@ -0,0 +1,34 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { getNodeList } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-subscript-node',
standalone: true,
imports: [CommonModule, forwardRef(() => NestedRendererComponent)],
template: `
<sub>
<markstream-angular-nested-renderer
[nodes]="children"
[context]="context"
[indexPrefix]="nestedPrefix"
/>
</sub>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SubscriptNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get children() {
return getNodeList((this.node as any)?.children)
}
get nestedPrefix() {
return `${this.indexKey || 'subscript'}-sub`
}
}
@@ -0,0 +1,34 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { getNodeList } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-superscript-node',
standalone: true,
imports: [CommonModule, forwardRef(() => NestedRendererComponent)],
template: `
<sup>
<markstream-angular-nested-renderer
[nodes]="children"
[context]="context"
[indexPrefix]="nestedPrefix"
/>
</sup>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SuperscriptNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get children() {
return getNodeList((this.node as any)?.children)
}
get nestedPrefix() {
return `${this.indexKey || 'superscript'}-sup`
}
}
@@ -0,0 +1,195 @@
import type { ElementRef, OnDestroy } from '@angular/core'
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, inject, Input, ViewChild } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { getNodeList, getString } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-table-node',
standalone: true,
imports: [CommonModule, forwardRef(() => NestedRendererComponent)],
template: `
<table #tableElement>
<colgroup *ngIf="columnWidths.length > 0">
<col
*ngFor="let cell of headerCells; let cellIndex = index; trackBy: trackCell"
[style.width.px]="columnWidths[cellIndex] || null"
>
</colgroup>
<thead *ngIf="headerRow">
<tr>
<ng-container *ngFor="let cell of headerCells; let cellIndex = index; trackBy: trackCell">
<th [style.text-align]="alignment(cell) || null">
<markstream-angular-nested-renderer
[nodes]="childrenFor(cell)"
[context]="context"
[indexPrefix]="cellPrefix('head', cellIndex)"
/>
<button
*ngIf="cellIndex < headerCells.length - 1"
type="button"
class="table-node__resize-handle"
[attr.aria-label]="'Resize columns ' + (cellIndex + 1) + ' and ' + (cellIndex + 2)"
(pointerdown)="startColumnResize(cellIndex, $event)"
></button>
</th>
</ng-container>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of rows; let rowIndex = index; trackBy: trackRow">
<ng-container *ngFor="let cell of cellsFor(row); let cellIndex = index; trackBy: trackCell">
<td [style.text-align]="alignment(cell) || null">
<markstream-angular-nested-renderer
[nodes]="childrenFor(cell)"
[context]="context"
[indexPrefix]="cellPrefix(rowIndex, cellIndex)"
/>
</td>
</ng-container>
</tr>
</tbody>
</table>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TableNodeComponent implements OnDestroy {
@ViewChild('tableElement') tableElement?: ElementRef<HTMLTableElement>
private readonly cdr = inject(ChangeDetectorRef)
private currentHeaderCellCount = 0
private readonly minColumnWidth = 48
private resizeState: {
index: number
startX: number
startWidth: number
nextStartWidth: number
widths: number[]
} | null = null
columnWidths: number[] = []
private _node!: AngularRenderableNode
@Input({ required: true })
set node(value: AngularRenderableNode) {
this._node = value
const count = this.headerCells.length
if (count !== this.currentHeaderCellCount) {
this.stopColumnResize()
this.columnWidths = []
this.currentHeaderCellCount = count
}
}
get node() {
return this._node
}
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get headerRow() {
const row = (this.node as any)?.header
return row && typeof row === 'object' ? row as AngularRenderableNode : null
}
get headerCells() {
return this.headerRow ? this.cellsFor(this.headerRow) : []
}
get rows() {
return getNodeList((this.node as any)?.rows)
}
cellsFor(row: AngularRenderableNode) {
return getNodeList((row as any)?.cells)
}
childrenFor(cell: AngularRenderableNode) {
return getNodeList((cell as any)?.children)
}
alignment(cell: AngularRenderableNode) {
return getString((cell as any)?.align)
}
trackRow = (index: number) => {
return `${this.indexKey || 'table'}-row-${index}`
}
trackCell = (index: number) => {
return index
}
cellPrefix(rowIndex: string | number, cellIndex: number) {
return `${this.indexKey || 'table'}-${rowIndex}-${cellIndex}`
}
startColumnResize(index: number, event: PointerEvent) {
if (event.button !== 0)
return
const widths = this.measureHeaderWidths()
const startWidth = widths[index]
const nextStartWidth = widths[index + 1]
if (!startWidth || !nextStartWidth)
return
event.preventDefault()
this.stopColumnResize()
this.resizeState = {
index,
startX: event.clientX,
startWidth,
nextStartWidth,
widths,
}
this.columnWidths = widths
this.cdr.markForCheck()
window.addEventListener('pointermove', this.onColumnResizeMove)
window.addEventListener('pointerup', this.stopColumnResize)
window.addEventListener('pointercancel', this.stopColumnResize)
}
ngOnDestroy() {
this.stopColumnResize()
}
private measureHeaderWidths() {
const cells = this.tableElement?.nativeElement.querySelectorAll('thead th')
return Array.from(cells ?? [], cell => Math.round(cell.getBoundingClientRect().width))
}
private onColumnResizeMove = (event: PointerEvent) => {
if (!this.resizeState)
return
event.preventDefault()
const pairWidth = this.resizeState.startWidth + this.resizeState.nextStartWidth
const minWidth = Math.min(this.minColumnWidth, Math.floor(pairWidth / 2))
const width = Math.max(
minWidth,
Math.min(pairWidth - minWidth, Math.round(this.resizeState.startWidth + event.clientX - this.resizeState.startX)),
)
const nextWidths = [...this.resizeState.widths]
nextWidths[this.resizeState.index] = width
nextWidths[this.resizeState.index + 1] = pairWidth - width
this.columnWidths = nextWidths
this.cdr.markForCheck()
}
private stopColumnResize = () => {
if (!this.resizeState)
return
window.removeEventListener('pointermove', this.onColumnResizeMove)
window.removeEventListener('pointerup', this.stopColumnResize)
window.removeEventListener('pointercancel', this.stopColumnResize)
this.resizeState = null
}
}
@@ -0,0 +1,121 @@
import type { OnChanges } from '@angular/core'
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { getString } from '../shared/node-helpers'
import { resolveStreamingTextState } from './streamingTextState'
@Component({
selector: 'markstream-angular-text-node',
standalone: true,
imports: [CommonModule],
template: `
<ng-container *ngIf="!centered; else centeredText">
<span class="markstream-angular-text-node">
<span *ngIf="settledText">{{ settledText }}</span>
<span
*ngIf="streamedDelta"
[class]="'markstream-angular-text__stream-delta ' + streamedDeltaClass"
(animationend)="settleStreamedDelta()"
>{{ streamedDelta }}</span>
</span>
</ng-container>
<ng-template #centeredText>
<span class="markstream-angular-text-node markstream-angular-text--centered">
<span *ngIf="settledText">{{ settledText }}</span>
<span
*ngIf="streamedDelta"
[class]="'markstream-angular-text__stream-delta ' + streamedDeltaClass"
(animationend)="settleStreamedDelta()"
>{{ streamedDelta }}</span>
</span>
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TextNodeComponent implements OnChanges {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
@Input() typewriter?: boolean
settledText = ''
streamedDelta = ''
private streamFadeVersion = 0
private lastStreamRenderVersion?: number
ngOnChanges() {
const nextText = getString((this.node as any)?.content)
const streamStateKey = getString(this.indexKey).trim()
const textStreamState = this.context?.textStreamState
const streamRenderVersion = this.context?.streamRenderVersion
const streamRenderVersionChanged = streamRenderVersion !== this.lastStreamRenderVersion
const rendered = `${this.settledText}${this.streamedDelta}`
const previousPersisted = streamStateKey
? textStreamState?.get(streamStateKey)
: undefined
const previousText = previousPersisted ?? rendered
if (!this.typewriterEnabled) {
this.settledText = nextText
this.streamedDelta = ''
if (streamStateKey)
textStreamState?.set(streamStateKey, nextText)
this.lastStreamRenderVersion = streamRenderVersion
return
}
if (nextText === previousText) {
if (this.streamedDelta && streamRenderVersionChanged) {
this.settleStreamedDelta()
}
else if (rendered !== nextText) {
this.settledText = nextText
this.streamedDelta = ''
}
if (streamStateKey)
textStreamState?.set(streamStateKey, nextText)
this.lastStreamRenderVersion = streamRenderVersion
return
}
const nextState = resolveStreamingTextState({
nextContent: nextText,
previousContent: previousText,
typewriterEnabled: this.typewriterEnabled,
})
this.settledText = nextState.settledContent
this.streamedDelta = nextState.streamedDelta
if (nextState.appended)
this.streamFadeVersion += 1
if (streamStateKey)
textStreamState?.set(streamStateKey, nextText)
this.lastStreamRenderVersion = streamRenderVersion
}
get centered() {
return !!(this.node as any)?.center
}
get streamedDeltaClass() {
return this.streamFadeVersion % 2 === 0
? 'markstream-angular-text__stream-delta--a'
: 'markstream-angular-text__stream-delta--b'
}
get typewriterEnabled() {
if (typeof this.typewriter === 'boolean')
return this.typewriter
if (typeof this.context?.typewriter === 'boolean')
return this.context.typewriter
return true
}
settleStreamedDelta() {
if (!this.streamedDelta)
return
this.settledText = `${this.settledText}${this.streamedDelta}`
this.streamedDelta = ''
}
}
@@ -0,0 +1,47 @@
export interface ResolveStreamingTextStateOptions {
nextContent: string
previousContent: string
typewriterEnabled: boolean
}
export interface StreamingTextStateResult {
settledContent: string
streamedDelta: string
appended: boolean
}
export function resolveStreamingTextState({
nextContent,
previousContent,
typewriterEnabled,
}: ResolveStreamingTextStateOptions): StreamingTextStateResult {
if (!typewriterEnabled) {
return {
settledContent: nextContent,
streamedDelta: '',
appended: false,
}
}
if (nextContent === previousContent) {
return {
settledContent: nextContent,
streamedDelta: '',
appended: false,
}
}
if (previousContent && nextContent.startsWith(previousContent) && nextContent.length > previousContent.length) {
return {
settledContent: previousContent,
streamedDelta: nextContent.slice(previousContent.length),
appended: true,
}
}
return {
settledContent: nextContent,
streamedDelta: '',
appended: false,
}
}
@@ -0,0 +1,9 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
@Component({
selector: 'markstream-angular-thematic-break-node',
standalone: true,
template: '<hr>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ThematicBreakNodeComponent {}
@@ -0,0 +1,34 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { getNodeList } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-vmr-container-node',
standalone: true,
imports: [CommonModule, forwardRef(() => NestedRendererComponent)],
template: `
<div class="markstream-angular-vmr-container">
<markstream-angular-nested-renderer
[nodes]="children"
[context]="context"
[indexPrefix]="nestedPrefix"
/>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VmrContainerNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get children() {
return getNodeList((this.node as any)?.children)
}
get nestedPrefix() {
return `${this.indexKey || 'vmr'}-container`
}
}
@@ -0,0 +1,63 @@
export const MERMAID_PREVIEW_MIN_HEIGHT = 360
export const MERMAID_PREVIEW_MAX_HEIGHT = 500
export const INFOGRAPHIC_PREVIEW_MIN_HEIGHT = 360
export const INFOGRAPHIC_PREVIEW_MAX_HEIGHT = 500
export function parsePositiveNumber(value: unknown) {
const numeric = typeof value === 'number' ? value : Number.parseFloat(String(value ?? ''))
return Number.isFinite(numeric) && numeric > 0 ? numeric : null
}
export function getMermaidDiagramKind(code: string) {
for (const rawLine of code.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || line.startsWith('%%'))
continue
const match = line.match(/^([A-Z][\w-]*)\b/i)
return match?.[1]?.toLowerCase() || ''
}
return ''
}
export function estimateMermaidPreviewHeight(code: string) {
const meaningfulLines = code
.split(/\r?\n/)
.map(line => line.trim())
.filter(line => line && !line.startsWith('%%'))
const lineCount = Math.max(1, meaningfulLines.length)
const kind = getMermaidDiagramKind(code)
if (kind === 'gantt')
return 220 + lineCount * 28
if (kind === 'sequencediagram')
return 180 + lineCount * 26
if (kind === 'classdiagram' || kind === 'statediagram' || kind === 'erdiagram')
return 180 + lineCount * 24
if (kind === 'flowchart' || kind === 'graph')
return 170 + lineCount * 28
return 200 + lineCount * 22
}
export function estimateInfographicPreviewHeight(code: string) {
const itemCount = code
.split(/\r?\n/)
.filter(line => /^\s*-\s+/.test(line))
.length
if (itemCount >= 3)
return INFOGRAPHIC_PREVIEW_MAX_HEIGHT
if (itemCount > 0)
return 280 + itemCount * 60
return INFOGRAPHIC_PREVIEW_MIN_HEIGHT
}
export function clampPreviewHeight(
height: number,
minHeight = MERMAID_PREVIEW_MIN_HEIGHT,
maxHeight: number | null = MERMAID_PREVIEW_MAX_HEIGHT,
) {
return maxHeight == null
? Math.max(minHeight, height)
: Math.min(Math.max(minHeight, height), maxHeight)
}
@@ -0,0 +1,363 @@
import type { Type } from '@angular/core'
import type { BaseNode, MarkdownIt, ParsedNode, ParseOptions } from 'stream-markdown-parser'
import type { CodeBlockMonacoOptions, CodeBlockMonacoTheme } from '../../types/monaco'
import {
getHtmlTagFromContent,
getMarkdown,
hasCompleteHtmlTagContent,
normalizeCustomHtmlTags,
normalizeCustomHtmlTagName as normalizeTagName,
parseMarkdownToStructure,
stripCustomHtmlWrapper,
} from 'stream-markdown-parser'
import { hydrateCustomTagContent } from '../../hydrateCustomTagContent'
export {
getHtmlTagFromContent,
hasCompleteHtmlTagContent,
normalizeCustomHtmlTags,
normalizeTagName,
stripCustomHtmlWrapper,
}
export type AngularRenderableNode = (ParsedNode | BaseNode) & Record<string, unknown>
export interface NodeRendererEvents {
onCopy?: (code: string) => void
onHandleArtifactClick?: (payload: any) => void
}
export interface NodeRendererProps {
content?: string
nodes?: readonly BaseNode[] | null
final?: boolean
parseOptions?: ParseOptions
customMarkdownIt?: (md: MarkdownIt) => MarkdownIt
debugPerformance?: boolean
customHtmlTags?: readonly string[]
viewportPriority?: boolean
codeBlockStream?: boolean
codeBlockDarkTheme?: CodeBlockMonacoTheme
codeBlockLightTheme?: CodeBlockMonacoTheme
codeBlockMonacoOptions?: CodeBlockMonacoOptions
renderCodeBlocksAsPre?: boolean
codeBlockMinWidth?: string | number
codeBlockMaxWidth?: string | number
codeBlockProps?: Record<string, any>
mermaidProps?: Record<string, any>
d2Props?: Record<string, any>
infographicProps?: Record<string, any>
customComponents?: Record<string, Type<any>>
showTooltips?: boolean
themes?: CodeBlockMonacoTheme[]
isDark?: boolean
customId?: string
indexKey?: number | string
typewriter?: boolean
batchRendering?: boolean
initialRenderBatchSize?: number
renderBatchSize?: number
renderBatchDelay?: number
renderBatchBudgetMs?: number
renderBatchIdleTimeoutMs?: number
deferNodesUntilVisible?: boolean
maxLiveNodes?: number
liveNodeBuffer?: number
allowHtml?: boolean
}
export interface AngularRenderContext {
customId?: string
isDark?: boolean
indexKey?: string
final?: boolean
typewriter?: boolean
textStreamState?: Map<string, string>
streamRenderVersion?: number
showTooltips?: boolean
codeBlockStream?: boolean
renderCodeBlocksAsPre?: boolean
allowHtml?: boolean
customHtmlTags?: readonly string[]
parseOptions?: ParseOptions
customMarkdownIt?: (md: MarkdownIt) => MarkdownIt
codeBlockProps?: Record<string, any>
mermaidProps?: Record<string, any>
d2Props?: Record<string, any>
infographicProps?: Record<string, any>
customComponents?: Record<string, Type<any>>
codeBlockThemes?: {
themes?: CodeBlockMonacoTheme[]
darkTheme?: CodeBlockMonacoTheme
lightTheme?: CodeBlockMonacoTheme
monacoOptions?: CodeBlockMonacoOptions
minWidth?: string | number
maxWidth?: string | number
}
events: NodeRendererEvents
}
const markdownCache = new Map<string, MarkdownIt>()
export const BLOCK_LEVEL_TYPES = new Set([
'table',
'code_block',
'html_block',
'blockquote',
'list',
'list_item',
'definition_list',
'footnote',
'footnote_reference',
'footnote_anchor',
'admonition',
'thematic_break',
'math_block',
'thinking',
'vmr_container',
])
export function buildRenderContext(
props: NodeRendererProps,
events: NodeRendererEvents = {},
textStreamState?: Map<string, string>,
streamRenderVersion?: number,
): AngularRenderContext {
const customHtmlTags = normalizeCustomHtmlTags([
...(props.customHtmlTags || []),
...((((props.parseOptions as any)?.customHtmlTags) || []) as string[]),
])
return {
customId: props.customId,
isDark: props.isDark,
indexKey: props.indexKey != null ? String(props.indexKey) : undefined,
final: props.final,
typewriter: props.typewriter,
textStreamState,
streamRenderVersion,
showTooltips: props.showTooltips,
codeBlockStream: props.codeBlockStream,
renderCodeBlocksAsPre: props.renderCodeBlocksAsPre,
allowHtml: props.allowHtml !== false,
customHtmlTags,
parseOptions: props.parseOptions,
customMarkdownIt: props.customMarkdownIt,
codeBlockProps: props.codeBlockProps,
mermaidProps: props.mermaidProps,
d2Props: props.d2Props,
infographicProps: props.infographicProps,
customComponents: props.customComponents,
codeBlockThemes: {
themes: props.themes,
darkTheme: props.codeBlockDarkTheme,
lightTheme: props.codeBlockLightTheme,
monacoOptions: props.codeBlockMonacoOptions,
minWidth: props.codeBlockMinWidth,
maxWidth: props.codeBlockMaxWidth,
},
events,
}
}
export function resolveParsedNodes(props: NodeRendererProps): AngularRenderableNode[] {
if (Array.isArray(props.nodes))
return props.nodes as AngularRenderableNode[]
const content = getString(props.content)
if (!content)
return []
const normalizedTags = normalizeCustomHtmlTags([
...(props.customHtmlTags || []),
...((((props.parseOptions as any)?.customHtmlTags) || []) as string[]),
])
const cacheKey = `${props.customId || 'markstream-angular'}::${normalizedTags.join(',')}`
let markdown = markdownCache.get(cacheKey)
if (!markdown) {
markdown = getMarkdown(cacheKey, { customHtmlTags: normalizedTags })
markdownCache.set(cacheKey, markdown)
}
const parser = props.customMarkdownIt
? props.customMarkdownIt(markdown)
: markdown
const options: ParseOptions = {
...(props.parseOptions ?? {}),
}
if (typeof props.final === 'boolean')
options.final = props.final
if (normalizedTags.length > 0)
(options as any).customHtmlTags = normalizedTags
return hydrateCustomTagContent(
parseMarkdownToStructure(content, parser, options) as AngularRenderableNode[],
content,
normalizedTags,
) as AngularRenderableNode[]
}
export function getNodeList(value: unknown): AngularRenderableNode[] {
return Array.isArray(value)
? value.filter((item): item is AngularRenderableNode => !!item && typeof item === 'object')
: []
}
export function isWhitespaceTextNode(node: AngularRenderableNode | null | undefined) {
return getString((node as any)?.type) === 'text' && getString((node as any)?.content).trim() === ''
}
export function getMeaningfulLinkChildren(node: AngularRenderableNode | null | undefined) {
if (getString((node as any)?.type) !== 'link')
return []
return getNodeList((node as any)?.children).filter(child => !isWhitespaceTextNode(child))
}
export function isImageOnlyLinkNode(node: AngularRenderableNode | null | undefined) {
const linkChildren = getMeaningfulLinkChildren(node)
return linkChildren.length === 1 && getString((linkChildren[0] as any)?.type) === 'image'
}
export function isMediaOnlyParagraphNodes(children: readonly AngularRenderableNode[]) {
const meaningfulChildren = getNodeList(children).filter(child => !isWhitespaceTextNode(child))
return meaningfulChildren.length > 0
&& meaningfulChildren.every(child => getString((child as any)?.type) === 'image' || isImageOnlyLinkNode(child))
}
export function normalizeMediaOnlyParagraphNodes(children: readonly AngularRenderableNode[]) {
const source = getNodeList(children)
const meaningfulChildren = source.filter(child => !isWhitespaceTextNode(child))
if (!isMediaOnlyParagraphNodes(source) || meaningfulChildren.length <= 1)
return source
const normalized: AngularRenderableNode[] = []
for (let index = 0; index < source.length; index += 1) {
const child = source[index]
if (!isWhitespaceTextNode(child)) {
normalized.push(child)
continue
}
const hasPrevious = normalized.length > 0
const hasNext = source.slice(index + 1).some(nextChild => !isWhitespaceTextNode(nextChild))
if (!hasPrevious || !hasNext)
continue
normalized.push({
...(child as Record<string, unknown>),
content: ' ',
raw: ' ',
} as AngularRenderableNode)
}
return normalized
}
export function getString(value: unknown): string {
return typeof value === 'string'
? value
: value == null
? ''
: String(value)
}
export function isSafeAttrName(value: string): boolean {
return /^[^\s"'<>`=]+$/.test(value) && !/^on/i.test(value)
}
export function escapeHtml(value: unknown): string {
return getString(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
export function escapeAttr(value: unknown): string {
return escapeHtml(value).replace(/`/g, '&#96;')
}
export function sanitizeClassToken(value: string): string {
return value.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '')
}
export function clampHeadingLevel(value: unknown): number {
const level = Math.trunc(Number(value) || 1)
return Math.min(6, Math.max(1, level))
}
export function capitalize(value: string): string {
return value ? `${value[0].toUpperCase()}${value.slice(1)}` : ''
}
export function normalizeCodeLanguage(raw: unknown) {
const head = String(String(raw ?? '').split(/\s+/g)[0] ?? '').toLowerCase()
const safe = head.replace(/[^\w-]/g, '')
return safe || 'plaintext'
}
export function resolveCodeBlockLanguage(node: AngularRenderableNode) {
return normalizeCodeLanguage((node as any)?.language)
}
export function encodeDataPayload(value: string) {
if (!value)
return ''
const globalBuffer = (globalThis as any)?.require?.('buffer')?.Buffer
if (globalBuffer?.from)
return globalBuffer.from(value, 'utf8').toString('base64')
if (typeof TextEncoder !== 'undefined' && typeof globalThis.btoa === 'function') {
const bytes = new TextEncoder().encode(value)
let binary = ''
for (const byte of bytes)
binary += String.fromCharCode(byte)
return globalThis.btoa(binary)
}
return ''
}
export function normalizeTokenAttrs(attrs?: Array<[string, string | null]> | null) {
if (!Array.isArray(attrs) || attrs.length === 0)
return null
return attrs.reduce<Record<string, string | true>>((acc, [name, value]) => {
if (!name || !isSafeAttrName(name))
return acc
acc[name] = value ?? true
return acc
}, {})
}
export function splitParagraphChildren(children: readonly AngularRenderableNode[]) {
const parts: Array<
| { kind: 'inline', nodes: AngularRenderableNode[] }
| { kind: 'block', node: AngularRenderableNode }
> = []
const inlineBuffer: AngularRenderableNode[] = []
const flushInline = () => {
if (!inlineBuffer.length)
return
parts.push({ kind: 'inline', nodes: inlineBuffer.slice() })
inlineBuffer.length = 0
}
for (const child of children) {
if (BLOCK_LEVEL_TYPES.has(String(child?.type || ''))) {
flushInline()
parts.push({ kind: 'block', node: child })
}
else {
inlineBuffer.push(child)
}
}
flushInline()
return parts
}
@@ -0,0 +1,126 @@
import type { AngularRenderableNode, AngularRenderContext } from './node-helpers'
import { clampPreviewHeight, estimateInfographicPreviewHeight, estimateMermaidPreviewHeight, parsePositiveNumber } from './diagram-height'
import { getHtmlTagFromContent, resolveCodeBlockLanguage, stripCustomHtmlWrapper } from './node-helpers'
export type CodeBlockMode = 'mermaid' | 'd2' | 'infographic' | 'pre' | 'code'
export function resolveNodeOutletCodeMode(
node: AngularRenderableNode,
context?: AngularRenderContext,
): CodeBlockMode {
if (context?.renderCodeBlocksAsPre)
return 'pre'
const language = resolveCodeBlockLanguage(node)
if (language === 'd2' || language === 'd2lang')
return 'd2'
if (language === 'infographic')
return 'infographic'
if (language === 'mermaid')
return 'mermaid'
return 'code'
}
export function resolveHtmlTag(node: AngularRenderableNode) {
return String((node as any)?.tag || '').trim().toLowerCase() || getHtmlTagFromContent((node as any)?.content)
}
export function coerceCustomHtmlNode(node: AngularRenderableNode) {
const tag = resolveHtmlTag(node)
if (!tag)
return node
return {
...(node as any),
type: tag,
tag,
content: stripCustomHtmlWrapper((node as any)?.content, tag),
} as AngularRenderableNode
}
export function coerceBuiltinHtmlNode(node: AngularRenderableNode, resolvedType: string) {
const tag = resolveHtmlTag(node)
if (!tag)
return node
return {
...(node as any),
type: resolvedType,
tag,
} as AngularRenderableNode
}
export function resolveNodeOutletCustomInputs(
node: AngularRenderableNode,
context?: AngularRenderContext,
) {
if (String((node as any)?.type || '') !== 'code_block')
return null
const codeMode = resolveNodeOutletCodeMode(node, context)
if (codeMode === 'mermaid') {
return withEstimatedPreviewHeight(
context?.mermaidProps,
estimateMermaidPreviewHeight(getNodeCode(node)),
)
}
if (codeMode === 'd2')
return context?.d2Props ?? null
if (codeMode === 'infographic') {
return withEstimatedPreviewHeight(
context?.infographicProps,
estimateInfographicPreviewHeight(getNodeCode(node)),
)
}
return context?.codeBlockProps ?? null
}
function getNodeCode(node: AngularRenderableNode) {
return String((node as any)?.code ?? '')
}
function withEstimatedPreviewHeight(props: Record<string, any> | null | undefined, estimatedHeight: number) {
const next = { ...(props || {}) }
if (parsePositiveNumber(next.estimatedPreviewHeightPx) == null) {
next.estimatedPreviewHeightPx = clampPreviewHeight(
estimatedHeight,
undefined,
next.maxHeight === 'none' ? null : (parsePositiveNumber(next.maxHeight) ?? undefined),
)
}
return next
}
export function resolveNodeOutletCustomComponent(
node: AngularRenderableNode,
context?: AngularRenderContext,
customComponents?: Record<string, any> | null,
) {
const mapping = customComponents ?? context?.customComponents ?? null
const resolvedType = String((node as any)?.type || '')
if (resolvedType === 'code_block') {
const language = resolveCodeBlockLanguage(node)
const customForLanguage = language ? mapping?.[language] : null
if (customForLanguage)
return customForLanguage
const codeMode = resolveNodeOutletCodeMode(node, context)
if (codeMode === 'mermaid' && mapping?.mermaid)
return mapping.mermaid
if (codeMode === 'd2' && mapping?.d2)
return mapping.d2
if (codeMode === 'infographic' && mapping?.infographic)
return mapping.infographic
if (mapping?.code_block)
return mapping.code_block
}
const direct = mapping?.[resolvedType]
if (direct)
return direct
const tag = resolveHtmlTag(node)
if (tag && mapping?.[tag])
return mapping[tag]
return null
}
@@ -0,0 +1,99 @@
export const DEFAULT_NODE_HEIGHT = 32
export const MAX_DEFERRED_NODE_COUNT = 900
export interface LiveRange {
start: number
end: number
}
export interface DeferNodesOptions {
deferNodesUntilVisible?: boolean
maxLiveNodes?: number
parsedNodeCount: number
viewportPriority?: boolean
virtualizationEnabled: boolean
}
export function resolveVirtualizationEnabled(parsedNodeCount: number, maxLiveNodes?: number) {
const configuredMaxLiveNodes = Math.trunc(maxLiveNodes ?? 320)
if (!Number.isFinite(configuredMaxLiveNodes) || configuredMaxLiveNodes <= 0)
return false
const resolvedMaxLiveNodes = Math.max(1, configuredMaxLiveNodes)
return parsedNodeCount > resolvedMaxLiveNodes
}
export function resolveDeferNodes(options: DeferNodesOptions) {
if (options.deferNodesUntilVisible === false)
return false
const configuredMaxLiveNodes = Math.trunc(options.maxLiveNodes ?? 320)
if (!Number.isFinite(configuredMaxLiveNodes) || configuredMaxLiveNodes <= 0)
return false
if (options.virtualizationEnabled)
return false
if (options.parsedNodeCount > MAX_DEFERRED_NODE_COUNT)
return false
return options.viewportPriority !== false
}
export function resolveAverageNodeHeight(
nodeHeights: ReadonlyMap<number, number>,
fallback = DEFAULT_NODE_HEIGHT,
) {
if (!nodeHeights.size)
return fallback
let total = 0
for (const height of nodeHeights.values())
total += height
return Math.max(16, total / nodeHeights.size)
}
export function estimateHeightRange(
start: number,
end: number,
nodeHeights: ReadonlyMap<number, number>,
averageNodeHeight: number,
) {
if (start >= end)
return 0
let total = 0
for (let index = start; index < end; index += 1)
total += nodeHeights.get(index) ?? averageNodeHeight
return total
}
export function computeLiveRange(
total: number,
focusIndex: number,
maxLiveNodes?: number,
liveNodeBuffer?: number,
): LiveRange {
if (!total)
return { start: 0, end: 0 }
const resolvedMaxLiveNodes = Math.max(1, Math.trunc(maxLiveNodes ?? 320))
const resolvedBuffer = Math.max(0, Math.trunc(liveNodeBuffer ?? 60))
const focus = Math.max(0, Math.min(Math.trunc(focusIndex), total - 1))
let start = Math.max(0, focus - resolvedBuffer)
let end = Math.min(total, focus + resolvedBuffer + 1)
const size = end - start
if (size > resolvedMaxLiveNodes) {
const excess = size - resolvedMaxLiveNodes
start += Math.ceil(excess / 2)
end -= Math.floor(excess / 2)
}
else if (size < resolvedMaxLiveNodes) {
const missing = resolvedMaxLiveNodes - size
start = Math.max(0, start - Math.ceil(missing / 2))
end = Math.min(total, end + Math.floor(missing / 2))
}
return {
start: Math.max(0, Math.min(start, total)),
end: Math.max(0, Math.min(Math.max(end, start), total)),
}
}
@@ -0,0 +1,85 @@
export async function copyTextToClipboard(source: string) {
try {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(source)
return true
}
}
catch {
// Fall back to the textarea path below.
}
if (typeof document === 'undefined')
return false
const textarea = document.createElement('textarea')
textarea.value = source
textarea.setAttribute('readonly', 'true')
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
textarea.style.pointerEvents = 'none'
document.body.appendChild(textarea)
textarea.select()
try {
document.execCommand('copy')
return true
}
catch {
return false
}
finally {
textarea.remove()
}
}
export function clearElement(target: HTMLElement | null | undefined) {
if (!target)
return
try {
target.replaceChildren()
}
catch {
target.innerHTML = ''
}
}
export function setElementHtml(target: HTMLElement | null | undefined, html: string) {
if (!target)
return
clearElement(target)
if (!html)
return
try {
target.insertAdjacentHTML('afterbegin', html)
}
catch {
target.innerHTML = html
}
}
export function downloadSvgMarkup(svgMarkup: string, filename: string) {
if (!svgMarkup || typeof document === 'undefined' || typeof URL === 'undefined')
return
const blob = new Blob([svgMarkup], { type: 'image/svg+xml;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
export function resolveCssSize(value: unknown, fallback?: string) {
if (value == null || value === '')
return fallback ?? null
return typeof value === 'number' ? `${value}px` : String(value)
}
export function clampNumber(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
@@ -0,0 +1,67 @@
import type { OnChanges } from '@angular/core'
import { Directive, ElementRef, inject, Input, Renderer2 } from '@angular/core'
import { isSafeAttrName } from './node-helpers'
@Directive({
selector: '[markstreamSafeAttrs]',
standalone: true,
})
export class SafeAttrsDirective implements OnChanges {
private readonly elementRef = inject(ElementRef<HTMLElement>)
private readonly renderer = inject(Renderer2)
private readonly appliedAttrs = new Set<string>()
private readonly appliedClasses = new Set<string>()
private readonly appliedStyles = new Set<string>()
@Input() markstreamSafeAttrs?: Record<string, unknown> | null
ngOnChanges() {
this.clearAppliedState()
const attrs = this.markstreamSafeAttrs
if (!attrs)
return
for (const [name, rawValue] of Object.entries(attrs)) {
if (!isSafeAttrName(name))
continue
if (name === 'class' || name === 'className') {
for (const token of String(rawValue || '').split(/\s+/g).map(item => item.trim()).filter(Boolean)) {
this.renderer.addClass(this.elementRef.nativeElement, token)
this.appliedClasses.add(token)
}
continue
}
if (name === 'style' && rawValue && typeof rawValue === 'object') {
for (const [styleName, styleValue] of Object.entries(rawValue as Record<string, unknown>)) {
this.renderer.setStyle(this.elementRef.nativeElement, styleName, styleValue as any)
this.appliedStyles.add(styleName)
}
continue
}
if (rawValue === false || rawValue == null)
continue
this.renderer.setAttribute(
this.elementRef.nativeElement,
name,
rawValue === true ? '' : String(rawValue),
)
this.appliedAttrs.add(name)
}
}
private clearAppliedState() {
for (const name of this.appliedAttrs)
this.renderer.removeAttribute(this.elementRef.nativeElement, name)
for (const token of this.appliedClasses)
this.renderer.removeClass(this.elementRef.nativeElement, token)
for (const name of this.appliedStyles)
this.renderer.removeStyle(this.elementRef.nativeElement, name)
this.appliedAttrs.clear()
this.appliedClasses.clear()
this.appliedStyles.clear()
}
}
@@ -0,0 +1,89 @@
import type { Type } from '@angular/core'
export type CustomComponentMap = Record<string, Type<any>>
const GLOBAL_KEY = '__global__'
interface Store {
scopedComponents: Record<string, CustomComponentMap>
revision: number
listeners: Set<() => void>
}
const STORE_KEY = '__MARKSTREAM_ANGULAR_CUSTOM_COMPONENTS_STORE__'
const store: Store = (() => {
const globalStore = globalThis as any
if (globalStore[STORE_KEY])
return globalStore[STORE_KEY] as Store
const next: Store = {
scopedComponents: {},
revision: 0,
listeners: new Set(),
}
globalStore[STORE_KEY] = next
return next
})()
function bumpRevision() {
store.revision += 1
for (const listener of Array.from(store.listeners)) {
try {
listener()
}
catch {
// Ignore subscriber failures so one bad consumer does not break the registry.
}
}
}
export function subscribeCustomComponents(listener: () => void) {
store.listeners.add(listener)
return () => {
store.listeners.delete(listener)
}
}
export function getCustomComponentsRevision() {
return store.revision
}
export function setCustomComponents(id: string, mapping: CustomComponentMap): void
export function setCustomComponents(mapping: CustomComponentMap): void
export function setCustomComponents(idOrMapping: string | CustomComponentMap, maybeMapping?: CustomComponentMap) {
if (typeof idOrMapping === 'string')
store.scopedComponents[idOrMapping] = { ...(maybeMapping || {}) }
else
store.scopedComponents[GLOBAL_KEY] = { ...idOrMapping }
bumpRevision()
}
export function getCustomNodeComponents(customId?: string): CustomComponentMap {
const globalMapping = store.scopedComponents[GLOBAL_KEY] || {}
if (!customId)
return globalMapping
const scopedMapping = store.scopedComponents[customId] || {}
if (!globalMapping || Object.keys(globalMapping).length === 0)
return scopedMapping
if (!scopedMapping || Object.keys(scopedMapping).length === 0)
return globalMapping
return {
...globalMapping,
...scopedMapping,
}
}
export function removeCustomComponents(id: string) {
if (id === GLOBAL_KEY) {
throw new Error('removeCustomComponents: cannot delete global mapping; call clearGlobalCustomComponents instead.')
}
delete store.scopedComponents[id]
bumpRevision()
}
export function clearGlobalCustomComponents() {
delete store.scopedComponents[GLOBAL_KEY]
bumpRevision()
}
@@ -0,0 +1,847 @@
import { getD2 } from './optional/d2'
import { getInfographic } from './optional/infographic'
import { getKatex } from './optional/katex'
import { getMermaid } from './optional/mermaid'
import { getUseMonaco } from './optional/monaco'
import { extractRenderedSvg, toSafeSvgMarkup } from './sanitizeSvg'
import { normalizeKaTeXRenderInput } from './utils/normalizeKaTeXRenderInput'
import { renderKaTeXWithBackpressure, setKaTeXCache, WORKER_BUSY_CODE } from './workers/katexWorkerClient'
import { canParseOffthread, findPrefixOffthread } from './workers/mermaidWorkerClient'
let mermaidRenderId = 0
const rootHandles = new WeakMap<HTMLElement, RenderedHtmlEnhancementHandle>()
type MermaidTheme = 'light' | 'dark'
const DARK_THEME_OVERRIDES: Record<string, string> = {
N1: '#E5E7EB',
N2: '#CBD5E1',
N3: '#94A3B8',
N4: '#64748B',
N5: '#475569',
N6: '#334155',
N7: '#0B1220',
B1: '#60A5FA',
B2: '#3B82F6',
B3: '#2563EB',
B4: '#1D4ED8',
B5: '#1E40AF',
B6: '#111827',
AA2: '#22D3EE',
AA4: '#0EA5E9',
AA5: '#0284C7',
AB4: '#FBBF24',
AB5: '#F59E0B',
}
const MONACO_LANGUAGE_ALIASES: Record<string, string> = {
cjs: 'javascript',
cts: 'typescript',
d2lang: 'plaintext',
js: 'javascript',
jsonc: 'json',
jsx: 'javascript',
md: 'markdown',
mjs: 'javascript',
sh: 'shell',
text: 'plaintext',
ts: 'typescript',
tsx: 'typescript',
txt: 'plaintext',
vue: 'html',
yml: 'yaml',
}
export interface EnhanceRenderedHtmlOptions {
final?: boolean
isDark?: boolean
renderCodeBlocksAsPre?: boolean
monacoOptions?: Record<string, any>
d2ThemeId?: number | null
d2DarkThemeId?: number | null
showTooltips?: boolean
codeBlockProps?: Record<string, any>
mermaidProps?: Record<string, any>
d2Props?: Record<string, any>
infographicProps?: Record<string, any>
onCopy?: (code: string) => void
isCancelled?: () => boolean
}
export interface RenderedHtmlEnhancementHandle {
dispose: () => void
}
export async function enhanceRenderedHtml(
root: HTMLElement,
options: EnhanceRenderedHtmlOptions = {},
): Promise<RenderedHtmlEnhancementHandle> {
disposeRenderedHtmlEnhancements(root)
const cleanupFns: Array<() => void> = []
let disposed = false
const isActive = () => !disposed && options.isCancelled?.() !== true
const handle: RenderedHtmlEnhancementHandle = {
dispose: () => {
if (disposed)
return
disposed = true
for (let index = cleanupFns.length - 1; index >= 0; index -= 1) {
try {
cleanupFns[index]?.()
}
catch {
// Best-effort cleanup only.
}
}
if (rootHandles.get(root) === handle)
rootHandles.delete(root)
},
}
rootHandles.set(root, handle)
if (!isActive())
return handle
await renderKatex(root, isActive)
if (!isActive())
return handle
await renderMermaid(root, cleanupFns, options, isActive)
if (!isActive())
return handle
if (options.final !== false) {
await renderInfographic(root, cleanupFns, options, isActive)
if (!isActive())
return handle
await renderD2(root, cleanupFns, options, isActive)
if (!isActive())
return handle
if (!options.renderCodeBlocksAsPre)
await renderMonaco(root, cleanupFns, options, isActive)
}
return handle
}
export function disposeRenderedHtmlEnhancements(root: HTMLElement | null | undefined) {
if (!root)
return
const existing = rootHandles.get(root)
existing?.dispose()
}
function readKatexSource(node: HTMLElement, selector: string) {
const sourceElement = node.querySelector<HTMLElement>(selector)
return {
sourceElement,
source: (sourceElement?.textContent || (!sourceElement ? node.textContent : '') || '').trim(),
}
}
function resolveKatexRenderTarget(node: HTMLElement, selector: string) {
return node.querySelector<HTMLElement>(selector)
}
function hasRenderedKatex(node: HTMLElement, selector: string) {
const renderTarget = resolveKatexRenderTarget(node, selector)
const scope = renderTarget || node
return !!scope.querySelector('.katex, .katex-display')
}
function writeKatexMarkup(
node: HTMLElement,
renderTarget: HTMLElement | null,
markup: string,
options: { renderedClass?: string } = {},
) {
if (renderTarget)
renderTarget.innerHTML = markup
else
node.innerHTML = markup
node.dataset.markstreamKatex = '1'
if (options.renderedClass)
node.classList.add(options.renderedClass)
}
function clearKatexMarkup(
node: HTMLElement,
renderTarget: HTMLElement | null,
options: { renderedClass?: string } = {},
) {
if (renderTarget)
renderTarget.innerHTML = ''
delete node.dataset.markstreamKatex
delete node.dataset.markstreamKatexSource
if (options.renderedClass)
node.classList.remove(options.renderedClass)
}
async function renderKatex(root: HTMLElement, isActive: () => boolean) {
const inlineNodes = Array.from(root.querySelectorAll<HTMLElement>('.markstream-nested-math'))
for (const node of inlineNodes) {
if (!isActive())
return
if (node.dataset.markstreamKatexManaged === '1')
continue
const { source } = readKatexSource(node, '.markstream-nested-math__source')
const renderTarget = resolveKatexRenderTarget(node, '.markstream-nested-math__render')
if (!source) {
clearKatexMarkup(node, renderTarget)
continue
}
if (
node.dataset.markstreamKatex === '1'
&& node.dataset.markstreamKatexSource === source
&& hasRenderedKatex(node, '.markstream-nested-math__render')
) {
continue
}
try {
writeKatexMarkup(node, renderTarget, await renderKatexMarkup(source, node.dataset.display === 'block'))
node.dataset.markstreamKatexSource = source
}
catch {
clearKatexMarkup(node, renderTarget)
}
}
const blockNodes = Array.from(root.querySelectorAll<HTMLElement>('.markstream-nested-math-block'))
for (const node of blockNodes) {
if (!isActive())
return
if (node.dataset.markstreamKatexManaged === '1')
continue
const { source } = readKatexSource(node, '.markstream-nested-math-block__source')
const renderTarget = resolveKatexRenderTarget(node, '.markstream-nested-math-block__render')
if (!source) {
clearKatexMarkup(node, renderTarget, { renderedClass: 'markstream-nested-math-block--rendered' })
continue
}
if (
node.dataset.markstreamKatex === '1'
&& node.dataset.markstreamKatexSource === source
&& hasRenderedKatex(node, '.markstream-nested-math-block__render')
) {
continue
}
try {
writeKatexMarkup(node, renderTarget, await renderKatexMarkup(source, true), {
renderedClass: 'markstream-nested-math-block--rendered',
})
node.dataset.markstreamKatexSource = source
}
catch {
clearKatexMarkup(node, renderTarget, { renderedClass: 'markstream-nested-math-block--rendered' })
}
}
}
async function renderKatexMarkup(source: string, displayMode: boolean) {
const normalizedSource = normalizeKaTeXRenderInput(source)
try {
return await renderKaTeXWithBackpressure(normalizedSource, displayMode, {
timeout: 1500,
waitTimeout: 0,
maxRetries: 0,
})
}
catch (error: any) {
const code = error?.code || error?.name
const isWorkerInitFailure = code === 'WORKER_INIT_ERROR' || error?.fallbackToRenderer
const isBusyOrTimeout = code === WORKER_BUSY_CODE || code === 'WORKER_TIMEOUT'
if (!isWorkerInitFailure && !isBusyOrTimeout)
throw error
}
const katex = await getKatex()
if (!katex)
throw new Error('KaTeX renderer is not available.')
const html = katex.renderToString(normalizedSource, {
displayMode,
throwOnError: false,
})
setKaTeXCache(normalizedSource, displayMode, html)
return html
}
async function renderMermaid(
root: HTMLElement,
cleanupFns: Array<() => void>,
options: EnhanceRenderedHtmlOptions,
isActive: () => boolean,
) {
const mermaid = await getMermaid({
startOnLoad: false,
securityLevel: 'loose',
suppressErrorRendering: true,
})
if (!mermaid || !isActive())
return
const codeNodes = Array.from(root.querySelectorAll<HTMLElement>('pre > code.language-mermaid'))
for (const codeNode of codeNodes) {
if (!isActive())
return
const pre = codeNode.parentElement as HTMLElement | null
const source = (codeNode.textContent || '').trim()
if (!pre || !source)
continue
const originalPre = pre.cloneNode(true) as HTMLElement
const shell = createEnhancedBlockShell(
'mermaid',
'Mermaid',
source,
true,
options,
{
showHeader: options.mermaidProps?.showHeader !== false,
},
)
pre.replaceWith(shell.wrapper)
try {
const theme: MermaidTheme = options.isDark ? 'dark' : 'light'
let sourceToRender = source
if (options.final === false) {
try {
await canParseMermaidWithFallback(source, theme, mermaid, options)
}
catch (error: any) {
if (error?.name === 'AbortError')
return
const prefix = await findMermaidPrefixCandidate(source, theme, options)
if (!isActive())
return
if (!prefix) {
shell.wrapper.replaceWith(originalPre)
continue
}
sourceToRender = prefix
}
}
const renderId = `markstream-angular-mermaid-${++mermaidRenderId}`
const rendered = await withTimeout(
() => Promise.resolve(mermaid.render(renderId, applyMermaidThemeTo(sourceToRender, theme))),
options.final === false
? Number(options.mermaidProps?.renderTimeoutMs ?? 2500)
: Number(options.mermaidProps?.fullRenderTimeoutMs ?? 4000),
)
if (!isActive())
return
const svg = typeof rendered === 'string' ? rendered : rendered?.svg
if (!svg)
continue
shell.body.innerHTML = svg
shell.body.classList.add('markstream-angular-mermaid')
shell.wrapper.dataset.markstreamMermaid = '1'
rendered?.bindFunctions?.(shell.body)
cleanupFns.push(() => {
if (shell.wrapper.isConnected)
shell.wrapper.replaceWith(originalPre.cloneNode(true))
})
}
catch {
shell.wrapper.replaceWith(originalPre)
}
}
}
function applyMermaidThemeTo(source: string, theme: MermaidTheme) {
const trimmed = source.trimStart()
if (trimmed.startsWith('%%{'))
return source
const themeValue = theme === 'dark' ? 'dark' : 'default'
return `%%{init: {"theme": "${themeValue}"}}%%\n${source}`
}
async function canParseMermaidWithFallback(
source: string,
theme: MermaidTheme,
mermaid: any,
options: EnhanceRenderedHtmlOptions,
) {
const workerTimeout = Number(options.mermaidProps?.workerTimeoutMs ?? 1400)
const parseTimeout = Number(options.mermaidProps?.parseTimeoutMs ?? 1800)
try {
const ok = await canParseOffthread(source, theme, workerTimeout)
if (ok)
return true
}
catch (error: any) {
if (error?.name === 'AbortError')
throw error
}
const themedSource = applyMermaidThemeTo(source, theme)
if (typeof mermaid?.parse === 'function') {
await withTimeout(() => Promise.resolve(mermaid.parse(themedSource)), parseTimeout)
return true
}
const renderId = `markstream-angular-mermaid-parse-${++mermaidRenderId}`
await withTimeout(() => Promise.resolve(mermaid.render(renderId, themedSource)), parseTimeout)
return true
}
async function findMermaidPrefixCandidate(
source: string,
theme: MermaidTheme,
options: EnhanceRenderedHtmlOptions,
) {
const workerTimeout = Number(options.mermaidProps?.workerTimeoutMs ?? 1400)
try {
const prefix = await findPrefixOffthread(source, theme, workerTimeout)
if (prefix)
return prefix
}
catch (error: any) {
if (error?.name === 'AbortError')
throw error
}
return getSafeMermaidPrefixCandidate(source)
}
function getSafeMermaidPrefixCandidate(source: string) {
const lines = source.split('\n')
while (lines.length > 0) {
const lastRaw = lines[lines.length - 1]
const last = lastRaw.trimEnd()
if (!last) {
lines.pop()
continue
}
const looksDangling = /^[-=~>|<\s]+$/.test(last.trim())
|| /(?:--|==|~~|->|<-|-\||-\)|-x|o-|\|-|\.-)\s*$/.test(last)
|| /[-|><]$/.test(last)
|| /(?:graph|flowchart|sequenceDiagram|classDiagram|stateDiagram|erDiagram|gantt)\s*$/i.test(last)
if (!looksDangling)
break
lines.pop()
}
return lines.join('\n')
}
function withTimeout<T>(run: () => Promise<T>, timeoutMs: number) {
if (!timeoutMs || timeoutMs <= 0)
return run()
return new Promise<T>((resolve, reject) => {
let settled = false
const timer = globalThis.setTimeout(() => {
if (settled)
return
settled = true
reject(new Error('Operation timed out'))
}, timeoutMs)
run()
.then((value) => {
if (settled)
return
settled = true
clearTimeout(timer)
resolve(value)
})
.catch((error) => {
if (settled)
return
settled = true
clearTimeout(timer)
reject(error)
})
})
}
async function renderInfographic(
root: HTMLElement,
cleanupFns: Array<() => void>,
options: EnhanceRenderedHtmlOptions,
isActive: () => boolean,
) {
const InfographicClass = await getInfographic()
if (!InfographicClass || !isActive())
return
const codeNodes = Array.from(root.querySelectorAll<HTMLElement>('pre[data-markstream-code-block="1"] > code.language-infographic'))
for (const codeNode of codeNodes) {
if (!isActive())
return
const pre = codeNode.parentElement as HTMLElement | null
const source = codeNode.textContent || ''
if (!pre || !source.trim())
continue
const originalPre = pre.cloneNode(true) as HTMLElement
const shell = createEnhancedBlockShell('infographic', 'Infographic', source, true, options, {
showHeader: options.infographicProps?.showHeader !== false,
})
shell.body.style.minHeight = '320px'
shell.body.style.height = '360px'
pre.replaceWith(shell.wrapper)
try {
const instance = new InfographicClass({
container: shell.body,
width: '100%',
height: '100%',
})
instance.render(source)
let frameId: number | null = null
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
frameId = window.requestAnimationFrame(() => {
const actualHeight = shell.body.scrollHeight
if (actualHeight > 0)
shell.body.style.height = `${Math.min(Math.max(actualHeight, 260), 720)}px`
})
}
cleanupFns.push(() => {
if (frameId != null && typeof window !== 'undefined')
window.cancelAnimationFrame(frameId)
instance.destroy?.()
if (shell.wrapper.isConnected)
shell.wrapper.replaceWith(originalPre.cloneNode(true))
})
shell.wrapper.dataset.markstreamInfographic = '1'
}
catch {
shell.wrapper.replaceWith(originalPre)
}
}
}
async function renderD2(
root: HTMLElement,
cleanupFns: Array<() => void>,
options: EnhanceRenderedHtmlOptions,
isActive: () => boolean,
) {
const D2Ctor = await getD2()
if (!D2Ctor || !isActive())
return
const codeNodes = Array.from(root.querySelectorAll<HTMLElement>('pre[data-markstream-code-block="1"] > code.language-d2, pre[data-markstream-code-block="1"] > code.language-d2lang'))
for (const codeNode of codeNodes) {
if (!isActive())
return
const pre = codeNode.parentElement as HTMLElement | null
const source = codeNode.textContent || ''
if (!pre || !source.trim())
continue
const originalPre = pre.cloneNode(true) as HTMLElement
const shell = createEnhancedBlockShell('d2', 'D2', source, true, options, {
showHeader: options.d2Props?.showHeader !== false,
})
pre.replaceWith(shell.wrapper)
try {
const instance = createD2Instance(D2Ctor)
if (!instance || typeof instance.compile !== 'function' || typeof instance.render !== 'function')
throw new TypeError('D2 instance is missing compile/render methods.')
const compileResult = await instance.compile(source)
const diagram = compileResult?.diagram ?? compileResult
const baseRenderOptions = compileResult?.renderOptions ?? compileResult?.options ?? {}
const resolvedThemeId = options.d2ThemeId ?? baseRenderOptions.themeID
const resolvedDarkThemeId = options.d2DarkThemeId ?? baseRenderOptions.darkThemeID
const renderOptions: Record<string, any> = { ...baseRenderOptions }
renderOptions.themeID = options.isDark && resolvedDarkThemeId != null
? resolvedDarkThemeId
: resolvedThemeId
renderOptions.darkThemeID = null
renderOptions.darkThemeOverrides = null
if (options.isDark) {
const baseOverrides = baseRenderOptions.themeOverrides && typeof baseRenderOptions.themeOverrides === 'object'
? baseRenderOptions.themeOverrides
: null
renderOptions.themeOverrides = {
...DARK_THEME_OVERRIDES,
...(baseOverrides || {}),
}
}
const renderResult = await instance.render(diagram, renderOptions)
if (!isActive())
return
const safeSvg = toSafeSvgMarkup(extractRenderedSvg(renderResult))
if (!safeSvg)
throw new Error('D2 render returned empty output.')
shell.body.innerHTML = safeSvg
shell.wrapper.dataset.markstreamD2 = '1'
cleanupFns.push(() => {
if (shell.wrapper.isConnected)
shell.wrapper.replaceWith(originalPre.cloneNode(true))
})
}
catch {
shell.wrapper.replaceWith(originalPre)
}
}
}
async function renderMonaco(
root: HTMLElement,
cleanupFns: Array<() => void>,
options: EnhanceRenderedHtmlOptions,
isActive: () => boolean,
) {
const monacoModule = await getUseMonaco()
if (!monacoModule || typeof monacoModule.useMonaco !== 'function' || !isActive())
return
const preNodes = Array.from(root.querySelectorAll<HTMLElement>('pre[data-markstream-code-block="1"]'))
for (const pre of preNodes) {
if (!isActive())
return
const codeNode = pre.querySelector<HTMLElement>('code')
if (!codeNode)
continue
const rawLanguage = resolveCodeLanguage(pre, codeNode)
const normalizedLanguage = rawLanguage.trim().toLowerCase()
if (normalizedLanguage === 'mermaid' || normalizedLanguage === 'infographic' || normalizedLanguage === 'd2' || normalizedLanguage === 'd2lang')
continue
const source = codeNode.textContent || ''
const diff = pre.dataset.markstreamDiff === '1'
const updatedCode = decodeDataPayload(pre.dataset.markstreamUpdated)
const monacoLanguage = resolveMonacoLanguage(rawLanguage)
const renderLanguage = diff ? 'plaintext' : monacoLanguage
const shell = createEnhancedBlockShell(
'code',
diff ? `Diff / ${monacoLanguage}` : `Code / ${monacoLanguage}`,
source,
false,
options,
{
showHeader: options.codeBlockProps?.showHeader !== false,
},
)
shell.body.classList.add('markstream-angular-enhanced-block__body--code')
shell.body.style.minHeight = `${estimateCodeBlockHeight(diff ? updatedCode || source : source, diff)}px`
const originalPre = pre.cloneNode(true) as HTMLElement
pre.replaceWith(shell.wrapper)
const helpers = monacoModule.useMonaco({
themes: ['vitesse-dark', 'vitesse-light'],
languages: Array.from(new Set([monacoLanguage, 'plaintext'])),
readOnly: true,
minimap: { enabled: false },
lineNumbers: 'on',
wordWrap: 'off',
revealDebounceMs: 75,
MAX_HEIGHT: 500,
fontSize: 13,
...(options.monacoOptions || {}),
})
try {
// Vue/React wire Monaco diff editors through dedicated components.
// Angular currently enhances static HTML after the fact, and the diff
// editor path in stream-monaco can leave an empty shell or raise
// "no diff result available" during worker setup. Prefer a stable
// single-editor fallback that still preserves the diff source text.
await helpers.createEditor(
shell.body,
source,
renderLanguage,
)
if (!isActive())
return
await helpers.setTheme?.(options.isDark ? 'vitesse-dark' : 'vitesse-light')
shell.wrapper.dataset.markstreamMonaco = '1'
if (diff)
shell.wrapper.dataset.markstreamMonacoDiff = '1'
cleanupFns.push(() => {
try {
helpers.cleanupEditor?.()
}
finally {
if (shell.wrapper.isConnected)
shell.wrapper.replaceWith(originalPre.cloneNode(true))
}
})
}
catch {
try {
helpers.cleanupEditor?.()
}
catch {
// Ignore cleanup failures during fallback.
}
shell.wrapper.replaceWith(originalPre)
}
}
}
function createD2Instance(D2Ctor: any) {
if (typeof D2Ctor === 'function') {
const instance = new D2Ctor()
if (instance && typeof instance.compile === 'function')
return instance
if (typeof D2Ctor.compile === 'function')
return D2Ctor
}
if (D2Ctor?.D2 && typeof D2Ctor.D2 === 'function')
return new D2Ctor.D2()
if (typeof D2Ctor?.compile === 'function')
return D2Ctor
return null
}
function resolveCodeLanguage(pre: HTMLElement, codeNode: HTMLElement) {
const explicit = pre.dataset.markstreamLanguage?.trim()
if (explicit)
return explicit
const languageClass = Array.from(codeNode.classList).find(className => className.startsWith('language-'))
return languageClass ? languageClass.slice('language-'.length) : 'plaintext'
}
function resolveMonacoLanguage(language: string) {
const normalized = language.trim().toLowerCase()
return MONACO_LANGUAGE_ALIASES[normalized] || normalized || 'plaintext'
}
function estimateCodeBlockHeight(source: string, diff: boolean) {
const lineCount = Math.max(1, source.split('\n').length)
const perLine = diff ? 20 : 18
const base = diff ? 180 : 96
return Math.min(520, base + lineCount * perLine)
}
function decodeDataPayload(value: string | null | undefined) {
if (!value)
return ''
const globalBuffer = (globalThis as any)?.require?.('buffer')?.Buffer
if (globalBuffer?.from)
return globalBuffer.from(value, 'base64').toString('utf8')
if (typeof globalThis.atob === 'function' && typeof TextDecoder !== 'undefined') {
const binary = globalThis.atob(value)
const bytes = Uint8Array.from(binary, char => char.charCodeAt(0))
return new TextDecoder().decode(bytes)
}
return ''
}
function createEnhancedBlockShell(
kind: 'code' | 'd2' | 'infographic' | 'mermaid',
label: string,
source: string,
showSourceDetails: boolean,
options: EnhanceRenderedHtmlOptions,
shellOptions: {
showHeader?: boolean
} = {},
) {
const wrapper = document.createElement('div')
wrapper.className = `markstream-angular-enhanced-block markstream-angular-enhanced-block--${kind}`
if (shellOptions.showHeader !== false) {
const header = document.createElement('div')
header.className = 'markstream-angular-enhanced-block__header'
const badge = document.createElement('span')
badge.className = 'markstream-angular-enhanced-block__badge'
badge.textContent = label
header.appendChild(badge)
header.appendChild(createHeaderActions(source, options))
wrapper.appendChild(header)
}
const body = document.createElement('div')
body.className = 'markstream-angular-enhanced-block__body'
wrapper.appendChild(body)
if (showSourceDetails) {
const details = document.createElement('details')
details.className = 'markstream-angular-enhanced-block__details'
const summary = document.createElement('summary')
summary.textContent = 'Source'
details.appendChild(summary)
const sourcePre = document.createElement('pre')
sourcePre.className = 'markstream-angular-enhanced-block__source'
const code = document.createElement('code')
code.textContent = source
sourcePre.appendChild(code)
details.appendChild(sourcePre)
wrapper.appendChild(details)
}
return { wrapper, body }
}
function createHeaderActions(source: string, options: EnhanceRenderedHtmlOptions) {
const actions = document.createElement('div')
actions.className = 'markstream-angular-enhanced-block__actions'
const copyButton = document.createElement('button')
copyButton.type = 'button'
copyButton.className = 'markstream-angular-enhanced-block__action'
copyButton.textContent = 'Copy'
if (options.showTooltips !== false)
copyButton.title = 'Copy source'
copyButton.addEventListener('click', () => {
void copyText(source)
options.onCopy?.(source)
})
actions.appendChild(copyButton)
return actions
}
async function copyText(source: string) {
try {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(source)
return
}
}
catch {
// Fall through to the textarea fallback below.
}
if (typeof document === 'undefined')
return
const textarea = document.createElement('textarea')
textarea.value = source
textarea.setAttribute('readonly', 'true')
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
textarea.style.pointerEvents = 'none'
document.body.appendChild(textarea)
textarea.select()
try {
document.execCommand('copy')
}
catch {
// Ignore copy failures; the output callback still fires.
}
finally {
textarea.remove()
}
}
@@ -0,0 +1,178 @@
import type { BaseNode } from 'stream-markdown-parser'
import { normalizeCustomHtmlTags, normalizeCustomHtmlTagName as normalizeTagName } from 'stream-markdown-parser'
interface CustomTagSegment {
tag: string
start: number
end: number
innerContent: string
raw: string
loading: boolean
}
interface CustomTagOpen {
tag: string
start: number
openEnd: number
}
const TAG_TOKEN_RE = /<\/?([A-Z][\w:-]*)(?:\s[^<>]*)?>/gi
export function hydrateCustomTagContent<T extends BaseNode>(
nodes: readonly T[] | null | undefined,
source: string,
customHtmlTags?: readonly string[],
): T[] {
const normalizedTags = normalizeCustomHtmlTags(customHtmlTags)
const tagSet = new Set(normalizedTags)
if (!source || tagSet.size === 0 || !Array.isArray(nodes) || nodes.length === 0)
return Array.isArray(nodes) ? nodes.slice() : []
const segments = collectCustomTagSegments(source, tagSet)
if (segments.length === 0)
return nodes.slice()
let segmentIndex = 0
const cloned = nodes.map(node => cloneNodeTree(node))
const visitNode = (node: any) => {
if (!node || typeof node !== 'object')
return
const tag = resolveCustomTagName(node, tagSet)
if (tag) {
const segment = consumeNextSegment(segments, tag, () => segmentIndex, nextIndex => (segmentIndex = nextIndex))
if (segment) {
node.tag = tag
node.type = tag
node.content = segment.innerContent
node.raw = segment.raw
if (typeof node.loading !== 'boolean')
node.loading = segment.loading
}
}
for (const value of Object.values(node)) {
if (!Array.isArray(value))
continue
for (const child of value)
visitNode(child)
}
}
for (const node of cloned)
visitNode(node)
return cloned
}
function collectCustomTagSegments(source: string, tagSet: Set<string>) {
const stack: CustomTagOpen[] = []
const segments: CustomTagSegment[] = []
let match: RegExpExecArray | null
TAG_TOKEN_RE.lastIndex = 0
while ((match = TAG_TOKEN_RE.exec(source)) !== null) {
const raw = match[0]
const tag = normalizeTagName(match[1])
if (!tag || !tagSet.has(tag))
continue
const isClosing = raw.startsWith('</')
if (!isClosing) {
stack.push({
tag,
start: match.index,
openEnd: TAG_TOKEN_RE.lastIndex,
})
continue
}
const openIndex = findLastOpenIndex(stack, tag)
if (openIndex < 0)
continue
const [open] = stack.splice(openIndex, 1)
const closeStart = match.index
const closeEnd = TAG_TOKEN_RE.lastIndex
segments.push({
tag,
start: open.start,
end: closeEnd,
innerContent: source.slice(open.openEnd, closeStart),
raw: source.slice(open.start, closeEnd),
loading: false,
})
}
for (const open of stack) {
segments.push({
tag: open.tag,
start: open.start,
end: source.length,
innerContent: source.slice(open.openEnd),
raw: source.slice(open.start),
loading: true,
})
}
return segments.sort((left, right) => {
if (left.start !== right.start)
return left.start - right.start
return right.end - left.end
})
}
function findLastOpenIndex(stack: CustomTagOpen[], tag: string) {
for (let index = stack.length - 1; index >= 0; index -= 1) {
if (stack[index]?.tag === tag)
return index
}
return -1
}
function consumeNextSegment(
segments: CustomTagSegment[],
tag: string,
getIndex: () => number,
setIndex: (value: number) => void,
) {
let index = getIndex()
while (index < segments.length) {
const segment = segments[index]
index += 1
if (segment.tag !== tag)
continue
setIndex(index)
return segment
}
setIndex(index)
return null
}
function resolveCustomTagName(node: Record<string, any>, tagSet: Set<string>) {
const byTag = normalizeTagName(node.tag)
if (byTag && tagSet.has(byTag))
return byTag
const byType = normalizeTagName(node.type)
return byType && tagSet.has(byType) ? byType : ''
}
function cloneNodeTree<T extends BaseNode>(node: T): T {
if (!node || typeof node !== 'object')
return node
const cloned = Array.isArray(node)
? node.map(item => cloneNodeTree(item)) as unknown as T
: { ...(node as any) }
if (!Array.isArray(cloned)) {
for (const [key, value] of Object.entries(cloned as Record<string, unknown>)) {
if (Array.isArray(value))
(cloned as any)[key] = value.map(item => cloneNodeTree(item as BaseNode))
}
}
return cloned
}
@@ -0,0 +1,41 @@
function humanizeKey(key: string) {
const suffix = key.split('.').pop() || key
return suffix
.replace(/[_-]/g, ' ')
.replace(/([A-Z])/g, ' $1')
.replace(/\s+/g, ' ')
.replace(/\b\w/g, char => char.toUpperCase())
.trim()
}
const defaultMap: Record<string, string> = {
'common.copy': 'Copy',
'common.copied': 'Copied',
'common.decrease': 'Decrease',
'common.reset': 'Reset',
'common.increase': 'Increase',
'common.expand': 'Expand',
'common.collapse': 'Collapse',
'common.preview': 'Preview',
'common.source': 'Source',
'common.export': 'Export',
'common.open': 'Open',
'common.close': 'Close',
'common.zoomIn': 'Zoom in',
'common.zoomOut': 'Zoom out',
'common.resetZoom': 'Reset zoom',
'artifacts.htmlPreviewTitle': 'HTML Preview',
'artifacts.svgPreviewTitle': 'SVG Preview',
}
export function setDefaultI18nMap(map: Record<string, string>) {
Object.assign(defaultMap, map)
}
export function useSafeI18n() {
return {
t(key: string) {
return defaultMap[key] ?? humanizeKey(key)
},
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,147 @@
import './index.css'
import './workers/katexRenderer.worker?worker'
import './workers/mermaidParser.worker?worker'
export { AdmonitionNodeComponent as AdmonitionNode } from './components/AdmonitionNode/AdmonitionNode.component'
export { BlockquoteNodeComponent as BlockquoteNode } from './components/BlockquoteNode/BlockquoteNode.component'
export { CheckboxNodeComponent as CheckboxNode } from './components/CheckboxNode/CheckboxNode.component'
export { CodeBlockNodeComponent as CodeBlockNode } from './components/CodeBlockNode/CodeBlockNode.component'
export { CodeBlockNodeComponent as AngularCodeBlockNode } from './components/CodeBlockNode/CodeBlockNode.component'
export { CodeBlockNodeComponent as MarkdownCodeBlockNode } from './components/CodeBlockNode/CodeBlockNode.component'
export { HtmlPreviewFrameComponent as HtmlPreviewFrame } from './components/CodeBlockNode/HtmlPreviewFrame.component'
export { D2BlockNodeComponent as D2BlockNode } from './components/D2BlockNode/D2BlockNode.component'
export { DefinitionListNodeComponent as DefinitionListNode } from './components/DefinitionListNode/DefinitionListNode.component'
export { EmojiNodeComponent as EmojiNode } from './components/EmojiNode/EmojiNode.component'
export { EmphasisNodeComponent as EmphasisNode } from './components/EmphasisNode/EmphasisNode.component'
export { FallbackComponent } from './components/FallbackComponent/FallbackComponent.component'
export { FootnoteAnchorNodeComponent as FootnoteAnchorNode } from './components/FootnoteAnchorNode/FootnoteAnchorNode.component'
export { FootnoteNodeComponent as FootnoteNode } from './components/FootnoteNode/FootnoteNode.component'
export { FootnoteReferenceNodeComponent as FootnoteReferenceNode } from './components/FootnoteReferenceNode/FootnoteReferenceNode.component'
export { HardBreakNodeComponent as HardBreakNode } from './components/HardBreakNode/HardBreakNode.component'
export { HeadingNodeComponent as HeadingNode } from './components/HeadingNode/HeadingNode.component'
export { HighlightNodeComponent as HighlightNode } from './components/HighlightNode/HighlightNode.component'
export { HtmlBlockNodeComponent as HtmlBlockNode } from './components/HtmlBlockNode/HtmlBlockNode.component'
export { HtmlInlineNodeComponent as HtmlInlineNode } from './components/HtmlInlineNode/HtmlInlineNode.component'
export { ImageNodeComponent as ImageNode } from './components/ImageNode/ImageNode.component'
export { InfographicBlockNodeComponent as InfographicBlockNode } from './components/InfographicBlockNode/InfographicBlockNode.component'
export { InlineCodeNodeComponent as InlineCodeNode } from './components/InlineCodeNode/InlineCodeNode.component'
export { InsertNodeComponent as InsertNode } from './components/InsertNode/InsertNode.component'
export { LinkNodeComponent as LinkNode } from './components/LinkNode/LinkNode.component'
export { ListItemNodeComponent as ListItemNode } from './components/ListItemNode/ListItemNode.component'
export { ListNodeComponent as ListNode } from './components/ListNode/ListNode.component'
export { MathBlockNodeComponent as MathBlockNode } from './components/MathBlockNode/MathBlockNode.component'
export { MathInlineNodeComponent as MathInlineNode } from './components/MathInlineNode/MathInlineNode.component'
export { MermaidBlockNodeComponent as MermaidBlockNode } from './components/MermaidBlockNode/MermaidBlockNode.component'
export { NestedRendererComponent as NestedRenderer } from './components/NestedRenderer/NestedRenderer.component'
export { NodeRendererComponent as NodeRenderer } from './components/NodeRenderer/NodeRenderer.component'
export { ParagraphNodeComponent as ParagraphNode } from './components/ParagraphNode/ParagraphNode.component'
export { PreCodeNodeComponent as PreCodeNode } from './components/PreCodeNode/PreCodeNode.component'
export { ReferenceNodeComponent as ReferenceNode } from './components/ReferenceNode/ReferenceNode.component'
export type {
AngularRenderableNode,
AngularRenderContext,
NodeRendererEvents,
NodeRendererProps,
} from './components/shared/node-helpers'
export {
buildRenderContext,
resolveParsedNodes,
} from './components/shared/node-helpers'
export { SafeAttrsDirective } from './components/shared/safe-attrs.directive'
export { StrikethroughNodeComponent as StrikethroughNode } from './components/StrikethroughNode/StrikethroughNode.component'
export { StrongNodeComponent as StrongNode } from './components/StrongNode/StrongNode.component'
export { SubscriptNodeComponent as SubscriptNode } from './components/SubscriptNode/SubscriptNode.component'
export { SuperscriptNodeComponent as SuperscriptNode } from './components/SuperscriptNode/SuperscriptNode.component'
export { TableNodeComponent as TableNode } from './components/TableNode/TableNode.component'
export { TextNodeComponent as TextNode } from './components/TextNode/TextNode.component'
export { ThematicBreakNodeComponent as ThematicBreakNode } from './components/ThematicBreakNode/ThematicBreakNode.component'
export { VmrContainerNodeComponent as VmrContainerNode } from './components/VmrContainerNode/VmrContainerNode.component'
export {
clearGlobalCustomComponents,
getCustomComponentsRevision,
getCustomNodeComponents,
removeCustomComponents,
setCustomComponents,
subscribeCustomComponents,
} from './customComponents'
export type { CustomComponentMap } from './customComponents'
export {
disposeRenderedHtmlEnhancements,
enhanceRenderedHtml,
} from './enhanceRenderedHtml'
export type { EnhanceRenderedHtmlOptions, RenderedHtmlEnhancementHandle } from './enhanceRenderedHtml'
export { setDefaultI18nMap, useSafeI18n } from './i18n/useSafeI18n'
export { MarkstreamAngularComponent } from './markstream-angular.component'
export { MarkstreamAngularComponent as MarkdownRenderComponent } from './markstream-angular.component'
export { MarkstreamAngularComponent as default } from './markstream-angular.component'
export type { MarkstreamAngularComponentProps } from './markstream-angular.component'
export type { D2Loader } from './optional/d2'
export {
disableD2,
enableD2,
isD2Enabled,
setD2Loader,
} from './optional/d2'
export type { KatexLoader } from './optional/katex'
export {
disableKatex,
enableKatex,
getKatex,
isKatexEnabled,
setKatexLoader,
} from './optional/katex'
export type { MermaidLoader } from './optional/mermaid'
export {
disableMermaid,
enableMermaid,
getMermaid,
isMermaidEnabled,
setMermaidLoader,
} from './optional/mermaid'
export {
parseNestedMarkdownToNodes,
} from './parseNestedMarkdownToNodes'
export type {
NestedMarkdownNodesInput,
NestedMarkdownNodesOptions,
} from './parseNestedMarkdownToNodes'
export {
renderMarkdownNodesToHtml,
renderMarkdownNodeToHtml,
renderMarkdownToHtml,
renderNestedMarkdownToHtml,
} from './renderMarkdownHtml'
export type {
MarkstreamAngularRenderOptions,
NestedMarkdownHtmlInput,
NestedMarkdownHtmlOptions,
RenderableMarkdownNode,
} from './renderMarkdownHtml'
export { sanitizeHtmlContent } from './sanitizeHtmlContent'
export type {
CodeBlockDiffAppearance,
CodeBlockDiffHideUnchangedRegions,
CodeBlockDiffHideUnchangedRegionsOptions,
CodeBlockDiffHunkActionContext,
CodeBlockDiffHunkActionKind,
CodeBlockDiffHunkSide,
CodeBlockDiffLineStyle,
CodeBlockDiffUnchangedRegionStyle,
CodeBlockMonacoLanguage,
CodeBlockMonacoOptions,
CodeBlockMonacoTheme,
CodeBlockMonacoThemeObject,
} from './types/monaco'
export {
getLanguageIcon,
languageMap,
normalizeLanguageIdentifier,
resolveMonacoLanguageId,
setLanguageIconResolver,
} from './utils/languageIcon'
export type { LanguageIconResolver } from './utils/languageIcon'
export * from './workers/katexCdnWorker'
export * from './workers/katexWorkerClient'
export * from './workers/mermaidCdnWorker'
export * from './workers/mermaidWorkerClient'
@@ -0,0 +1,2 @@
export { NodeRendererComponent as MarkstreamAngularComponent } from './components/NodeRenderer/NodeRenderer.component'
export type { NodeRendererProps as MarkstreamAngularComponentProps } from './components/shared/node-helpers'
@@ -0,0 +1,81 @@
export type D2Loader = () => Promise<any> | any
const defaultD2Loader: D2Loader = () => import('@terrastruct/d2')
let cachedD2: any = null
let d2Loader: D2Loader | null = defaultD2Loader
let pendingImport: Promise<any | null> | null = null
function resetCachedD2() {
cachedD2 = null
pendingImport = null
}
function normalizeD2Module(mod: any) {
if (!mod)
return mod
if (mod.D2 && typeof mod.D2 === 'function')
return mod.D2
if (mod.default && mod.default.D2 && typeof mod.default.D2 === 'function')
return mod.default.D2
const candidate = mod.default ?? mod
if (typeof candidate === 'function')
return candidate
if (candidate?.D2 && typeof candidate.D2 === 'function')
return candidate.D2
return candidate
}
export function setD2Loader(loader: D2Loader | null) {
d2Loader = loader
resetCachedD2()
}
export function enableD2(loader?: D2Loader) {
setD2Loader(loader ?? defaultD2Loader)
}
export function disableD2() {
setD2Loader(null)
}
export function isD2Enabled() {
return typeof d2Loader === 'function'
}
export async function getD2() {
if (cachedD2)
return cachedD2
if (pendingImport)
return await pendingImport
const loader = d2Loader
if (!loader)
return null
pendingImport = (async () => {
let mod: any
try {
mod = await loader()
}
catch (error) {
if (loader === defaultD2Loader) {
throw new Error('Optional dependency "@terrastruct/d2" is not installed. Please install it to enable D2 diagrams.')
}
throw error
}
if (!mod)
return null
cachedD2 = normalizeD2Module(mod)
return cachedD2
})()
try {
return await pendingImport
}
finally {
pendingImport = null
}
}
@@ -0,0 +1,35 @@
let infographicPromise: Promise<any> | null = null
let infographicInstance: any = null
export async function getInfographic() {
if (infographicInstance)
return infographicInstance
if (infographicPromise)
return await infographicPromise
infographicPromise = import('@antv/infographic')
.then((mod) => {
const defaultExport = (mod && (mod as any).default) ? (mod as any).default : mod
let resolved = defaultExport
if (typeof defaultExport === 'function' && defaultExport.prototype && defaultExport.prototype.render) {
resolved = defaultExport
}
else if ((mod as any)?.Infographic) {
resolved = (mod as any).Infographic
}
else if (defaultExport && defaultExport.Infographic) {
resolved = defaultExport.Infographic
}
infographicInstance = resolved
return resolved
})
.catch((error) => {
console.warn('[markstream-angular] Failed to load @antv/infographic', error)
return null
})
return await infographicPromise
}
@@ -0,0 +1,108 @@
export type KatexLoader = () => Promise<any> | any
let katex: any = null
let importAttempted = false
let pendingImport: Promise<any | null> | null = null
let katexLoader: KatexLoader | null = defaultKatexLoader
function normalizeKatexModule(mod: any) {
const resolved = mod?.default ?? mod
if (resolved && typeof resolved.renderToString === 'function')
return resolved
return null
}
function getGlobalKatex() {
try {
const globalStore = globalThis as any
return normalizeKatexModule(globalStore?.katex)
}
catch {
return null
}
}
function defaultKatexLoader() {
return (async () => {
const globalKatex = getGlobalKatex()
if (globalKatex)
return globalKatex
const mod = await import('katex')
try {
await import('katex/contrib/mhchem')
}
catch {
// Ignore optional mhchem support failures.
}
return normalizeKatexModule(mod)
})()
}
function resetCache() {
katex = null
importAttempted = false
pendingImport = null
}
export function setKatexLoader(loader: KatexLoader | null) {
katexLoader = loader
resetCache()
}
export function enableKatex(loader?: KatexLoader) {
setKatexLoader(loader ?? defaultKatexLoader)
}
export function disableKatex() {
setKatexLoader(null)
}
export function isKatexEnabled() {
return typeof katexLoader === 'function'
}
export async function getKatex() {
const globalKatex = getGlobalKatex()
if (globalKatex) {
katex = globalKatex
return katex
}
if (katex)
return katex
if (pendingImport)
return await pendingImport
if (importAttempted)
return null
const loader = katexLoader
if (!loader) {
importAttempted = true
return null
}
pendingImport = (async () => {
try {
const result = await loader()
const normalized = normalizeKatexModule(result) ?? result
if (normalized) {
katex = normalized
return katex
}
}
catch {
// Swallow loader errors so callers can gracefully fall back.
}
importAttempted = true
return null
})()
try {
return await pendingImport
}
finally {
pendingImport = null
}
}
@@ -0,0 +1,194 @@
export type MermaidLoader = () => Promise<any> | any
const defaultMermaidLoader: MermaidLoader = () => import('mermaid')
const GLOBAL_MERMAID_KEY = '__MARKSTREAM_ANGULAR_MERMAID__'
let cachedMermaid: any = null
let importAttempted = false
let lastInitKey: string | null = null
let pendingImport: Promise<any | null> | null = null
let mermaidLoader: MermaidLoader | null = defaultMermaidLoader
function computeInitKey(config: Record<string, any>) {
const securityLevel = String(config?.securityLevel ?? 'loose')
const htmlLabels = config?.flowchart?.htmlLabels
return `${securityLevel}|htmlLabels:${htmlLabels === false ? '0' : '1'}`
}
function normalizeMermaidModule(mod: any) {
if (!mod)
return mod
const candidate = mod?.default ?? mod
if (candidate && (typeof candidate.render === 'function' || typeof candidate.parse === 'function' || typeof candidate.initialize === 'function'))
return candidate
if (candidate?.mermaidAPI && (typeof candidate.mermaidAPI.render === 'function' || typeof candidate.mermaidAPI.parse === 'function')) {
const api = candidate.mermaidAPI
return {
...candidate,
render: api.render.bind(api),
parse: api.parse ? api.parse.bind(api) : undefined,
initialize: (opts: any) => {
if (typeof candidate.initialize === 'function')
return candidate.initialize(opts)
return api.initialize ? api.initialize(opts) : undefined
},
}
}
if (mod?.mermaid && typeof mod.mermaid.render === 'function')
return mod.mermaid
return candidate
}
function patchInitialize(target: any) {
if (!target)
return
try {
const originalInitialize = target?.initialize
target.initialize = (opts: any) => {
const merged = { suppressErrorRendering: true, ...(opts || {}) }
if (typeof originalInitialize === 'function')
return originalInitialize.call(target, merged)
if (target?.mermaidAPI && typeof target.mermaidAPI.initialize === 'function')
return target.mermaidAPI.initialize(merged)
return undefined
}
}
catch {
// Ignore patching failures and keep the original instance.
}
}
function ensureInitialized(instance: any, config?: Record<string, any>) {
if (!instance || !config)
return
const key = computeInitKey(config)
if (lastInitKey === key)
return
try {
if (typeof instance.initialize === 'function')
instance.initialize(config)
else if (instance.mermaidAPI?.initialize)
instance.mermaidAPI.initialize(config)
lastInitKey = key
}
catch {
// Mermaid may already be initialized; keep going.
}
}
function getGlobalMermaid() {
try {
const globalStore = globalThis as any
return normalizeMermaidModule(globalStore?.[GLOBAL_MERMAID_KEY] ?? globalStore?.mermaid)
}
catch {
return null
}
}
function setGlobalMermaid(instance: any) {
try {
;(globalThis as any)[GLOBAL_MERMAID_KEY] = instance
}
catch {
// Ignore global cache writes when the host forbids mutation.
}
}
function resetCachedMermaid() {
cachedMermaid = null
importAttempted = false
lastInitKey = null
pendingImport = null
try {
delete (globalThis as any)[GLOBAL_MERMAID_KEY]
}
catch {
// Ignore failures when the global object is sealed.
}
}
export function setMermaidLoader(loader: MermaidLoader | null) {
mermaidLoader = loader
resetCachedMermaid()
}
export function enableMermaid(loader?: MermaidLoader) {
setMermaidLoader(loader ?? defaultMermaidLoader)
}
export function disableMermaid() {
setMermaidLoader(null)
}
export function isMermaidEnabled() {
return typeof mermaidLoader === 'function'
}
export async function getMermaid(initConfig?: Record<string, any>) {
if (cachedMermaid) {
ensureInitialized(cachedMermaid, initConfig)
return cachedMermaid
}
const globalMermaid = getGlobalMermaid()
if (globalMermaid) {
patchInitialize(globalMermaid)
cachedMermaid = globalMermaid
setGlobalMermaid(cachedMermaid)
ensureInitialized(cachedMermaid, initConfig)
return cachedMermaid
}
if (pendingImport) {
const instance = await pendingImport
if (instance)
ensureInitialized(instance, initConfig)
return instance
}
if (importAttempted)
return null
const loader = mermaidLoader
if (!loader) {
importAttempted = true
return null
}
pendingImport = (async () => {
try {
const mod = await loader()
const instance = normalizeMermaidModule(mod)
if (!instance) {
importAttempted = true
return null
}
patchInitialize(instance)
cachedMermaid = instance
setGlobalMermaid(cachedMermaid)
return cachedMermaid
}
catch {
importAttempted = true
return null
}
})()
try {
const instance = await pendingImport
if (instance)
ensureInitialized(instance, initConfig)
return instance
}
finally {
pendingImport = null
}
}
@@ -0,0 +1,75 @@
let monacoModule: any = null
let importAttempted = false
let pendingImport: Promise<any | null> | null = null
let workersPreloaded = false
async function preloadWorkers(mod: any) {
if (workersPreloaded)
return
workersPreloaded = true
const existingEnv = (globalThis as any)?.MonacoEnvironment
if (existingEnv && (typeof existingEnv.getWorker === 'function' || typeof existingEnv.getWorkerUrl === 'function'))
return
if (typeof mod?.preloadMonacoWorkers === 'function')
await mod.preloadMonacoWorkers()
}
async function warmupShikiTokenizer(mod: any) {
const getOrCreateHighlighter = mod?.getOrCreateHighlighter
if (typeof getOrCreateHighlighter !== 'function')
return true
try {
const highlighter = await getOrCreateHighlighter(
['vitesse-dark', 'vitesse-light'],
['plaintext', 'text', 'javascript'],
)
if (highlighter && typeof highlighter.codeToTokens === 'function') {
highlighter.codeToTokens('const a = 1', { lang: 'javascript', theme: 'vitesse-dark' })
}
return true
}
catch (error) {
console.warn('[markstream-angular] Failed to warm up stream-monaco tokenizer; falling back to plain code rendering.', error)
return false
}
}
export async function getUseMonaco() {
if (monacoModule)
return monacoModule
if (pendingImport)
return await pendingImport
if (importAttempted)
return null
pendingImport = (async () => {
try {
const imported: any = await import('stream-monaco')
monacoModule = imported?.default ?? imported
await preloadWorkers(monacoModule)
const ready = await warmupShikiTokenizer(monacoModule)
if (!ready) {
monacoModule = null
importAttempted = true
return null
}
return monacoModule
}
catch {
importAttempted = true
return null
}
})()
try {
return await pendingImport
}
finally {
pendingImport = null
}
}
@@ -0,0 +1,102 @@
import type { BaseNode, MarkdownIt, ParseOptions } from 'stream-markdown-parser'
import { getMarkdown, mergeCustomHtmlTags, parseMarkdownToStructure } from 'stream-markdown-parser'
import { hydrateCustomTagContent } from './hydrateCustomTagContent'
export interface NestedMarkdownNodesInput {
node?: (BaseNode & Record<string, any>) | null
nodes?: readonly BaseNode[] | null
content?: string | null
}
export interface NestedMarkdownNodesOptions {
cacheKey?: string
final?: boolean
parseOptions?: ParseOptions
customHtmlTags?: readonly string[]
customMarkdownIt?: (markdown: MarkdownIt) => MarkdownIt
}
const DEFAULT_CACHE_KEY = 'markstream-angular-nested-nodes'
const markdownCache = new Map<string, MarkdownIt>()
export function parseNestedMarkdownToNodes(
input: NestedMarkdownNodesInput,
options: NestedMarkdownNodesOptions = {},
): BaseNode[] {
if (Array.isArray(input.nodes))
return input.nodes.slice()
const nestedNode = input.node
if (nestedNode) {
const children = getNodeList(nestedNode.children)
if (children.length > 0)
return children.slice()
}
const content = resolveContent(input)
if (!content)
return []
const parseOptions = resolveParseOptions(input, options)
const markdown = resolveMarkdownInstance(options)
return hydrateCustomTagContent(
parseMarkdownToStructure(content, markdown, parseOptions),
content,
mergeCustomHtmlTags(options.customHtmlTags, (parseOptions as any)?.customHtmlTags),
)
}
function resolveContent(input: NestedMarkdownNodesInput) {
if (typeof input.content === 'string')
return input.content
if (typeof input.node?.content === 'string')
return input.node.content
return ''
}
function resolveParseOptions(
input: NestedMarkdownNodesInput,
options: NestedMarkdownNodesOptions,
): ParseOptions | undefined {
const base = options.parseOptions ?? {}
const resolvedFinal = options.final ?? resolveFinalFromNode(input.node)
const customHtmlTags = mergeCustomHtmlTags(options.customHtmlTags, (base as any).customHtmlTags)
if (resolvedFinal == null && customHtmlTags.length === 0)
return base
return {
...(base as any),
...(resolvedFinal == null ? {} : { final: resolvedFinal }),
...(customHtmlTags.length === 0 ? {} : { customHtmlTags }),
} as ParseOptions
}
function resolveFinalFromNode(node?: (BaseNode & Record<string, any>) | null) {
if (!node || typeof node !== 'object')
return undefined
if (typeof node.loading === 'boolean')
return !node.loading
return undefined
}
function resolveMarkdownInstance(options: NestedMarkdownNodesOptions) {
const normalizedTags = mergeCustomHtmlTags(options.customHtmlTags, (options.parseOptions as any)?.customHtmlTags)
const cacheKey = `${options.cacheKey || DEFAULT_CACHE_KEY}::${normalizedTags.join(',')}`
let markdown = markdownCache.get(cacheKey)
if (!markdown) {
markdown = getMarkdown(cacheKey, {
customHtmlTags: normalizedTags,
})
markdownCache.set(cacheKey, markdown)
}
return options.customMarkdownIt
? options.customMarkdownIt(markdown)
: markdown
}
function getNodeList(value: unknown) {
return Array.isArray(value) ? value as BaseNode[] : []
}
@@ -0,0 +1,568 @@
import type { BaseNode, CustomComponentAttrs, ParsedNode, ParseOptions } from 'stream-markdown-parser'
import {
DANGEROUS_HTML_ATTRS,
getMarkdown,
isUnsafeHtmlUrl,
NON_STRUCTURING_HTML_TAGS,
normalizeCustomHtmlTagName,
normalizeCustomHtmlTags,
parseMarkdownToStructure,
URL_HTML_ATTRS,
} from 'stream-markdown-parser'
import { hydrateCustomTagContent } from './hydrateCustomTagContent'
import { sanitizeHtmlContent } from './sanitizeHtmlContent'
export type RenderableMarkdownNode = (ParsedNode | BaseNode) & Record<string, unknown>
type NestedClassValue = string | readonly string[] | null | undefined
export interface MarkstreamAngularRenderOptions {
content?: string | null
nodes?: readonly RenderableMarkdownNode[] | null
final?: boolean
parseOptions?: ParseOptions
customHtmlTags?: readonly string[]
allowHtml?: boolean
}
export interface NestedMarkdownHtmlOptions {
cacheKey?: string
customHtmlTags?: readonly string[]
allowHtml?: boolean
customNodeTag?: string
customNodeClass?: NestedClassValue | ((node: RenderableMarkdownNode) => NestedClassValue)
}
export interface NestedMarkdownHtmlInput {
node?: RenderableMarkdownNode | null
nodes?: readonly RenderableMarkdownNode[] | null
content?: string | null
}
interface RenderContext {
markdown: ReturnType<typeof getMarkdown>
options: Required<Pick<NestedMarkdownHtmlOptions, 'allowHtml' | 'customNodeTag'>> & Pick<NestedMarkdownHtmlOptions, 'customNodeClass'>
}
const DEFAULT_CACHE_KEY = 'markstream-angular-html'
const DEFAULT_CUSTOM_NODE_TAG = 'div'
const markdownCache = new Map<string, ReturnType<typeof getMarkdown>>()
const KNOWN_NODE_TYPES = new Set([
'admonition',
'blockquote',
'checkbox',
'checkbox_input',
'code_block',
'definition_item',
'definition_list',
'emoji',
'emphasis',
'footnote',
'footnote_reference',
'hardbreak',
'heading',
'highlight',
'html_block',
'html_inline',
'image',
'inline_code',
'insert',
'link',
'list',
'list_item',
'math_block',
'math_inline',
'paragraph',
'reference',
'strikethrough',
'strong',
'subscript',
'superscript',
'table',
'table_cell',
'table_row',
'text',
'thematic_break',
])
export function renderMarkdownToHtml(input: MarkstreamAngularRenderOptions): string {
const normalizedTags = normalizeCustomHtmlTags([
...(input.customHtmlTags || []),
...((((input.parseOptions as any)?.customHtmlTags) || []) as string[]),
])
const ctx = createRenderContext({
allowHtml: input.allowHtml,
customHtmlTags: normalizedTags,
})
const nodes = resolveParsedNodes(input, ctx)
return renderNodesToHtml(nodes, ctx)
}
export function renderNestedMarkdownToHtml(
input: NestedMarkdownHtmlInput,
options: NestedMarkdownHtmlOptions = {},
): string {
const ctx = createRenderContext(options)
return renderNestedInputToHtml(input, ctx)
}
export function renderMarkdownNodesToHtml(
nodes: readonly RenderableMarkdownNode[] | null | undefined,
options: NestedMarkdownHtmlOptions = {},
): string {
const ctx = createRenderContext(options)
return renderNodesToHtml(nodes, ctx)
}
export function renderMarkdownNodeToHtml(
node: RenderableMarkdownNode | null | undefined,
options: NestedMarkdownHtmlOptions = {},
): string {
const ctx = createRenderContext(options)
return renderNodeToHtml(node, ctx)
}
function resolveParsedNodes(input: MarkstreamAngularRenderOptions, ctx: RenderContext) {
if (Array.isArray(input.nodes))
return input.nodes
const content = getString(input.content)
if (!content)
return []
const normalizedTags = normalizeCustomHtmlTags([
...(input.customHtmlTags || []),
...((((input.parseOptions as any)?.customHtmlTags) || []) as string[]),
])
const mergedParseOptions: ParseOptions = {
...(input.parseOptions ?? {}),
}
if (typeof input.final === 'boolean')
mergedParseOptions.final = input.final
if (normalizedTags.length > 0)
(mergedParseOptions as any).customHtmlTags = normalizedTags
return hydrateCustomTagContent(
parseMarkdownToStructure(content, ctx.markdown, mergedParseOptions) as RenderableMarkdownNode[],
content,
normalizedTags,
)
}
function createRenderContext(options: NestedMarkdownHtmlOptions): RenderContext {
const normalizedTags = normalizeCustomHtmlTags(options.customHtmlTags)
const cacheKey = `${options.cacheKey || DEFAULT_CACHE_KEY}::${normalizedTags.join(',')}`
let markdown = markdownCache.get(cacheKey)
if (!markdown) {
markdown = getMarkdown(cacheKey, {
customHtmlTags: normalizedTags,
})
markdownCache.set(cacheKey, markdown)
}
return {
markdown,
options: {
allowHtml: options.allowHtml !== false,
customNodeTag: normalizeCustomHtmlTagName(options.customNodeTag) || DEFAULT_CUSTOM_NODE_TAG,
customNodeClass: options.customNodeClass,
},
}
}
function renderNestedInputToHtml(input: NestedMarkdownHtmlInput, ctx: RenderContext): string {
if (Array.isArray(input.nodes) && input.nodes.length > 0)
return renderNodesToHtml(input.nodes, ctx)
const node = input.node
if (node) {
const children = getNodeList(node.children)
if (children.length > 0)
return renderNodesToHtml(children, ctx)
const content = getString(node.content)
if (content)
return renderMarkdownFragment(content, ctx)
if (node.raw != null)
return escapeHtml(getString(node.raw))
}
return input.content ? renderMarkdownFragment(getString(input.content), ctx) : ''
}
function renderNodesToHtml(nodes: readonly RenderableMarkdownNode[] | null | undefined, ctx: RenderContext): string {
return (Array.isArray(nodes) ? nodes : []).map(node => renderNodeToHtml(node, ctx)).join('')
}
function renderNodeToHtml(node: RenderableMarkdownNode | null | undefined, ctx: RenderContext): string {
if (!node || typeof node !== 'object')
return ''
switch (node.type) {
case 'text':
return renderTextNode(node)
case 'paragraph':
return `<p>${renderNodesToHtml(getNodeList(node.children), ctx)}</p>`
case 'strong':
return `<strong>${renderNodesToHtml(getNodeList(node.children), ctx)}</strong>`
case 'emphasis':
return `<em>${renderNodesToHtml(getNodeList(node.children), ctx)}</em>`
case 'strikethrough':
return `<del>${renderNodesToHtml(getNodeList(node.children), ctx)}</del>`
case 'highlight':
return `<mark>${renderNodesToHtml(getNodeList(node.children), ctx)}</mark>`
case 'insert':
return `<ins>${renderNodesToHtml(getNodeList(node.children), ctx)}</ins>`
case 'subscript':
return `<sub>${renderNodesToHtml(getNodeList(node.children), ctx)}</sub>`
case 'superscript':
return `<sup>${renderNodesToHtml(getNodeList(node.children), ctx)}</sup>`
case 'inline_code':
return `<code>${escapeHtml(getString(node.code))}</code>`
case 'hardbreak':
return '<br>'
case 'link':
return renderLinkNode(node, ctx)
case 'image':
return renderImageNode(node)
case 'list':
return renderListNode(node, ctx)
case 'list_item':
return `<li>${renderNodesToHtml(getNodeList(node.children), ctx)}</li>`
case 'blockquote':
return `<blockquote>${renderNodesToHtml(getNodeList(node.children), ctx)}</blockquote>`
case 'heading':
return renderHeadingNode(node, ctx)
case 'code_block':
return renderCodeBlockNode(node)
case 'thematic_break':
return '<hr>'
case 'table':
return renderTableNode(node, ctx)
case 'table_row':
return renderTableRowNode(node, ctx)
case 'table_cell':
return renderTableCellNode(node, ctx)
case 'definition_list':
return renderDefinitionListNode(node, ctx)
case 'definition_item':
return renderDefinitionItemNode(node, ctx)
case 'footnote':
return renderFootnoteNode(node, ctx)
case 'footnote_reference':
return renderFootnoteReferenceNode(node)
case 'admonition':
return renderAdmonitionNode(node, ctx)
case 'checkbox':
case 'checkbox_input':
return `<input type="checkbox" disabled${node.checked ? ' checked' : ''}>`
case 'emoji':
return escapeHtml(getString(node.raw || node.markup || node.content || node.name))
case 'math_inline':
return renderMathInlineNode(node)
case 'math_block':
return renderMathBlockNode(node)
case 'reference':
return `<span class="markstream-nested-reference">${escapeHtml(getString(node.id))}</span>`
case 'html_inline':
case 'html_block':
return renderHtmlNode(node, ctx)
default:
return renderCustomOrFallbackNode(node, ctx)
}
}
function renderTextNode(node: RenderableMarkdownNode): string {
const content = getString(node.content)
const escaped = escapeHtml(content)
const centered = !!node.center
if (!centered && !content.includes('\n'))
return escaped
const className = centered
? 'markstream-angular-text-node markstream-angular-text--centered'
: 'markstream-angular-text-node'
return `<span class="${className}">${escaped}</span>`
}
function renderLinkNode(node: RenderableMarkdownNode, ctx: RenderContext): string {
const href = getString(node.href)
const title = getString(node.title)
const content = getNodeList(node.children).length > 0
? renderNodesToHtml(getNodeList(node.children), ctx)
: escapeHtml(getString(node.text || href))
const titleAttr = title ? ` title="${escapeAttr(title)}"` : ''
const hrefAttr = href ? ` href="${escapeAttr(href)}"` : ''
return `<a${hrefAttr}${titleAttr} target="_blank" rel="noreferrer noopener">${content}</a>`
}
function renderMathInlineNode(node: RenderableMarkdownNode) {
const source = escapeHtml(getString(node.content || node.markup || node.raw))
return `<span class="markstream-nested-math" data-display="inline"><span class="markstream-nested-math__source">${source}</span><span class="markstream-nested-math__render" aria-hidden="true"></span></span>`
}
function renderMathBlockNode(node: RenderableMarkdownNode) {
const source = escapeHtml(getString(node.content || node.markup || node.raw))
return `<div class="markstream-nested-math-block"><pre class="markstream-nested-math-block__source"><code>${source}</code></pre><div class="markstream-nested-math-block__render" aria-hidden="true"></div></div>`
}
function renderImageNode(node: RenderableMarkdownNode): string {
const src = getString(node.src)
const alt = getString(node.alt)
const title = getString(node.title)
const titleAttr = title ? ` title="${escapeAttr(title)}"` : ''
return `<img src="${escapeAttr(src)}" alt="${escapeAttr(alt)}"${titleAttr}>`
}
function renderListNode(node: RenderableMarkdownNode, ctx: RenderContext): string {
const tag = node.ordered ? 'ol' : 'ul'
const start = node.ordered && Number.isFinite(node.start) ? ` start="${Number(node.start)}"` : ''
return `<${tag}${start}>${renderNodesToHtml(getNodeList(node.items), ctx)}</${tag}>`
}
function renderHeadingNode(node: RenderableMarkdownNode, ctx: RenderContext): string {
const level = clampHeadingLevel(node.level)
return `<h${level}>${renderNodesToHtml(getNodeList(node.children), ctx)}</h${level}>`
}
function renderCodeBlockNode(node: RenderableMarkdownNode): string {
const rawLanguage = getString(node.language).trim()
const language = sanitizeClassToken(rawLanguage)
const languageClass = language ? ` class="language-${language}"` : ''
const diff = !!node.diff
const blockAttrs = [
'data-markstream-code-block="1"',
rawLanguage ? `data-markstream-language="${escapeAttr(rawLanguage)}"` : '',
diff ? 'data-markstream-diff="1"' : '',
diff ? `data-markstream-original="${escapeAttr(encodeDataPayload(getString(node.originalCode)))}"` : '',
diff ? `data-markstream-updated="${escapeAttr(encodeDataPayload(getString(node.updatedCode)))}"` : '',
].filter(Boolean).join(' ')
return `<pre ${blockAttrs}><code${languageClass}>${escapeHtml(getString(node.code))}</code></pre>`
}
function renderTableNode(node: RenderableMarkdownNode, ctx: RenderContext): string {
const header = node.header ? renderTableRowNode(node.header as RenderableMarkdownNode, ctx, true) : ''
const rows = renderNodesToHtml(getNodeList(node.rows), ctx)
const thead = header ? `<thead>${header}</thead>` : ''
const tbody = rows ? `<tbody>${rows}</tbody>` : ''
return `<table>${thead}${tbody}</table>`
}
function renderTableRowNode(node: RenderableMarkdownNode, ctx: RenderContext, forceHeader = false): string {
const cells = getNodeList(node.cells).map(cell => renderTableCellNode(cell, ctx, forceHeader)).join('')
return `<tr>${cells}</tr>`
}
function renderTableCellNode(node: RenderableMarkdownNode, ctx: RenderContext, forceHeader = false): string {
const tag = forceHeader || !!node.header ? 'th' : 'td'
const align = getString(node.align)
const alignAttr = align ? ` style="text-align:${escapeAttr(align)}"` : ''
return `<${tag}${alignAttr}>${renderNodesToHtml(getNodeList(node.children), ctx)}</${tag}>`
}
function renderDefinitionListNode(node: RenderableMarkdownNode, ctx: RenderContext): string {
return `<dl>${getNodeList(node.items).map(item => renderDefinitionItemNode(item, ctx)).join('')}</dl>`
}
function renderDefinitionItemNode(node: RenderableMarkdownNode, ctx: RenderContext): string {
const term = renderNodesToHtml(getNodeList(node.term), ctx)
const definition = renderNodesToHtml(getNodeList(node.definition), ctx)
return `<dt>${term}</dt><dd>${definition}</dd>`
}
function renderFootnoteNode(node: RenderableMarkdownNode, ctx: RenderContext): string {
const id = escapeAttr(getString(node.id))
return `<div id="fnref--${id}" class="markstream-nested-footnote">${renderNodesToHtml(getNodeList(node.children), ctx)}</div>`
}
function renderFootnoteReferenceNode(node: RenderableMarkdownNode): string {
const id = escapeHtml(getString(node.id))
const href = escapeAttr(getString(node.id))
return `<sup class="markstream-nested-footnote-ref"><a href="#fnref--${href}">[${id}]</a></sup>`
}
function renderAdmonitionNode(node: RenderableMarkdownNode, ctx: RenderContext): string {
const kind = sanitizeClassToken(getString(node.kind || 'note')) || 'note'
const title = getString(node.title) || capitalize(kind)
return `<div class="markstream-nested-admonition markstream-nested-admonition--${kind}"><div class="markstream-nested-admonition__title">${escapeHtml(title)}</div><div class="markstream-nested-admonition__content">${renderNodesToHtml(getNodeList(node.children), ctx)}</div></div>`
}
function renderHtmlNode(node: RenderableMarkdownNode, ctx: RenderContext): string {
const content = getString(node.content)
const rawContent = content || getString(node.raw)
const tag = getString(node.tag).trim().toLowerCase()
const children = getNodeList(node.children)
if (!ctx.options.allowHtml)
return escapeHtml(rawContent)
if (node.loading && !node.autoClosed)
return escapeHtml(rawContent)
if (tag && children.length > 0 && !NON_STRUCTURING_HTML_TAGS.has(tag)) {
const attrs = serializeAttrs(node.attrs as CustomComponentAttrs | undefined)
return `<${tag}${attrs}>${renderNodesToHtml(children, ctx)}</${tag}>`
}
return sanitizeHtmlContent(rawContent)
}
function renderCustomOrFallbackNode(node: RenderableMarkdownNode, ctx: RenderContext): string {
const tagName = getString(node.tag || node.type)
if (!tagName || KNOWN_NODE_TYPES.has(tagName))
return escapeHtml(getString(node.content || node.raw))
const classes = [
'markstream-nested-custom',
`markstream-nested-custom--${sanitizeClassToken(tagName) || 'node'}`,
resolveCustomNodeClass(node, ctx.options.customNodeClass),
].filter(Boolean).join(' ')
const attrs = serializeAttrs(node.attrs as CustomComponentAttrs | undefined, classes)
const body = resolveCustomNodeBody(node, ctx)
const wrapperTag = ctx.options.customNodeTag
return `<${wrapperTag}${attrs} data-markstream-custom-tag="${escapeAttr(tagName)}">${body}</${wrapperTag}>`
}
function resolveCustomNodeBody(node: RenderableMarkdownNode, ctx: RenderContext): string {
const children = getNodeList(node.children)
if (children.length > 0)
return renderNodesToHtml(children, ctx)
const content = getString(node.content)
if (content)
return renderMarkdownFragment(content, ctx)
return escapeHtml(getString(node.raw))
}
function renderMarkdownFragment(content: string, ctx: RenderContext): string {
return content ? ctx.markdown.render(content) : ''
}
function resolveCustomNodeClass(
node: RenderableMarkdownNode,
customNodeClass?: NestedMarkdownHtmlOptions['customNodeClass'],
): string {
if (!customNodeClass)
return ''
const resolved = typeof customNodeClass === 'function'
? customNodeClass(node)
: customNodeClass
return serializeClassValue(resolved)
}
function serializeClassValue(value: NestedClassValue): string {
if (Array.isArray(value))
return value.map(item => serializeClassValue(item)).filter(Boolean).join(' ')
return typeof value === 'string'
? value.trim()
: ''
}
function serializeAttrs(attrs?: CustomComponentAttrs | null, extraClass = ''): string {
const pairs = normalizeAttrs(attrs)
const rendered: string[] = []
const mergedClasses = [extraClass]
for (const [name, value] of pairs) {
const safeName = String(name).trim()
const lowerName = safeName.toLowerCase()
if (!safeName || !isSafeAttrName(safeName))
continue
if (DANGEROUS_HTML_ATTRS.has(lowerName))
continue
if (value !== true && URL_HTML_ATTRS.has(lowerName) && value && isUnsafeHtmlUrl(String(value)))
continue
if (lowerName === 'class') {
mergedClasses.push(String(value))
continue
}
if (value === true)
rendered.push(` ${safeName}`)
else
rendered.push(` ${safeName}="${escapeAttr(String(value))}"`)
}
const className = mergedClasses.map(value => value.trim()).filter(Boolean).join(' ')
if (className)
rendered.unshift(` class="${escapeAttr(className)}"`)
return rendered.join('')
}
function normalizeAttrs(attrs?: CustomComponentAttrs | null): Array<[string, string | boolean]> {
if (!attrs)
return []
if (Array.isArray(attrs)) {
if (attrs.length === 0)
return []
const first = attrs[0]
if (Array.isArray(first))
return (attrs as [string, string][]).map(([name, value]) => [String(name), value])
return (attrs as Array<{ name: string, value: string | boolean }>).map(item => [String(item.name), item.value])
}
return Object.entries(attrs).map(([name, value]) => [name, value])
}
function getNodeList(value: unknown): RenderableMarkdownNode[] {
return Array.isArray(value)
? value.filter((item): item is RenderableMarkdownNode => !!item && typeof item === 'object')
: []
}
function getString(value: unknown): string {
return typeof value === 'string'
? value
: value == null
? ''
: String(value)
}
function isSafeAttrName(value: string): boolean {
return /^[^\s"'<>`=]+$/.test(value) && !/^on/i.test(value)
}
function sanitizeClassToken(value: string): string {
return value.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '')
}
function clampHeadingLevel(value: unknown): number {
const level = Math.trunc(Number(value) || 1)
return Math.min(6, Math.max(1, level))
}
function escapeHtml(value: unknown): string {
return getString(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function escapeAttr(value: unknown): string {
return escapeHtml(value).replace(/`/g, '&#96;')
}
function capitalize(value: string): string {
return value ? `${value[0].toUpperCase()}${value.slice(1)}` : ''
}
function encodeDataPayload(value: string) {
if (!value)
return ''
const globalBuffer = (globalThis as any)?.require?.('buffer')?.Buffer
if (globalBuffer?.from)
return globalBuffer.from(value, 'utf8').toString('base64')
if (typeof TextEncoder !== 'undefined' && typeof globalThis.btoa === 'function') {
const bytes = new TextEncoder().encode(value)
let binary = ''
for (const byte of bytes)
binary += String.fromCharCode(byte)
return globalThis.btoa(binary)
}
return ''
}
@@ -0,0 +1,212 @@
import {
BLOCKED_HTML_TAGS as BLOCKED_TAGS,
DANGEROUS_HTML_ATTRS as DANGEROUS_ATTRS,
isUnsafeHtmlUrl as isUnsafeUrl,
URL_HTML_ATTRS as URL_ATTRS,
VOID_HTML_TAGS as VOID_ELEMENTS,
} from 'stream-markdown-parser'
import { escapeAttr, escapeHtml, isSafeAttrName } from './components/shared/node-helpers'
interface HtmlToken {
type: 'text' | 'tag_open' | 'tag_close' | 'self_closing'
tagName?: string
attrs?: Record<string, string>
content?: string
}
function sanitizeAttrs(attrs: Record<string, string>): Record<string, string> {
const clean: Record<string, string> = {}
for (const [key, value] of Object.entries(attrs)) {
const safeName = key.trim()
const lowerKey = safeName.toLowerCase()
if (!safeName || !isSafeAttrName(safeName))
continue
if (DANGEROUS_ATTRS.has(lowerKey))
continue
if (URL_ATTRS.has(lowerKey) && value && isUnsafeUrl(value))
continue
clean[safeName] = value
}
return clean
}
function tokenizeHtml(html: string): HtmlToken[] {
const tokens: HtmlToken[] = []
let pos = 0
while (pos < html.length) {
if (html.startsWith('<!--', pos)) {
const commentEnd = html.indexOf('-->', pos)
if (commentEnd !== -1) {
pos = commentEnd + 3
continue
}
break
}
const tagStart = html.indexOf('<', pos)
if (tagStart === -1) {
if (pos < html.length)
tokens.push({ type: 'text', content: html.slice(pos) })
break
}
if (tagStart > pos)
tokens.push({ type: 'text', content: html.slice(pos, tagStart) })
if (html.startsWith('![CDATA[', tagStart + 1)) {
const cdataEnd = html.indexOf(']]>', tagStart)
if (cdataEnd !== -1) {
tokens.push({ type: 'text', content: html.slice(tagStart, cdataEnd + 3) })
pos = cdataEnd + 3
continue
}
break
}
if (html.startsWith('!', tagStart + 1)) {
const specialEnd = html.indexOf('>', tagStart)
if (specialEnd !== -1) {
pos = specialEnd + 1
continue
}
break
}
const tagEnd = html.indexOf('>', tagStart)
if (tagEnd === -1)
break
const tagContent = html.slice(tagStart + 1, tagEnd).trim()
if (!tagContent) {
pos = tagEnd + 1
continue
}
const isClosingTag = tagContent.startsWith('/')
const isSelfClosing = tagContent.endsWith('/')
if (isClosingTag) {
const tagName = tagContent.slice(1).trim()
tokens.push({ type: 'tag_close', tagName })
pos = tagEnd + 1
continue
}
const spaceIndex = tagContent.indexOf(' ')
let tagName = ''
let attrsStr = ''
if (spaceIndex === -1) {
tagName = isSelfClosing ? tagContent.slice(0, -1).trim() : tagContent.trim()
}
else {
tagName = tagContent.slice(0, spaceIndex).trim()
attrsStr = tagContent.slice(spaceIndex + 1)
}
const attrs: Record<string, string> = {}
if (attrsStr) {
const attrRegex = /([^\s=]+)(?:=(?:"([^"]*)"|'([^']*)'|(\S*)))?/g
let attrMatch: RegExpExecArray | null
while ((attrMatch = attrRegex.exec(attrsStr)) !== null) {
const name = attrMatch[1]
const value = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? ''
if (name && !name.endsWith('/'))
attrs[name] = value
}
}
tokens.push({
type: isSelfClosing || VOID_ELEMENTS.has(tagName.toLowerCase()) ? 'self_closing' : 'tag_open',
tagName,
attrs,
})
pos = tagEnd + 1
}
return tokens
}
function normalizeTagName(tagName: string | undefined): string {
return String(tagName ?? '').trim().toLowerCase()
}
function serializeAttrs(attrs: Record<string, string>): string {
const pairs = Object.entries(attrs)
if (pairs.length === 0)
return ''
return pairs
.map(([name, value]) => value === '' ? ` ${name}` : ` ${name}="${escapeAttr(value)}"`)
.join('')
}
export function sanitizeHtmlContent(content: string): string {
if (!content)
return ''
const tokens = tokenizeHtml(content)
const stack: string[] = []
const output: string[] = []
let blockedDepth = 0
for (const token of tokens) {
if (token.type === 'text') {
if (blockedDepth === 0)
output.push(escapeHtml(token.content ?? ''))
continue
}
const tagName = normalizeTagName(token.tagName)
if (!tagName)
continue
if (BLOCKED_TAGS.has(tagName)) {
if (token.type === 'tag_open')
blockedDepth += 1
else if (token.type === 'tag_close' && blockedDepth > 0)
blockedDepth -= 1
continue
}
if (blockedDepth > 0)
continue
if (token.type === 'self_closing') {
output.push(`<${tagName}${serializeAttrs(sanitizeAttrs(token.attrs ?? {}))}>`)
continue
}
if (token.type === 'tag_open') {
output.push(`<${tagName}${serializeAttrs(sanitizeAttrs(token.attrs ?? {}))}>`)
if (!VOID_ELEMENTS.has(tagName))
stack.push(tagName)
continue
}
const matchedIndex = stack.lastIndexOf(tagName)
if (matchedIndex === -1)
continue
while (stack.length > matchedIndex + 1) {
const danglingTag = stack.pop()
if (danglingTag)
output.push(`</${danglingTag}>`)
}
const closingTag = stack.pop()
if (closingTag)
output.push(`</${closingTag}>`)
}
while (stack.length > 0) {
const danglingTag = stack.pop()
if (danglingTag)
output.push(`</${danglingTag}>`)
}
return output.join('')
}
@@ -0,0 +1,86 @@
const DISALLOWED_STYLE_PATTERNS = [/javascript:/i, /expression\s*\(/i, /url\s*\(\s*javascript:/i, /@import/i]
const SAFE_URL_PROTOCOLS = /^(?:https?:|mailto:|tel:|#|\/|data:image\/(?:png|gif|jpe?g|webp);)/i
function neutralizeScriptProtocols(raw: string) {
return raw
.replace(/["']\s*javascript:/gi, '#')
.replace(/\bjavascript:/gi, '#')
.replace(/["']\s*vbscript:/gi, '#')
.replace(/\bvbscript:/gi, '#')
.replace(/\bdata:text\/html/gi, '#')
}
function sanitizeUrl(value: string | null | undefined) {
if (!value)
return ''
const trimmed = value.trim()
if (SAFE_URL_PROTOCOLS.test(trimmed))
return trimmed
return ''
}
function scrubSvgElement(svgEl: SVGElement) {
const forbiddenTags = new Set(['script'])
const nodes = [svgEl, ...Array.from(svgEl.querySelectorAll<SVGElement>('*'))]
for (const node of nodes) {
if (forbiddenTags.has(node.tagName.toLowerCase())) {
node.remove()
continue
}
const attrs = Array.from(node.attributes)
for (const attr of attrs) {
const name = attr.name
if (/^on/i.test(name)) {
node.removeAttribute(name)
continue
}
if (name === 'style' && attr.value) {
if (DISALLOWED_STYLE_PATTERNS.some(re => re.test(attr.value))) {
node.removeAttribute(name)
continue
}
}
if ((name === 'href' || name === 'xlink:href') && attr.value) {
const safe = sanitizeUrl(attr.value)
if (!safe) {
node.removeAttribute(name)
continue
}
if (safe !== attr.value)
node.setAttribute(name, safe)
}
}
}
}
export function toSafeSvgMarkup(svg: string | null | undefined) {
if (typeof DOMParser === 'undefined')
return ''
if (!svg)
return ''
const neutralized = neutralizeScriptProtocols(svg)
const parsed = new DOMParser().parseFromString(neutralized, 'image/svg+xml')
const svgEl = parsed.documentElement
if (!svgEl || svgEl.nodeName.toLowerCase() !== 'svg')
return ''
const svgElement = svgEl as unknown as SVGElement
scrubSvgElement(svgElement)
return svgElement.outerHTML
}
export function extractRenderedSvg(renderResult: any) {
if (!renderResult)
return ''
if (typeof renderResult === 'string')
return renderResult
if (typeof renderResult.svg === 'string')
return renderResult.svg
if (typeof renderResult.data === 'string')
return renderResult.data
return ''
}
@@ -0,0 +1,154 @@
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'
export type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right'
let tooltipEl: HTMLDivElement | null = null
let currentAnchor: HTMLElement | null = null
let cleanupAutoUpdate: (() => void) | null = null
let showTimer: ReturnType<typeof setTimeout> | null = null
let hideTimer: ReturnType<typeof setTimeout> | null = null
let currentId: string | null = null
function clearTimers() {
if (showTimer) {
clearTimeout(showTimer)
showTimer = null
}
if (hideTimer) {
clearTimeout(hideTimer)
hideTimer = null
}
}
function ensureTooltipEl() {
if (tooltipEl || typeof document === 'undefined')
return tooltipEl
tooltipEl = document.createElement('div')
tooltipEl.className = 'ms-tooltip'
tooltipEl.setAttribute('role', 'tooltip')
tooltipEl.dataset.visible = 'false'
tooltipEl.style.position = 'fixed'
tooltipEl.style.left = '0px'
tooltipEl.style.top = '0px'
tooltipEl.style.transform = 'translate3d(0,0,0)'
document.body.appendChild(tooltipEl)
return tooltipEl
}
function detectDarkModeHint(hint?: boolean | null) {
if (typeof hint === 'boolean')
return hint
if (typeof document !== 'undefined') {
try {
if (document.documentElement.classList.contains('dark'))
return true
}
catch {
// Ignore classList failures on locked-down documents.
}
}
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
try {
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
catch {
// Ignore matchMedia failures in non-browser environments.
}
}
return false
}
async function updateTooltipPosition(placement: TooltipPlacement) {
if (!tooltipEl || !currentAnchor)
return
const { x, y } = await computePosition(currentAnchor, tooltipEl, {
placement,
middleware: [offset(8), flip(), shift({ padding: 8 })],
strategy: 'fixed',
})
tooltipEl.style.transform = `translate3d(${Math.round(x)}px, ${Math.round(y)}px, 0)`
}
export function showTooltipForAnchor(
anchor: HTMLElement | null,
content: string,
placement: TooltipPlacement = 'top',
immediate = false,
isDark?: boolean | null,
) {
if (!anchor || typeof document === 'undefined')
return
const element = ensureTooltipEl()
if (!element)
return
clearTimers()
const doShow = async () => {
if (!tooltipEl)
return
currentAnchor = anchor
tooltipEl.textContent = content
tooltipEl.dataset.placement = placement
tooltipEl.dataset.dark = detectDarkModeHint(isDark) ? 'true' : 'false'
tooltipEl.dataset.visible = 'false'
currentId = `tooltip-${Date.now()}-${Math.random().toString(36).slice(2)}`
tooltipEl.id = currentId
try {
anchor.setAttribute('aria-describedby', currentId)
}
catch {
// Ignore aria sync failures.
}
await updateTooltipPosition(placement)
tooltipEl.dataset.visible = 'true'
cleanupAutoUpdate?.()
cleanupAutoUpdate = autoUpdate(anchor, tooltipEl, () => {
void updateTooltipPosition(placement)
})
}
if (immediate)
void doShow()
else
showTimer = setTimeout(doShow, 80)
}
export function hideTooltip(immediate = false) {
if (!tooltipEl)
return
clearTimers()
const doHide = () => {
if (!tooltipEl)
return
tooltipEl.dataset.visible = 'false'
if (currentAnchor && currentId) {
try {
currentAnchor.removeAttribute('aria-describedby')
}
catch {
// Ignore aria sync failures.
}
}
currentAnchor = null
currentId = null
cleanupAutoUpdate?.()
cleanupAutoUpdate = null
}
if (immediate)
doHide()
else
hideTimer = setTimeout(doHide, 120)
}
@@ -0,0 +1,78 @@
export interface CodeBlockMonacoThemeObject {
name: string
base?: string
inherit?: boolean
colors?: Record<string, string>
rules?: Array<Record<string, unknown>>
[key: string]: unknown
}
export type CodeBlockMonacoTheme = string | CodeBlockMonacoThemeObject
export type CodeBlockMonacoLanguage = string | ((...args: any[]) => unknown)
export interface CodeBlockDiffHideUnchangedRegionsOptions {
enabled?: boolean
contextLineCount?: number
minimumLineCount?: number
revealLineCount?: number
}
export type CodeBlockDiffHideUnchangedRegions
= | boolean
| CodeBlockDiffHideUnchangedRegionsOptions
export type CodeBlockDiffLineStyle = 'background' | 'bar'
export type CodeBlockDiffAppearance = 'auto' | 'light' | 'dark'
export type CodeBlockDiffUnchangedRegionStyle = 'line-info' | 'line-info-basic' | 'metadata' | 'simple'
export type CodeBlockDiffHunkActionKind = 'revert' | 'stage'
export type CodeBlockDiffHunkSide = 'upper' | 'lower'
export interface CodeBlockDiffHunkActionContext {
action: CodeBlockDiffHunkActionKind
side: CodeBlockDiffHunkSide
lineChange: unknown
originalModel: unknown
modifiedModel: unknown
}
export interface CodeBlockMonacoOptions {
MAX_HEIGHT?: number | string
fontSize?: number
lineHeight?: number
fontFamily?: string
tabSize?: number
readOnly?: boolean
wordWrap?: 'off' | 'on' | 'wordWrapColumn' | 'bounded' | string
wrappingIndent?: 'none' | 'same' | 'indent' | 'deepIndent' | string
theme?: string
themes?: CodeBlockMonacoTheme[]
languages?: CodeBlockMonacoLanguage[]
renderSideBySide?: boolean
useInlineViewWhenSpaceIsLimited?: boolean
enableSplitViewResizing?: boolean
ignoreTrimWhitespace?: boolean
maxComputationTime?: number
diffAlgorithm?: string
renderIndicators?: boolean
originalEditable?: boolean
revealDebounceMs?: number
revealStrategy?: 'bottom' | 'centerIfOutside' | 'center'
revealBatchOnIdleMs?: number
updateThrottleMs?: number
diffUpdateThrottleMs?: number
diffAutoScroll?: boolean
diffHideUnchangedRegions?: CodeBlockDiffHideUnchangedRegions
diffLineStyle?: CodeBlockDiffLineStyle
diffAppearance?: CodeBlockDiffAppearance
diffUnchangedRegionStyle?: CodeBlockDiffUnchangedRegionStyle
diffHunkActionsOnHover?: boolean
diffHunkHoverHideDelayMs?: number
onDiffHunkAction?: (context: CodeBlockDiffHunkActionContext) => void | boolean | Promise<void | boolean>
scrollbar?: Record<string, any>
[key: string]: any
}
@@ -0,0 +1,148 @@
export type LanguageIconResolver = (lang: string) => string | undefined | null
const DEFAULT_ICON = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M14 3h5a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5" />
<path d="M8 11l-3 3l3 3" />
<path d="M16 11l3 3l-3 3" />
<path d="M13 9l-2 10" />
</svg>`
const MERMAID_ICON = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="4" width="7" height="5" rx="1.5" />
<rect x="14" y="4" width="7" height="5" rx="1.5" />
<rect x="8.5" y="15" width="7" height="5" rx="1.5" />
<path d="M10 6.5h4" />
<path d="M12 9v2.5" />
<path d="M12 11.5l-3.5 3.5" />
<path d="M12 11.5l3.5 3.5" />
</svg>`
const HTML_ICON = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M8 7l-4 5l4 5" />
<path d="M16 7l4 5l-4 5" />
<path d="M14 4l-4 16" />
</svg>`
const MARKDOWN_ICON = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="M7 15V9l3 3l3-3v6" />
<path d="M16 9v6" />
<path d="M14 13l2 2l2-2" />
</svg>`
const JSON_ICON = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 4c-2 0-3 1.5-3 3.5v2c0 1.1-.4 1.8-1.5 2.5C5.6 12.7 6 13.4 6 14.5v2C6 18.5 7 20 9 20" />
<path d="M15 4c2 0 3 1.5 3 3.5v2c0 1.1.4 1.8 1.5 2.5c-1.1.7-1.5 1.4-1.5 2.5v2c0 2-1 3.5-3 3.5" />
</svg>`
const TEXT_ICON = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 6h14" />
<path d="M12 6v12" />
<path d="M8 18h8" />
</svg>`
const LANGUAGE_ALIAS_MAP: Record<string, string> = {
'': '',
'cjs': 'javascript',
'd2lang': 'd2',
'js': 'javascript',
'jsx': 'jsx',
'md': 'markdown',
'mjs': 'javascript',
'plaintext': 'plain',
'py': 'python',
'sh': 'shell',
'shellscript': 'shell',
'text': 'plain',
'ts': 'typescript',
'tsx': 'tsx',
}
const LANGUAGE_LABEL_MAP: Record<string, string> = {
'': 'Text',
'css': 'CSS',
'd2': 'D2',
'html': 'HTML',
'javascript': 'JavaScript',
'json': 'JSON',
'jsx': 'JSX',
'markdown': 'Markdown',
'mermaid': 'Mermaid',
'plain': 'Text',
'plaintext': 'Text',
'python': 'Python',
'shell': 'Shell',
'svg': 'SVG',
'tsx': 'TSX',
'typescript': 'TypeScript',
'vue': 'Vue',
}
const LANGUAGE_ICON_MAP: Record<string, string> = {
'': TEXT_ICON,
'html': HTML_ICON,
'javascript': DEFAULT_ICON,
'json': JSON_ICON,
'jsx': DEFAULT_ICON,
'markdown': MARKDOWN_ICON,
'mermaid': MERMAID_ICON,
'plain': TEXT_ICON,
'python': DEFAULT_ICON,
'shell': DEFAULT_ICON,
'svg': HTML_ICON,
'tsx': DEFAULT_ICON,
'typescript': DEFAULT_ICON,
}
let userLanguageIconResolver: LanguageIconResolver | null = null
function extractLanguageToken(lang?: string | null) {
if (!lang)
return ''
const trimmed = lang.trim()
if (!trimmed)
return ''
const [firstToken] = trimmed.split(/\s+/)
const [base] = firstToken.split(':')
return base.toLowerCase()
}
export function normalizeLanguageIdentifier(lang?: string | null): string {
const token = extractLanguageToken(lang)
return LANGUAGE_ALIAS_MAP[token] ?? token
}
export function resolveMonacoLanguageId(lang?: string | null): string {
const canonical = normalizeLanguageIdentifier(lang)
if (!canonical)
return 'plaintext'
if (canonical === 'plain')
return 'plaintext'
if (canonical === 'jsx')
return 'javascript'
if (canonical === 'tsx')
return 'typescript'
return canonical
}
export function setLanguageIconResolver(resolver?: LanguageIconResolver | null) {
userLanguageIconResolver = resolver ?? null
}
export function getLanguageIcon(lang: string): string {
if (userLanguageIconResolver) {
const hit = userLanguageIconResolver(lang)
if (hit != null && hit !== '')
return hit
}
const normalized = normalizeLanguageIdentifier(lang)
return LANGUAGE_ICON_MAP[normalized] || DEFAULT_ICON
}
export const languageMap = LANGUAGE_LABEL_MAP
@@ -0,0 +1,8 @@
export function normalizeKaTeXRenderInput(content: string) {
if (!content)
return ''
return content
.replace(/·/g, '⋅')
.replace(/℃/g, '°C')
}
@@ -0,0 +1,223 @@
export type KaTeXCDNWorkerMode = 'classic' | 'module'
export interface KaTeXCDNWorkerOptions {
katexUrl: string
mhchemUrl?: string
mode?: KaTeXCDNWorkerMode
debug?: boolean
workerOptions?: WorkerOptions
renderOptions?: {
throwOnError?: boolean
output?: string
strict?: string
}
}
export interface KaTeXCDNWorkerHandle {
worker: Worker | null
dispose: () => void
source: string
}
function stringifyForWorker(value: any) {
return JSON.stringify(value)
}
export function buildKaTeXCDNWorkerSource(options: KaTeXCDNWorkerOptions): string {
const mode: KaTeXCDNWorkerMode = options.mode ?? 'classic'
const renderOptions = {
throwOnError: true,
displayMode: true,
output: 'html',
strict: 'ignore',
...(options.renderOptions || {}),
}
const renderOptionsLiteral = stringifyForWorker(renderOptions)
const katexUrlLiteral = stringifyForWorker(options.katexUrl)
const mhchemUrlLiteral = options.mhchemUrl ? stringifyForWorker(options.mhchemUrl) : '""'
if (mode === 'module') {
return `
let DEBUG = false
let katex = null
let katexLoadError = null
let loadPromise = null
function normalizeKaTeX(mod) {
const resolved = mod && mod.default ? mod.default : mod
if (resolved && typeof resolved.renderToString === 'function')
return resolved
return null
}
async function loadKaTeX() {
if (katex || katexLoadError)
return
if (!loadPromise) {
loadPromise = (async () => {
try {
const mod = await import(${katexUrlLiteral})
katex = normalizeKaTeX(mod) || null
const mhchemUrl = ${mhchemUrlLiteral}
if (mhchemUrl) {
try {
await import(mhchemUrl)
}
catch (e) {
if (DEBUG)
console.warn('[markstream-angular:katex-cdn-worker] failed to load mhchem', e)
}
}
}
catch (e) {
katexLoadError = e
}
})()
}
await loadPromise
}
globalThis.addEventListener('message', async (ev) => {
const data = ev.data || {}
if (data.type === 'init') {
DEBUG = !!data.debug
return
}
const id = data.id ?? ''
const content = data.content ?? ''
const displayMode = data.displayMode ?? true
await loadKaTeX()
if (!katex) {
const reason = katexLoadError ? String(katexLoadError?.message || katexLoadError) : 'KaTeX is not available in worker'
globalThis.postMessage({ id, error: reason, content, displayMode })
return
}
try {
const opts = ${renderOptionsLiteral}
const html = katex.renderToString(content, { ...opts, displayMode: !!displayMode })
globalThis.postMessage({ id, html, content, displayMode })
}
catch (err) {
globalThis.postMessage({ id, error: String(err?.message || err), content, displayMode })
}
})
`.trimStart()
}
return `
let DEBUG = false
let katex = null
let katexLoadError = null
function normalizeKaTeX(val) {
const resolved = val && val.default ? val.default : val
if (resolved && typeof resolved.renderToString === 'function')
return resolved
return null
}
function loadKaTeXClassic() {
if (katex || katexLoadError)
return
try {
importScripts(${katexUrlLiteral})
const mhchemUrl = ${mhchemUrlLiteral}
if (mhchemUrl) {
try {
importScripts(mhchemUrl)
}
catch (e) {
if (DEBUG)
console.warn('[markstream-angular:katex-cdn-worker] failed to load mhchem', e)
}
}
katex = normalizeKaTeX(globalThis.katex)
}
catch (e) {
katexLoadError = e
}
}
loadKaTeXClassic()
globalThis.addEventListener('message', (ev) => {
const data = ev.data || {}
if (data.type === 'init') {
DEBUG = !!data.debug
return
}
const id = data.id ?? ''
const content = data.content ?? ''
const displayMode = data.displayMode ?? true
if (!katex && !katexLoadError)
loadKaTeXClassic()
if (!katex) {
const reason = katexLoadError ? String(katexLoadError?.message || katexLoadError) : 'KaTeX is not available in worker'
globalThis.postMessage({ id, error: reason, content, displayMode })
return
}
try {
const opts = ${renderOptionsLiteral}
const html = katex.renderToString(content, { ...opts, displayMode: !!displayMode })
globalThis.postMessage({ id, html, content, displayMode })
}
catch (err) {
globalThis.postMessage({ id, error: String(err?.message || err), content, displayMode })
}
})
`.trimStart()
}
export function createKaTeXWorkerFromCDN(options: KaTeXCDNWorkerOptions): KaTeXCDNWorkerHandle {
const source = buildKaTeXCDNWorkerSource(options)
if (typeof Worker === 'undefined' || typeof URL === 'undefined' || typeof Blob === 'undefined') {
return {
worker: null,
dispose: () => {},
source,
}
}
const blob = new Blob([source], { type: 'text/javascript' })
const url = URL.createObjectURL(blob)
let revoked = false
const dispose = () => {
if (revoked)
return
revoked = true
try {
URL.revokeObjectURL(url)
}
catch {
// Ignore revoke failures.
}
}
const mode = options.mode ?? 'classic'
const workerOptions = mode === 'module'
? ({ ...(options.workerOptions ?? {}), type: 'module' as const } satisfies WorkerOptions)
: options.workerOptions
const worker = new Worker(url, workerOptions)
if (options.debug) {
try {
worker.postMessage({ type: 'init', debug: true })
}
catch {
// Ignore init messaging failures.
}
}
return { worker, dispose, source }
}
@@ -0,0 +1,73 @@
import katex from 'katex'
import 'katex/dist/contrib/mhchem'
interface MessageIn {
type?: 'init' | 'render'
id?: string
content?: string
displayMode?: boolean
debug?: boolean
}
interface MessageOut {
id: string
html?: string
error?: string
}
let debugWorker = false
;(globalThis as any).addEventListener('message', (event: MessageEvent<MessageIn>) => {
const data = event.data || {}
if (data.type === 'init') {
debugWorker = !!data.debug
return
}
const id = data.id ?? ''
const content = data.content ?? ''
const displayMode = data.displayMode ?? true
try {
if (debugWorker)
console.debug('[markstream-angular:katexRenderer.worker] render start', { id, displayMode, content })
const html = katex.renderToString(content, {
throwOnError: true,
displayMode,
output: 'html',
strict: 'ignore',
})
const result: MessageOut & { content: string, displayMode: boolean } = {
id,
html,
content,
displayMode,
}
;(globalThis as any).postMessage(result)
}
catch (error: any) {
const result: MessageOut & { content: string, displayMode: boolean } = {
id,
error: String(error?.message ?? error),
content,
displayMode,
}
;(globalThis as any).postMessage(result)
}
})
;(globalThis as any).addEventListener('error', (event: ErrorEvent) => {
try {
;(globalThis as any).postMessage({
id: '__worker_uncaught__',
error: String(event.message ?? event.error),
content: '',
displayMode: true,
})
}
catch {
// Ignore postMessage failures while surfacing uncaught worker errors.
}
})
@@ -0,0 +1,333 @@
import { isKatexEnabled } from '../optional/katex'
import { normalizeKaTeXRenderInput } from '../utils/normalizeKaTeXRenderInput'
export const WORKER_BUSY_CODE = 'WORKER_BUSY'
interface Pending {
resolve: (val: string) => void
reject: (err: any) => void
timeoutId: number
}
let worker: Worker | null = null
let workerInitError: any = null
let debugKatexWorker = false
const pending = new Map<string, Pending>()
const cache = new Map<string, string>()
const cacheMax = 200
const drainWaiters = new Set<() => void>()
let maxConcurrency = 5
function notifyDrainIfBelowCap() {
if (pending.size >= maxConcurrency || drainWaiters.size === 0)
return
const currentWaiters = Array.from(drainWaiters)
drainWaiters.clear()
for (const waiter of currentWaiters) {
try {
waiter()
}
catch {
// Ignore waiter failures.
}
}
}
function rememberCache(content: string, displayMode: boolean, html: string) {
cache.set(`${displayMode ? 'd' : 'i'}:${content}`, html)
if (cache.size > cacheMax) {
const firstKey = cache.keys().next().value
if (firstKey)
cache.delete(firstKey)
}
}
export function setKaTeXWorker(nextWorker: Worker) {
worker = nextWorker
workerInitError = null
worker.onmessage = (event: MessageEvent) => {
const { id, html, error, content, displayMode } = event.data || {}
const active = pending.get(id)
if (!active)
return
pending.delete(id)
clearTimeout(active.timeoutId)
notifyDrainIfBelowCap()
if (error) {
active.reject(new Error(error))
return
}
if (content)
rememberCache(String(content), Boolean(displayMode), String(html || ''))
active.resolve(String(html || ''))
}
worker.onerror = (event: ErrorEvent) => {
for (const [, active] of pending.entries()) {
clearTimeout(active.timeoutId)
active.reject(new Error(`Worker error: ${event.message}`))
}
pending.clear()
notifyDrainIfBelowCap()
}
if (debugKatexWorker)
worker.postMessage({ type: 'init', debug: true })
}
export function clearKaTeXWorker() {
if (worker)
worker.terminate?.()
worker = null
workerInitError = null
}
function ensureWorker() {
if (worker)
return worker
workerInitError = new Error('[markstream-angular:katexWorkerClient] No worker instance set. Please inject a Worker via setKaTeXWorker().')
;(workerInitError as any).name = 'WorkerInitError'
;(workerInitError as any).code = 'WORKER_INIT_ERROR'
return null
}
export function setKaTeXWorkerDebug(enabled: boolean) {
debugKatexWorker = !!enabled
if (worker)
worker.postMessage({ type: 'init', debug: debugKatexWorker })
}
export async function renderKaTeXInWorker(content: string, displayMode = true, timeout = 2000, signal?: AbortSignal): Promise<string> {
const normalizedContent = normalizeKaTeXRenderInput(content)
if (!isKatexEnabled()) {
const error = new Error('KaTeX rendering disabled')
;(error as any).name = 'KaTeXDisabled'
;(error as any).code = 'KATEX_DISABLED'
return Promise.reject(error)
}
if (workerInitError)
return Promise.reject(workerInitError)
const cacheKey = `${displayMode ? 'd' : 'i'}:${normalizedContent}`
const cached = cache.get(cacheKey)
if (cached)
return cached
const activeWorker = ensureWorker()
if (!activeWorker)
return Promise.reject(workerInitError)
if (pending.size >= maxConcurrency) {
const error = new Error('Worker busy')
;(error as any).name = 'WorkerBusy'
;(error as any).code = WORKER_BUSY_CODE
;(error as any).busy = true
;(error as any).inFlight = pending.size
;(error as any).max = maxConcurrency
return Promise.reject(error)
}
return new Promise((resolve, reject) => {
if (signal?.aborted) {
const error = new Error('Aborted')
;(error as any).name = 'AbortError'
reject(error)
return
}
const id = Math.random().toString(36).slice(2)
const timeoutId = globalThis.setTimeout(() => {
pending.delete(id)
notifyDrainIfBelowCap()
const error = new Error('Worker render timed out')
;(error as any).name = 'WorkerTimeout'
;(error as any).code = 'WORKER_TIMEOUT'
reject(error)
}, timeout)
const onAbort = () => {
globalThis.clearTimeout(timeoutId)
if (pending.delete(id))
notifyDrainIfBelowCap()
const error = new Error('Aborted')
;(error as any).name = 'AbortError'
reject(error)
}
signal?.addEventListener('abort', onAbort, { once: true })
pending.set(id, {
timeoutId,
resolve: (html) => {
signal?.removeEventListener('abort', onAbort)
resolve(html)
},
reject: (error) => {
signal?.removeEventListener('abort', onAbort)
reject(error)
},
})
activeWorker.postMessage({
id,
type: 'render',
content: normalizedContent,
displayMode,
})
})
}
export function setKaTeXCache(content: string, displayMode = true, html: string) {
rememberCache(normalizeKaTeXRenderInput(content), displayMode, html)
}
export function getKaTeXWorkerLoad() {
return {
inFlight: pending.size,
max: maxConcurrency,
}
}
export function setKaTeXWorkerMaxConcurrency(value: number) {
if (Number.isFinite(value) && value > 0)
maxConcurrency = Math.floor(value)
}
export function isKaTeXWorkerBusy() {
return pending.size >= maxConcurrency
}
export function waitForKaTeXWorkerSlot(timeout = 2000, signal?: AbortSignal): Promise<void> {
if (pending.size < maxConcurrency)
return Promise.resolve()
return new Promise((resolve, reject) => {
let settled = false
let timer: ReturnType<typeof setTimeout> | null = null
const onDrain = () => {
if (settled)
return
settled = true
if (timer)
clearTimeout(timer)
drainWaiters.delete(onDrain)
resolve()
}
drainWaiters.add(onDrain)
timer = setTimeout(() => {
if (settled)
return
settled = true
drainWaiters.delete(onDrain)
const error = new Error('Wait for worker slot timed out')
;(error as any).name = 'WorkerBusyTimeout'
;(error as any).code = 'WORKER_BUSY_TIMEOUT'
reject(error)
}, timeout)
queueMicrotask(() => notifyDrainIfBelowCap())
if (signal) {
const onAbort = () => {
if (settled)
return
settled = true
if (timer)
clearTimeout(timer)
drainWaiters.delete(onDrain)
const error = new Error('Aborted')
;(error as any).name = 'AbortError'
reject(error)
}
if (signal.aborted)
onAbort()
else
signal.addEventListener('abort', onAbort, { once: true })
}
})
}
export interface BackpressureOptions {
timeout?: number
waitTimeout?: number
backoffMs?: number
maxRetries?: number
signal?: AbortSignal
}
const defaultBackpressure = {
timeout: 2000,
waitTimeout: 1500,
backoffMs: 30,
maxRetries: 1,
}
const maxBackpressureRetries = 8
export function setKaTeXBackpressureDefaults(options: Partial<typeof defaultBackpressure>) {
if (options.timeout != null)
defaultBackpressure.timeout = Math.max(0, Math.floor(options.timeout))
if (options.waitTimeout != null)
defaultBackpressure.waitTimeout = Math.max(0, Math.floor(options.waitTimeout))
if (options.backoffMs != null)
defaultBackpressure.backoffMs = Math.max(0, Math.floor(options.backoffMs))
if (options.maxRetries != null)
defaultBackpressure.maxRetries = Math.max(0, Math.floor(options.maxRetries))
}
export function getKaTeXBackpressureDefaults() {
return { ...defaultBackpressure }
}
export async function renderKaTeXWithBackpressure(
content: string,
displayMode = true,
options: BackpressureOptions = {},
): Promise<string> {
if (!isKatexEnabled()) {
const error = new Error('KaTeX rendering disabled')
;(error as any).name = 'KaTeXDisabled'
;(error as any).code = 'KATEX_DISABLED'
throw error
}
const timeout = options.timeout ?? defaultBackpressure.timeout
const waitTimeout = options.waitTimeout ?? defaultBackpressure.waitTimeout
const backoffMs = options.backoffMs ?? defaultBackpressure.backoffMs
const rawMaxRetries = options.maxRetries ?? defaultBackpressure.maxRetries
const maxRetries = Number.isFinite(rawMaxRetries)
? Math.max(0, Math.min(Math.floor(rawMaxRetries), maxBackpressureRetries))
: defaultBackpressure.maxRetries
let attempt = 0
for (;;) {
if (options.signal?.aborted) {
const error = new Error('Aborted')
;(error as any).name = 'AbortError'
throw error
}
try {
return await renderKaTeXInWorker(content, displayMode, timeout, options.signal)
}
catch (error: any) {
if (error?.code !== WORKER_BUSY_CODE || attempt >= maxRetries)
throw error
attempt += 1
await waitForKaTeXWorkerSlot(waitTimeout, options.signal).catch(() => {})
if (backoffMs > 0)
await new Promise(resolve => globalThis.setTimeout(resolve, backoffMs * attempt))
}
}
}
@@ -0,0 +1,259 @@
export type MermaidCDNWorkerMode = 'classic' | 'module'
export interface MermaidCDNWorkerOptions {
mermaidUrl: string
mode?: MermaidCDNWorkerMode
debug?: boolean
workerOptions?: WorkerOptions
initializeOptions?: Record<string, any>
}
export interface MermaidCDNWorkerHandle {
worker: Worker | null
dispose: () => void
source: string
}
function stringifyForWorker(value: any) {
return JSON.stringify(value)
}
export function buildMermaidCDNWorkerSource(options: MermaidCDNWorkerOptions): string {
const mode: MermaidCDNWorkerMode = options.mode ?? 'module'
const mermaidUrlLiteral = stringifyForWorker(options.mermaidUrl)
const initLiteral = stringifyForWorker({
startOnLoad: false,
securityLevel: 'loose',
...(options.initializeOptions || {}),
})
const sharedLogic = `
let DEBUG = false
let mermaid = null
let mermaidLoadError = null
function normalizeMermaidModule(mod) {
if (!mod)
return mod
const candidate = mod && mod.default ? mod.default : mod
if (candidate && (typeof candidate.render === 'function' || typeof candidate.parse === 'function' || typeof candidate.initialize === 'function'))
return candidate
if (candidate && candidate.mermaidAPI && (typeof candidate.mermaidAPI.render === 'function' || typeof candidate.mermaidAPI.parse === 'function')) {
const api = candidate.mermaidAPI
return {
...candidate,
render: api.render ? api.render.bind(api) : undefined,
parse: api.parse ? api.parse.bind(api) : undefined,
initialize: (opts) => {
if (typeof candidate.initialize === 'function')
return candidate.initialize(opts)
return api.initialize ? api.initialize(opts) : undefined
},
}
}
if (mod && mod.mermaid && typeof mod.mermaid.parse === 'function')
return mod.mermaid
return candidate
}
function applyThemeTo(code, theme) {
const themeValue = theme === 'dark' ? 'dark' : 'default'
const themeConfig = \`%%{init: {"theme": "\${themeValue}"}}%%\\n\`
const trimmed = String(code || '').trimStart()
if (trimmed.startsWith('%%{'))
return code
return themeConfig + code
}
function findHeaderIndex(lines) {
const headerRe = /^(?:graph|flowchart|flowchart\\s+tb|flowchart\\s+lr|sequenceDiagram|gantt|classDiagram|stateDiagram(?:-v2)?|erDiagram|journey|pie|quadrantChart|timeline|xychart(?:-beta)?)\\b/
for (let i = 0; i < lines.length; i++) {
const line = (lines[i] || '').trim()
if (!line || line.startsWith('%%'))
continue
if (headerRe.test(line))
return i
}
return -1
}
async function canParse(code, theme) {
const themed = applyThemeTo(code, theme)
if (mermaid && typeof mermaid.parse === 'function') {
await mermaid.parse(themed)
return true
}
throw new Error('mermaid.parse not available in worker')
}
async function findLastRenderablePrefix(baseCode, theme) {
const lines = String(baseCode || '').split('\\n')
const headerIndex = findHeaderIndex(lines)
if (headerIndex === -1)
return null
const head = lines.slice(0, headerIndex + 1)
await canParse(head.join('\\n'), theme)
let low = headerIndex + 1
let high = lines.length
let lastGood = headerIndex + 1
let tries = 0
const MAX_TRIES = 12
while (low <= high && tries < MAX_TRIES) {
const mid = Math.floor((low + high) / 2)
const candidate = [...head, ...lines.slice(headerIndex + 1, mid)].join('\\n')
tries += 1
try {
await canParse(candidate, theme)
lastGood = mid
low = mid + 1
}
catch {
high = mid - 1
}
}
return [...head, ...lines.slice(headerIndex + 1, lastGood)].join('\\n')
}
function initMermaidOnce() {
if (!mermaid)
return
try {
if (typeof mermaid.initialize === 'function')
mermaid.initialize(${initLiteral})
}
catch (e) {
if (DEBUG)
console.warn('[markstream-angular:mermaid-cdn-worker] initialize failed', e)
}
}
globalThis.addEventListener('message', async (ev) => {
const msg = ev.data || {}
if (msg.type === 'init') {
DEBUG = !!msg.debug
return
}
const id = msg.id
const action = msg.action
const payload = msg.payload || {}
if (!mermaid) {
const error = mermaidLoadError ? String(mermaidLoadError?.message || mermaidLoadError) : 'Mermaid is not available in worker'
globalThis.postMessage({ id, ok: false, error })
return
}
try {
if (action === 'canParse') {
const ok = await canParse(payload.code, payload.theme)
globalThis.postMessage({ id, ok: true, result: ok })
return
}
if (action === 'findPrefix') {
const result = await findLastRenderablePrefix(payload.code, payload.theme)
globalThis.postMessage({ id, ok: true, result })
return
}
globalThis.postMessage({ id, ok: false, error: 'Unknown action' })
}
catch (e) {
globalThis.postMessage({ id, ok: false, error: String(e?.message || e) })
}
})
`.trimStart()
if (mode === 'module') {
return `
${sharedLogic}
let loadPromise = null
async function loadMermaid() {
if (mermaid || mermaidLoadError)
return
if (!loadPromise) {
loadPromise = (async () => {
try {
const mod = await import(${mermaidUrlLiteral})
mermaid = normalizeMermaidModule(mod) || null
initMermaidOnce()
}
catch (e) {
mermaidLoadError = e
}
})()
}
await loadPromise
}
await loadMermaid()
`.trimStart()
}
return `
${sharedLogic}
function loadMermaidClassic() {
if (mermaid || mermaidLoadError)
return
try {
importScripts(${mermaidUrlLiteral})
mermaid = normalizeMermaidModule(globalThis.mermaid) || null
initMermaidOnce()
}
catch (e) {
mermaidLoadError = e
}
}
loadMermaidClassic()
`.trimStart()
}
export function createMermaidWorkerFromCDN(options: MermaidCDNWorkerOptions): MermaidCDNWorkerHandle {
const source = buildMermaidCDNWorkerSource(options)
if (typeof Worker === 'undefined' || typeof URL === 'undefined' || typeof Blob === 'undefined') {
return {
worker: null,
dispose: () => {},
source,
}
}
const blob = new Blob([source], { type: 'text/javascript' })
const url = URL.createObjectURL(blob)
let revoked = false
const dispose = () => {
if (revoked)
return
revoked = true
try {
URL.revokeObjectURL(url)
}
catch {
// Ignore revoke failures.
}
}
const mode = options.mode ?? 'module'
const workerOptions = mode === 'module'
? ({ ...(options.workerOptions ?? {}), type: 'module' as const } satisfies WorkerOptions)
: options.workerOptions
const worker = new Worker(url, workerOptions)
if (options.debug) {
try {
worker.postMessage({ type: 'init', debug: true })
}
catch {
// Ignore init messaging failures.
}
}
return { worker, dispose, source }
}
@@ -0,0 +1,114 @@
/// <reference lib="webworker" />
import mermaid from 'mermaid'
declare const self: DedicatedWorkerGlobalScope
mermaid.initialize({ startOnLoad: false, securityLevel: 'loose' })
type Theme = 'light' | 'dark'
interface RequestMessage {
id: string
action: 'canParse' | 'findPrefix' | (string & {})
payload: { code: string, theme: Theme }
}
type ResponseMessage
= | { id: string, ok: true, result: any }
| { id: string, ok: false, error: string }
function applyThemeTo(code: string, theme: Theme) {
const themeValue = theme === 'dark' ? 'dark' : 'default'
const themeConfig = `%%{init: {"theme": "${themeValue}"}}%%\n`
const trimmed = code.trimStart()
if (trimmed.startsWith('%%{'))
return code
return themeConfig + code
}
function findHeaderIndex(lines: string[]) {
const headerRe = /^(?:graph|flowchart|flowchart\s+tb|flowchart\s+lr|sequenceDiagram|gantt|classDiagram|stateDiagram(?:-v2)?|erDiagram|journey|pie|quadrantChart|timeline|xychart(?:-beta)?)\b/
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index].trim()
if (!line || line.startsWith('%%'))
continue
if (headerRe.test(line))
return index
}
return -1
}
async function canParse(code: string, theme: Theme) {
const themed = applyThemeTo(code, theme)
const anyMermaid = mermaid as any
if (typeof anyMermaid.parse === 'function') {
await anyMermaid.parse(themed)
return true
}
throw new Error('mermaid.parse not available in worker')
}
async function findLastRenderablePrefix(baseCode: string, theme: Theme) {
const lines = baseCode.split('\n')
const headerIndex = findHeaderIndex(lines)
if (headerIndex === -1)
return null
const head = lines.slice(0, headerIndex + 1)
await canParse(head.join('\n'), theme)
let low = headerIndex + 1
let high = lines.length
let lastGood = headerIndex + 1
let tries = 0
const maxTries = 12
while (low <= high && tries < maxTries) {
const mid = Math.floor((low + high) / 2)
const candidate = [...head, ...lines.slice(headerIndex + 1, mid)].join('\n')
tries += 1
try {
await canParse(candidate, theme)
lastGood = mid
low = mid + 1
}
catch {
high = mid - 1
}
}
return [...head, ...lines.slice(headerIndex + 1, lastGood)].join('\n')
}
self.onmessage = async (event: MessageEvent<RequestMessage>) => {
const message = event.data
const send = (response: ResponseMessage) => self.postMessage(response)
try {
if (message.action === 'canParse') {
send({
id: message.id,
ok: true,
result: await canParse(message.payload.code, message.payload.theme),
})
return
}
if (message.action === 'findPrefix') {
send({
id: message.id,
ok: true,
result: await findLastRenderablePrefix(message.payload.code, message.payload.theme),
})
return
}
send({ id: message.id, ok: false, error: 'Unknown action' })
}
catch (error: any) {
send({ id: message.id, ok: false, error: error?.message ?? String(error) })
}
}
export {}
@@ -0,0 +1,201 @@
import { isMermaidEnabled } from '../optional/mermaid'
type Theme = 'light' | 'dark'
let worker: Worker | null = null
let workerInitError: any = null
interface Pending {
resolve: (value: any) => void
reject: (error: any) => void
}
const rpcMap = new Map<string, Pending>()
let maxConcurrency = 5
let debugClient = false
export function setMermaidWorkerClientDebug(enabled: boolean) {
debugClient = !!enabled
}
export function setMermaidWorkerMaxConcurrency(value: number) {
if (Number.isFinite(value) && value > 0)
maxConcurrency = Math.floor(value)
}
export function getMermaidWorkerLoad() {
return { inFlight: rpcMap.size, max: maxConcurrency }
}
export const MERMAID_WORKER_BUSY_CODE = 'WORKER_BUSY'
export const MERMAID_DISABLED_CODE = 'MERMAID_DISABLED'
export function setMermaidWorker(nextWorker: Worker) {
worker = nextWorker
workerInitError = null
const current = nextWorker
worker.onmessage = (event: MessageEvent) => {
if (worker !== current)
return
const { id, ok, result, error } = event.data || {}
const active = rpcMap.get(id)
if (!active)
return
if (ok === false || error)
active.reject(new Error(error || 'Unknown error'))
else
active.resolve(result)
}
worker.onerror = (event: ErrorEvent) => {
if (worker !== current)
return
if (rpcMap.size === 0) {
console.debug?.('[markstream-angular:mermaidWorkerClient] Worker error (idle):', event?.message || event)
return
}
try {
if (debugClient)
console.error('[markstream-angular:mermaidWorkerClient] Worker error:', event?.message || event)
else
console.debug?.('[markstream-angular:mermaidWorkerClient] Worker error:', event?.message || event)
}
catch {
// Ignore logging failures.
}
for (const [, active] of rpcMap.entries())
active.reject(new Error(`Worker error: ${event.message}`))
rpcMap.clear()
}
;(worker as any).onmessageerror = (event: MessageEvent) => {
if (worker !== current)
return
if (rpcMap.size === 0) {
console.debug?.('[markstream-angular:mermaidWorkerClient] Worker messageerror (idle):', event)
return
}
for (const [, active] of rpcMap.entries())
active.reject(new Error('Worker messageerror'))
rpcMap.clear()
}
}
export function clearMermaidWorker() {
if (worker) {
try {
for (const [, active] of rpcMap.entries())
active.reject(new Error('Worker cleared'))
rpcMap.clear()
worker.terminate?.()
}
catch {
// Ignore worker termination failures.
}
}
worker = null
workerInitError = null
}
function ensureWorker() {
if (worker)
return worker
workerInitError = new Error('[markstream-angular:mermaidWorkerClient] No worker instance set. Please inject a Worker via setMermaidWorker().')
;(workerInitError as any).name = 'WorkerInitError'
;(workerInitError as any).code = 'WORKER_INIT_ERROR'
return null
}
function callWorker<T>(action: 'canParse' | 'findPrefix', payload: any, timeout = 1400): Promise<T> {
if (!isMermaidEnabled()) {
const error: any = new Error('Mermaid rendering disabled')
error.name = 'MermaidDisabled'
error.code = MERMAID_DISABLED_CODE
return Promise.reject(error)
}
if (workerInitError)
return Promise.reject(workerInitError)
const activeWorker = ensureWorker()
if (!activeWorker)
return Promise.reject(workerInitError)
if (rpcMap.size >= maxConcurrency) {
const error: any = new Error('Worker busy')
error.name = 'WorkerBusy'
error.code = MERMAID_WORKER_BUSY_CODE
error.inFlight = rpcMap.size
error.max = maxConcurrency
return Promise.reject(error)
}
return new Promise<T>((resolve, reject) => {
const id = Math.random().toString(36).slice(2)
let settled = false
let timeoutId: ReturnType<typeof setTimeout> | null = null
const cleanup = () => {
if (settled)
return
settled = true
if (timeoutId != null)
clearTimeout(timeoutId)
rpcMap.delete(id)
}
rpcMap.set(id, {
resolve: (value: any) => {
cleanup()
resolve(value)
},
reject: (error: any) => {
cleanup()
reject(error)
},
})
try {
activeWorker.postMessage({ id, action, payload })
}
catch (error) {
rpcMap.delete(id)
reject(error)
return
}
timeoutId = setTimeout(() => {
const error: any = new Error('Worker call timed out')
error.name = 'WorkerTimeout'
error.code = 'WORKER_TIMEOUT'
const pending = rpcMap.get(id)
pending?.reject(error)
}, timeout)
})
}
export async function canParseOffthread(code: string, theme: Theme, timeout = 1400) {
return await callWorker<boolean>('canParse', { code, theme }, timeout)
}
export async function findPrefixOffthread(code: string, theme: Theme, timeout = 1400) {
return await callWorker<string | null>('findPrefix', { code, theme }, timeout)
}
export function terminateWorker() {
if (worker) {
try {
for (const [, active] of rpcMap.entries())
active.reject(new Error('Worker terminated'))
rpcMap.clear()
worker.terminate()
}
finally {
worker = null
}
}
}
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"emitDeclarationOnly": false
},
"include": ["src/**/*.ts"]
}
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["DOM", "ES2022"],
"useDefineForClassFields": false,
"experimentalDecorators": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["vite/client"],
"strict": false,
"declaration": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
@@ -0,0 +1,77 @@
import path from 'node:path'
import { defineConfig } from 'vite'
import dts from 'vite-plugin-dts'
export default defineConfig(({ mode }) => ({
base: mode === 'npm' ? '' : './',
plugins: [
dts({
insertTypesEntry: true,
outDir: 'dist/types',
tsconfigPath: './tsconfig.build.json',
}),
],
resolve: mode === 'npm'
? undefined
: {
alias: [
{
find: /^stream-markdown-parser$/,
replacement: path.resolve(__dirname, '../markdown-parser/src/index.ts'),
},
{
find: /^stream-markdown-parser\//,
replacement: `${path.resolve(__dirname, '../markdown-parser/src')}/`,
},
],
},
build: {
copyPublicDir: false,
lib: {
entry: './src/index.ts',
formats: ['es'],
name: 'markstream-angular',
fileName: () => 'index.js',
},
rollupOptions: {
external: [
'@antv/infographic',
'@angular/common',
'@angular/core',
'@terrastruct/d2',
'katex',
'katex/contrib/mhchem',
'katex/dist/contrib/mhchem',
'mermaid',
'stream-monaco',
'stream-markdown-parser',
],
output: {
assetFileNames(assetInfo) {
if ((assetInfo.name || '').endsWith('.css'))
return 'index.css'
return '[name][extname]'
},
},
},
},
worker: {
format: 'es',
rollupOptions: {
external: [
'katex',
'katex/contrib/mhchem',
'katex/dist/contrib/mhchem',
'mermaid',
],
output: {
entryFileNames: 'workers/[name].js',
chunkFileNames: 'workers/[name].js',
assetFileNames: 'workers/[name][extname]',
},
},
},
css: {
postcss: path.resolve(__dirname, './postcss.config.cjs'),
},
}))