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:
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<meta
|
||||
name="description"
|
||||
content="markstream-angular playground for streaming Markdown, Mermaid, KaTeX, and cross-framework rendering checks."
|
||||
>
|
||||
<title>markstream-angular Playground | Angular demo</title>
|
||||
</head>
|
||||
<body>
|
||||
<playground-angular-root></playground-angular-root>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "playground-angular",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev --host 127.0.0.1 --port 4175",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 127.0.0.1 --port 4175"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/common": "^20.3.18",
|
||||
"@angular/compiler": "^20.3.18",
|
||||
"@angular/core": "^20.3.18",
|
||||
"@angular/platform-browser": "^20.3.18",
|
||||
"@antv/infographic": "^0.2.16",
|
||||
"@terrastruct/d2": "^0.1.33",
|
||||
"katex": "^0.16.44",
|
||||
"markstream-angular": "workspace:*",
|
||||
"mermaid": "^11.13.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"stream-monaco": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^18.19.130",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-monaco-editor-esm": "^2.0.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<style>
|
||||
path { fill: #222; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path { fill: #ffffff; }
|
||||
}
|
||||
</style>
|
||||
<path d="M27.562 26L17.17 8.928l2.366-3.888L17.828 4L16 7.005L14.17 4l-1.708 1.04l2.366 3.888L4.438 26H2v2h28v-2zM16 10.85L25.22 26H17v-8h-2v8H6.78z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 347 B |
@@ -0,0 +1 @@
|
||||
declare module 'markstream-vue'
|
||||
@@ -0,0 +1,266 @@
|
||||
import type { OnDestroy, OnInit } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core'
|
||||
import { MarkstreamAngularComponent } from 'markstream-angular'
|
||||
import { streamContent } from '../../playground/src/const/markdown'
|
||||
import { TestPageComponent } from './test-page.component'
|
||||
import { TestSandboxComponent } from './test-sandbox.component'
|
||||
import { ThinkingNodeComponent } from './thinking-node.component'
|
||||
|
||||
const markdownSource = typeof streamContent === 'string' ? streamContent : String(streamContent || '')
|
||||
const THINKING_TAGS = ['thinking'] as const
|
||||
|
||||
function normalizePath(pathname: string | null | undefined) {
|
||||
const normalized = (pathname || '/').replace(/\/+$/, '')
|
||||
return normalized || '/'
|
||||
}
|
||||
|
||||
function clampDelay(value: number) {
|
||||
if (!Number.isFinite(value))
|
||||
return 16
|
||||
return Math.min(200, Math.max(4, Math.round(value)))
|
||||
}
|
||||
|
||||
function clampChunk(value: number) {
|
||||
if (!Number.isFinite(value))
|
||||
return 1
|
||||
return Math.min(16, Math.max(1, Math.floor(value)))
|
||||
}
|
||||
|
||||
function readNumericInput(event: Event, fallback: number) {
|
||||
const value = Number((event.target as HTMLInputElement | null)?.value ?? fallback)
|
||||
return Number.isFinite(value) ? value : fallback
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'playground-angular-root',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MarkstreamAngularComponent, TestPageComponent, TestSandboxComponent],
|
||||
template: `
|
||||
<app-angular-test-sandbox *ngIf="isSandboxPage(); else nonSandbox" />
|
||||
|
||||
<ng-template #nonSandbox>
|
||||
<app-angular-test-page
|
||||
*ngIf="isTestPage(); else home"
|
||||
(navigateHome)="goHome()"
|
||||
/>
|
||||
|
||||
<ng-template #home>
|
||||
<div class="page">
|
||||
<header class="header">
|
||||
<div class="header-main">
|
||||
<div class="title">markstream-angular playground</div>
|
||||
<button type="button" class="btn" (click)="goToTest()">
|
||||
Open /test
|
||||
</button>
|
||||
</div>
|
||||
<div class="sub">
|
||||
Angular playground with streaming demo, standalone /test regression lab, and isolated /test-sandbox.
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<section class="panel controls">
|
||||
<h2>Stream controls</h2>
|
||||
|
||||
<div class="field">
|
||||
<label for="delay">Delay (ms)</label>
|
||||
<input
|
||||
id="delay"
|
||||
type="number"
|
||||
min="4"
|
||||
max="200"
|
||||
[value]="delay()"
|
||||
(input)="updateDelay($event)"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="chunk">Chunk size</label>
|
||||
<input
|
||||
id="chunk"
|
||||
type="number"
|
||||
min="1"
|
||||
max="16"
|
||||
[value]="chunkSize()"
|
||||
(input)="updateChunkSize($event)"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="btn" (click)="toggleStream()">
|
||||
{{ running() ? 'Pause' : 'Resume' }}
|
||||
</button>
|
||||
<button type="button" class="btn" (click)="resetStream()">
|
||||
Reset
|
||||
</button>
|
||||
<button type="button" class="btn ghost" (click)="fillAll()">
|
||||
Render all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="status">
|
||||
<div class="progress">
|
||||
<div class="bar" [style.width.%]="progress()"></div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
{{ content().length }} / {{ totalLength }} ({{ progress() }}%)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="note">
|
||||
首页保留主 demo;更完整的跨框架回归入口在 <code>/test</code>,隔离版本对照在 <code>/test-sandbox</code>。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="panel preview">
|
||||
<markstream-angular
|
||||
[content]="content()"
|
||||
[final]="isDone()"
|
||||
[renderCodeBlocksAsPre]="false"
|
||||
[codeBlockStream]="true"
|
||||
[customHtmlTags]="thinkingTags"
|
||||
[customComponents]="customComponents"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AppComponent implements OnInit, OnDestroy {
|
||||
readonly thinkingTags = THINKING_TAGS
|
||||
readonly customComponents = {
|
||||
thinking: ThinkingNodeComponent,
|
||||
}
|
||||
|
||||
readonly totalLength = markdownSource.length
|
||||
readonly currentPath = signal(typeof window === 'undefined' ? '/' : normalizePath(window.location.pathname))
|
||||
readonly content = signal('')
|
||||
readonly delay = signal(16)
|
||||
readonly chunkSize = signal(2)
|
||||
readonly running = signal(true)
|
||||
readonly normalizedChunkSize = computed(() => clampChunk(this.chunkSize()))
|
||||
readonly progress = computed(() => {
|
||||
if (!this.totalLength)
|
||||
return 0
|
||||
return Math.min(100, Math.round((this.content().length / this.totalLength) * 100))
|
||||
})
|
||||
|
||||
readonly isDone = computed(() => this.content().length >= this.totalLength)
|
||||
readonly isTestPage = computed(() => this.currentPath() === '/test')
|
||||
readonly isSandboxPage = computed(() => this.currentPath() === '/test-sandbox')
|
||||
|
||||
private timer: number | null = null
|
||||
|
||||
ngOnInit() {
|
||||
if (typeof window !== 'undefined')
|
||||
window.addEventListener('popstate', this.handlePopState)
|
||||
if (!this.isTestPage())
|
||||
this.startStream()
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
if (typeof window !== 'undefined')
|
||||
window.removeEventListener('popstate', this.handlePopState)
|
||||
this.stopStream()
|
||||
}
|
||||
|
||||
updateDelay(event: Event) {
|
||||
this.delay.set(clampDelay(readNumericInput(event, this.delay())))
|
||||
if (this.running() && !this.isTestPage())
|
||||
this.restartStream()
|
||||
}
|
||||
|
||||
updateChunkSize(event: Event) {
|
||||
this.chunkSize.set(clampChunk(readNumericInput(event, this.chunkSize())))
|
||||
}
|
||||
|
||||
goToTest() {
|
||||
this.navigate('/test')
|
||||
}
|
||||
|
||||
goHome() {
|
||||
this.navigate('/')
|
||||
}
|
||||
|
||||
toggleStream() {
|
||||
if (this.running())
|
||||
this.stopStream()
|
||||
else
|
||||
this.startStream()
|
||||
}
|
||||
|
||||
resetStream() {
|
||||
this.content.set('')
|
||||
this.startStream()
|
||||
}
|
||||
|
||||
fillAll() {
|
||||
this.content.set(markdownSource)
|
||||
this.stopStream()
|
||||
}
|
||||
|
||||
private readonly handlePopState = () => {
|
||||
this.syncPath()
|
||||
if (this.isTestPage() || this.isSandboxPage())
|
||||
this.stopStream()
|
||||
else if (!this.content())
|
||||
this.startStream()
|
||||
}
|
||||
|
||||
private syncPath() {
|
||||
if (typeof window === 'undefined')
|
||||
return
|
||||
this.currentPath.set(normalizePath(window.location.pathname))
|
||||
}
|
||||
|
||||
private navigate(pathname: string) {
|
||||
const nextPath = normalizePath(pathname)
|
||||
if (typeof window !== 'undefined' && nextPath !== normalizePath(window.location.pathname))
|
||||
window.history.pushState({}, '', nextPath)
|
||||
|
||||
this.currentPath.set(nextPath)
|
||||
|
||||
if (this.isTestPage() || this.isSandboxPage())
|
||||
this.stopStream()
|
||||
else if (!this.content() || this.content().length >= markdownSource.length)
|
||||
this.resetStream()
|
||||
}
|
||||
|
||||
private tick() {
|
||||
if (this.isDone()) {
|
||||
this.stopStream()
|
||||
return
|
||||
}
|
||||
|
||||
this.content.update((current) => {
|
||||
const nextChunk = markdownSource.slice(current.length, current.length + this.normalizedChunkSize())
|
||||
return current + nextChunk
|
||||
})
|
||||
}
|
||||
|
||||
private startStream() {
|
||||
this.stopStream()
|
||||
if (typeof window === 'undefined')
|
||||
return
|
||||
this.running.set(true)
|
||||
this.timer = window.setInterval(() => this.tick(), this.delay())
|
||||
}
|
||||
|
||||
private stopStream() {
|
||||
if (this.timer != null && typeof window !== 'undefined') {
|
||||
window.clearInterval(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
this.running.set(false)
|
||||
}
|
||||
|
||||
private restartStream() {
|
||||
if (!this.running())
|
||||
return
|
||||
this.startStream()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
background: #f7f7fb;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
padding: 1.5rem 2rem 2.5rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.header-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sub {
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
|
||||
.controls h2 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 0.9rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.field input {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.7rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: none;
|
||||
background: #111827;
|
||||
color: #fff;
|
||||
padding: 0.45rem 0.9rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn.ghost {
|
||||
background: #e5e7eb;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.progress {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #3b82f6, #6366f1);
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-top: 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.preview {
|
||||
min-height: 70vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.header-main {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
function resolveVendorAssetUrl(pathname: string) {
|
||||
if (typeof document !== 'undefined')
|
||||
return new URL(pathname.replace(/^\/+/, ''), document.baseURI).href
|
||||
|
||||
return pathname
|
||||
}
|
||||
|
||||
export async function bootstrapPlayground() {
|
||||
const [
|
||||
{ enableD2, setKaTeXWorker, setMermaidWorker },
|
||||
{ provideZonelessChangeDetection },
|
||||
{ bootstrapApplication },
|
||||
{ AppComponent },
|
||||
{ default: KatexWorker },
|
||||
{ default: MermaidWorker },
|
||||
] = await Promise.all([
|
||||
import('markstream-angular'),
|
||||
import('@angular/core'),
|
||||
import('@angular/platform-browser'),
|
||||
import('./app.component'),
|
||||
import('../../packages/markstream-angular/src/workers/katexRenderer.worker?worker&inline'),
|
||||
import('../../packages/markstream-angular/src/workers/mermaidParser.worker?worker&inline'),
|
||||
])
|
||||
|
||||
const d2VendorUrl = resolveVendorAssetUrl('/vendor/d2-browser.js')
|
||||
enableD2?.(() => import(/* @vite-ignore */ d2VendorUrl))
|
||||
setKaTeXWorker?.(new KatexWorker())
|
||||
setMermaidWorker?.(new MermaidWorker())
|
||||
|
||||
return bootstrapApplication(AppComponent, {
|
||||
providers: [
|
||||
provideZonelessChangeDetection(),
|
||||
],
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'katex/dist/katex.min.css'
|
||||
import './app.css'
|
||||
import './test-page.css'
|
||||
import '../../packages/markstream-angular/src/index.css'
|
||||
import '@angular/compiler'
|
||||
|
||||
async function main() {
|
||||
const { bootstrapPlayground } = await import('./bootstrap-app')
|
||||
return bootstrapPlayground()
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[playground-angular] bootstrap failed', error)
|
||||
})
|
||||
@@ -0,0 +1,332 @@
|
||||
import type { OnDestroy, OnInit } from '@angular/core'
|
||||
import type { TestLabFrameworkId, TestLabSampleId } from '../../playground-shared/testLabFixtures'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, computed, EventEmitter, Output, signal } from '@angular/core'
|
||||
import { MarkstreamAngularComponent } from 'markstream-angular'
|
||||
import { resolveMarkdownTextareaPaste } from '../../playground-shared/markdownPaste'
|
||||
import { TEST_LAB_FRAMEWORKS, TEST_LAB_SAMPLES } from '../../playground-shared/testLabFixtures'
|
||||
import { decodeMarkdownHash, resolveFrameworkTestHref } from '../../playground-shared/testPageState'
|
||||
import { ThinkingNodeComponent } from './thinking-node.component'
|
||||
|
||||
function clampInt(value: number, min: number, max: number, fallback: number) {
|
||||
const normalized = Number.isFinite(value) ? Math.round(value) : fallback
|
||||
return Math.min(max, Math.max(min, normalized))
|
||||
}
|
||||
|
||||
function readTextInput(event: Event, fallback = '') {
|
||||
return (event.target as HTMLInputElement | HTMLTextAreaElement | null)?.value ?? fallback
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-angular-test-lab',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MarkstreamAngularComponent],
|
||||
template: `
|
||||
<div class="test-lab">
|
||||
<div class="test-lab__shell">
|
||||
<section class="hero-panel">
|
||||
<div class="hero-copy">
|
||||
<span class="eyebrow">Angular Regression Lab</span>
|
||||
<h1>markstream-angular /test</h1>
|
||||
<p>
|
||||
现在已经把 parser、增强型 code block、KaTeX、Mermaid、Infographic 和 D2 拉进同一套回归驾驶舱。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="hero-metrics">
|
||||
<div class="metric-card">
|
||||
<span>字符数</span>
|
||||
<strong>{{ charCount() }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>行数</span>
|
||||
<strong>{{ lineCount() }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>预览进度</span>
|
||||
<strong>{{ progress() }}%</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="framework-switcher">
|
||||
<a
|
||||
*ngFor="let framework of frameworkCards"
|
||||
class="framework-chip"
|
||||
[class.framework-chip--current]="framework.id === currentFramework"
|
||||
[href]="frameworkHref(framework.id)"
|
||||
>
|
||||
<span class="framework-chip__label">{{ framework.label }}</span>
|
||||
<span class="framework-chip__note">{{ framework.note }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="lab-layout">
|
||||
<aside class="panel-card sidebar-card">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>样例切换</h2>
|
||||
<p>选一段输入,直接开始回归。</p>
|
||||
</div>
|
||||
<span class="mini-pill">{{ activeSample().title }}</span>
|
||||
</div>
|
||||
|
||||
<div class="sample-list">
|
||||
<button
|
||||
*ngFor="let sample of sampleCards"
|
||||
type="button"
|
||||
class="sample-card"
|
||||
[class.sample-card--active]="sample.id === selectedSampleId()"
|
||||
(click)="applySample(sample.id)"
|
||||
>
|
||||
<strong>{{ sample.title }}</strong>
|
||||
<span>{{ sample.summary }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-head panel-head--spaced">
|
||||
<div>
|
||||
<h2>流式控制</h2>
|
||||
<p>看 Angular baseline 在 streaming 下是否稳定。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-grid">
|
||||
<label class="input-card">
|
||||
<span>每次追加字符</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="80"
|
||||
[value]="streamSpeed()"
|
||||
(input)="updateStreamSpeed($event)"
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="input-card">
|
||||
<span>更新时间间隔 (ms)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="8"
|
||||
max="300"
|
||||
[value]="streamInterval()"
|
||||
(input)="updateStreamInterval($event)"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="button-grid">
|
||||
<button type="button" class="btn btn--primary" (click)="toggleStreamRender()">
|
||||
{{ isStreaming() ? '停止流式渲染' : '开始流式渲染' }}
|
||||
</button>
|
||||
<button type="button" class="btn" (click)="resetEditor()">
|
||||
重置样例
|
||||
</button>
|
||||
<button type="button" class="btn" (click)="clearEditor()">
|
||||
清空输入
|
||||
</button>
|
||||
<button type="button" class="btn" (click)="navigateHome.emit()">
|
||||
返回主 demo
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="workspace-grid">
|
||||
<article class="workspace-card">
|
||||
<header class="workspace-card__head">
|
||||
<div>
|
||||
<h2>Markdown 输入</h2>
|
||||
<p>把复现内容直接贴进来。</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<textarea
|
||||
class="editor-textarea"
|
||||
spellcheck="false"
|
||||
placeholder="在这里粘贴 markdown..."
|
||||
[value]="input()"
|
||||
(input)="updateInput($event)"
|
||||
(paste)="handleEditorPaste($event)"
|
||||
></textarea>
|
||||
|
||||
<footer class="workspace-card__foot">
|
||||
<span>{{ charCount() }} chars</span>
|
||||
<span>{{ lineCount() }} lines</span>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
<article class="workspace-card">
|
||||
<header class="workspace-card__head">
|
||||
<div>
|
||||
<h2>实时预览</h2>
|
||||
<p>{{ isStreaming() ? 'Streaming 中' : '已显示完整输入' }}</p>
|
||||
</div>
|
||||
<span class="mini-pill">{{ progress() }}%</span>
|
||||
</header>
|
||||
|
||||
<div class="preview-surface">
|
||||
<markstream-angular
|
||||
[content]="previewContent()"
|
||||
[final]="!isStreaming()"
|
||||
[codeBlockStream]="true"
|
||||
[customHtmlTags]="thinkingTags"
|
||||
[customComponents]="customComponents"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<footer class="workspace-card__foot">
|
||||
<span>{{ previewContent().length }} / {{ input().length || 0 }}</span>
|
||||
<span>Angular renderer</span>
|
||||
</footer>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TestLabComponent implements OnInit, OnDestroy {
|
||||
@Output() readonly navigateHome = new EventEmitter<void>()
|
||||
|
||||
readonly currentFramework: TestLabFrameworkId = 'angular'
|
||||
readonly thinkingTags = ['thinking'] as const
|
||||
readonly customComponents = {
|
||||
thinking: ThinkingNodeComponent,
|
||||
}
|
||||
|
||||
readonly frameworkCards = TEST_LAB_FRAMEWORKS
|
||||
readonly sampleCards = TEST_LAB_SAMPLES
|
||||
readonly selectedSampleId = signal<TestLabSampleId>('baseline')
|
||||
readonly input = signal(TEST_LAB_SAMPLES[0].content)
|
||||
readonly streamContent = signal('')
|
||||
readonly isStreaming = signal(false)
|
||||
readonly streamSpeed = signal(4)
|
||||
readonly streamInterval = signal(24)
|
||||
readonly activeSample = computed(() => this.sampleCards.find(sample => sample.id === this.selectedSampleId()) ?? this.sampleCards[0])
|
||||
readonly previewContent = computed(() => this.isStreaming() ? this.streamContent() : this.input())
|
||||
readonly progress = computed(() => {
|
||||
if (!this.input().length)
|
||||
return 0
|
||||
return Math.min(100, Math.round((this.previewContent().length / this.input().length) * 100))
|
||||
})
|
||||
|
||||
readonly charCount = computed(() => this.input().length)
|
||||
readonly lineCount = computed(() => this.input() ? this.input().split('\n').length : 0)
|
||||
|
||||
private timer: number | null = null
|
||||
|
||||
ngOnInit() {
|
||||
if (typeof window === 'undefined')
|
||||
return
|
||||
const restored = decodeMarkdownHash(window.location.hash || '')
|
||||
if (restored)
|
||||
this.input.set(restored)
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.stopStreamRender()
|
||||
}
|
||||
|
||||
applySample(id: TestLabSampleId) {
|
||||
const sample = this.sampleCards.find(item => item.id === id)
|
||||
if (!sample)
|
||||
return
|
||||
this.stopStreamRender()
|
||||
this.selectedSampleId.set(sample.id)
|
||||
this.input.set(sample.content)
|
||||
}
|
||||
|
||||
updateInput(event: Event) {
|
||||
this.stopStreamRender()
|
||||
this.input.set(readTextInput(event, this.input()))
|
||||
}
|
||||
|
||||
handleEditorPaste(event: ClipboardEvent) {
|
||||
const textarea = event.currentTarget
|
||||
if (!(textarea instanceof HTMLTextAreaElement))
|
||||
return
|
||||
|
||||
const pasted = event.clipboardData?.getData('text/plain') ?? ''
|
||||
const next = resolveMarkdownTextareaPaste(textarea, pasted)
|
||||
if (!next)
|
||||
return
|
||||
|
||||
event.preventDefault()
|
||||
textarea.value = next.nextValue
|
||||
textarea.selectionStart = next.selectionStart
|
||||
textarea.selectionEnd = next.selectionEnd
|
||||
this.stopStreamRender()
|
||||
this.input.set(next.nextValue)
|
||||
}
|
||||
|
||||
updateStreamSpeed(event: Event) {
|
||||
this.streamSpeed.set(clampInt(Number(readTextInput(event, String(this.streamSpeed()))), 1, 80, 4))
|
||||
}
|
||||
|
||||
updateStreamInterval(event: Event) {
|
||||
this.streamInterval.set(clampInt(Number(readTextInput(event, String(this.streamInterval()))), 8, 300, 24))
|
||||
}
|
||||
|
||||
toggleStreamRender() {
|
||||
if (this.isStreaming()) {
|
||||
this.stopStreamRender()
|
||||
return
|
||||
}
|
||||
this.startStreamRender()
|
||||
}
|
||||
|
||||
resetEditor() {
|
||||
this.applySample(this.selectedSampleId())
|
||||
}
|
||||
|
||||
clearEditor() {
|
||||
this.stopStreamRender()
|
||||
this.input.set('')
|
||||
}
|
||||
|
||||
frameworkHref(id: TestLabFrameworkId) {
|
||||
const framework = this.frameworkCards.find(item => item.id === id)
|
||||
if (!framework)
|
||||
return '/test'
|
||||
return resolveFrameworkTestHref(
|
||||
framework,
|
||||
this.currentFramework,
|
||||
this.input(),
|
||||
typeof window !== 'undefined'
|
||||
? { hostname: window.location.hostname, protocol: window.location.protocol }
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
|
||||
private startStreamRender() {
|
||||
this.streamSpeed.set(clampInt(this.streamSpeed(), 1, 80, 4))
|
||||
this.streamInterval.set(clampInt(this.streamInterval(), 8, 300, 24))
|
||||
this.streamContent.set('')
|
||||
this.isStreaming.set(true)
|
||||
this.scheduleNextChunk()
|
||||
}
|
||||
|
||||
private scheduleNextChunk() {
|
||||
if (!this.isStreaming() || typeof window === 'undefined')
|
||||
return
|
||||
|
||||
const nextLength = Math.min(this.streamContent().length + this.streamSpeed(), this.input().length)
|
||||
this.streamContent.set(this.input().slice(0, nextLength))
|
||||
|
||||
if (nextLength >= this.input().length) {
|
||||
this.stopStreamRender()
|
||||
return
|
||||
}
|
||||
|
||||
this.timer = window.setTimeout(() => this.scheduleNextChunk(), this.streamInterval())
|
||||
}
|
||||
|
||||
private stopStreamRender() {
|
||||
if (this.timer != null && typeof window !== 'undefined') {
|
||||
window.clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
this.isStreaming.set(false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
.test-lab {
|
||||
min-height: 100vh;
|
||||
padding: 24px 16px 36px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(56, 189, 248, 0.12), transparent 30%),
|
||||
radial-gradient(circle at 85% 15%, rgba(59, 130, 246, 0.12), transparent 26%),
|
||||
linear-gradient(180deg, #f8fbff 0%, #f2f6fb 100%);
|
||||
color: #10203a;
|
||||
}
|
||||
|
||||
.test-lab__shell {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.hero-panel,
|
||||
.panel-card,
|
||||
.workspace-card {
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
box-shadow: 0 24px 64px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
padding: 7px 11px;
|
||||
border-radius: 999px;
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
color: #1d4ed8;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero-copy h1 {
|
||||
margin: 12px 0 10px;
|
||||
font-size: clamp(2rem, 4vw, 3rem);
|
||||
line-height: 0.96;
|
||||
}
|
||||
|
||||
.hero-copy p,
|
||||
.panel-head p,
|
||||
.workspace-card__head p,
|
||||
.workspace-card__foot,
|
||||
.sample-card span {
|
||||
margin: 0;
|
||||
color: #5b6f88;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metric-card,
|
||||
.sample-card,
|
||||
.input-card,
|
||||
.framework-chip {
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.07);
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.metric-card span {
|
||||
display: block;
|
||||
color: #5b6f88;
|
||||
font-size: 0.82rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.metric-card strong {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.framework-switcher {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.framework-chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 14px 16px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.framework-chip--current {
|
||||
border-color: rgba(37, 99, 235, 0.24);
|
||||
background: linear-gradient(135deg, rgba(37, 99, 235, 0.12), rgba(255, 255, 255, 0.94));
|
||||
}
|
||||
|
||||
.framework-chip__label {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.framework-chip__note {
|
||||
color: #5b6f88;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.lab-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 330px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.panel-card {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.panel-head,
|
||||
.workspace-card__head,
|
||||
.workspace-card__foot {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-head--spaced {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.panel-head h2,
|
||||
.workspace-card__head h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.mini-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 7px 11px;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.05);
|
||||
color: #5b6f88;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sample-list,
|
||||
.button-grid,
|
||||
.workspace-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sample-list {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.sample-card {
|
||||
width: 100%;
|
||||
padding: 13px 15px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sample-card strong {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.sample-card--active {
|
||||
border-color: rgba(37, 99, 235, 0.24);
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
}
|
||||
|
||||
.control-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.input-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.input-card span {
|
||||
color: #5b6f88;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.input-card input {
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(15, 23, 42, 0.05);
|
||||
color: #10203a;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.button-grid {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.testlab-btn {
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(15, 23, 42, 0.06);
|
||||
color: #10203a;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.testlab-btn--primary {
|
||||
background: linear-gradient(135deg, #1d4ed8, #2563eb);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.workspace-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.workspace-card {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
min-height: 720px;
|
||||
}
|
||||
|
||||
.workspace-card__head,
|
||||
.workspace-card__foot {
|
||||
padding: 18px;
|
||||
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.workspace-card__foot {
|
||||
border-top: 1px solid rgba(15, 23, 42, 0.06);
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.editor-textarea {
|
||||
width: 100%;
|
||||
min-height: 520px;
|
||||
border: 0;
|
||||
resize: none;
|
||||
padding: 20px 18px;
|
||||
background: linear-gradient(180deg, rgba(248, 250, 252, 0.92), rgba(255, 255, 255, 0.98));
|
||||
color: #0f172a;
|
||||
font:
|
||||
500 0.94rem/1.7 "IBM Plex Mono", "SFMono-Regular", Consolas, monospace;
|
||||
}
|
||||
|
||||
.editor-textarea:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.preview-surface {
|
||||
min-height: 520px;
|
||||
padding: 20px 18px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.lab-layout,
|
||||
.workspace-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.hero-metrics,
|
||||
.framework-switcher {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.workspace-card {
|
||||
min-height: 620px;
|
||||
}
|
||||
|
||||
.editor-textarea,
|
||||
.preview-surface {
|
||||
min-height: 400px;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,417 @@
|
||||
import type { AfterViewInit, ElementRef, OnDestroy } from '@angular/core'
|
||||
import type { SandboxSelection } from '../../playground-shared/versionSandbox'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, computed, signal, ViewChild } from '@angular/core'
|
||||
import { decodeMarkdownHash } from '../../playground-shared/testPageState'
|
||||
import { parseSandboxSelection } from '../../playground-shared/versionSandbox'
|
||||
import {
|
||||
TEST_SANDBOX_KATEX_VERSION,
|
||||
TEST_SANDBOX_MERMAID_VERSION,
|
||||
testSandboxFrameworks,
|
||||
} from './testSandboxConfig'
|
||||
|
||||
type SandboxStatus = 'idle' | 'loading' | 'ready' | 'error'
|
||||
|
||||
function setHeadLink(id: string, href: string | null) {
|
||||
const existing = document.head.querySelector<HTMLLinkElement>(`link[data-sandbox-style="${id}"]`)
|
||||
if (!href) {
|
||||
existing?.remove()
|
||||
return
|
||||
}
|
||||
if (existing) {
|
||||
if (existing.href !== href)
|
||||
existing.href = href
|
||||
return
|
||||
}
|
||||
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = href
|
||||
link.dataset.sandboxStyle = id
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
|
||||
function encodeNpmVersion(version: string) {
|
||||
return encodeURIComponent(version.trim() || 'latest')
|
||||
}
|
||||
|
||||
function createEsmPackageUrl(packageName: string, version: string, deps: string[] = []) {
|
||||
const query = new URLSearchParams()
|
||||
if (deps.length)
|
||||
query.set('deps', deps.join(','))
|
||||
const suffix = query.toString()
|
||||
return `https://esm.sh/${packageName}@${encodeNpmVersion(version)}${suffix ? `?${suffix}` : ''}`
|
||||
}
|
||||
|
||||
function createEsmPackageSubpathUrl(
|
||||
packageName: string,
|
||||
version: string,
|
||||
subpath: string,
|
||||
deps: string[] = [],
|
||||
) {
|
||||
const base = createEsmPackageUrl(packageName, version, deps)
|
||||
const [prefix, query] = base.split('?')
|
||||
return `${prefix}/${subpath}${query ? `?${query}` : ''}`
|
||||
}
|
||||
|
||||
function createJsDelivrCssUrl(packageName: string, version: string) {
|
||||
return `https://cdn.jsdelivr.net/npm/${packageName}@${encodeNpmVersion(version)}/dist/index.css`
|
||||
}
|
||||
|
||||
function applySandboxStyles(current: SandboxSelection) {
|
||||
setHeadLink(
|
||||
'renderer',
|
||||
current.source === 'npm'
|
||||
? createJsDelivrCssUrl(current.framework.packageName, current.version)
|
||||
: null,
|
||||
)
|
||||
setHeadLink(
|
||||
'katex',
|
||||
`https://cdn.jsdelivr.net/npm/katex@${TEST_SANDBOX_KATEX_VERSION}/dist/katex.min.css`,
|
||||
)
|
||||
}
|
||||
|
||||
async function importRemote<T = any>(url: string): Promise<T> {
|
||||
return await import(/* @vite-ignore */ url) as T
|
||||
}
|
||||
|
||||
function setupCommonRendererRuntime(rendererModule: Record<string, any>) {
|
||||
rendererModule.enableKatex?.()
|
||||
rendererModule.enableMermaid?.()
|
||||
|
||||
const katexHandle = rendererModule.createKaTeXWorkerFromCDN?.({
|
||||
mode: 'classic',
|
||||
katexUrl: `https://cdn.jsdelivr.net/npm/katex@${TEST_SANDBOX_KATEX_VERSION}/dist/katex.min.js`,
|
||||
mhchemUrl: `https://cdn.jsdelivr.net/npm/katex@${TEST_SANDBOX_KATEX_VERSION}/dist/contrib/mhchem.min.js`,
|
||||
})
|
||||
if (katexHandle?.worker)
|
||||
rendererModule.setKaTeXWorker?.(katexHandle.worker)
|
||||
|
||||
const mermaidHandle = rendererModule.createMermaidWorkerFromCDN?.({
|
||||
mode: 'module',
|
||||
workerOptions: { type: 'module' },
|
||||
mermaidUrl: `https://cdn.jsdelivr.net/npm/mermaid@${TEST_SANDBOX_MERMAID_VERSION}/dist/mermaid.esm.min.mjs`,
|
||||
})
|
||||
if (mermaidHandle?.worker)
|
||||
rendererModule.setMermaidWorker?.(mermaidHandle.worker)
|
||||
|
||||
return () => {
|
||||
rendererModule.clearKaTeXWorker?.()
|
||||
rendererModule.clearMermaidWorker?.()
|
||||
katexHandle?.dispose?.()
|
||||
mermaidHandle?.dispose?.()
|
||||
}
|
||||
}
|
||||
|
||||
async function mountVue3Sandbox(current: SandboxSelection, content: string, mountPoint: HTMLElement) {
|
||||
const rendererModule = current.source === 'workspace'
|
||||
? await import('markstream-vue')
|
||||
: await importRemote(createEsmPackageUrl(
|
||||
current.framework.packageName,
|
||||
current.version,
|
||||
[`vue@${current.framework.runtimeVersion}`],
|
||||
))
|
||||
const vueModule = current.source === 'workspace'
|
||||
? null
|
||||
: await importRemote<any>(createEsmPackageUrl('vue', current.framework.runtimeVersion))
|
||||
|
||||
const teardownRuntime = setupCommonRendererRuntime(rendererModule)
|
||||
const MarkdownRender = rendererModule.default || rendererModule.MarkdownRender
|
||||
const createApp = current.source === 'workspace'
|
||||
? vueModule?.createApp ?? (await import('vue')).createApp
|
||||
: (vueModule?.createApp || vueModule?.default?.createApp)
|
||||
const h = current.source === 'workspace'
|
||||
? vueModule?.h ?? (await import('vue')).h
|
||||
: (vueModule?.h || vueModule?.default?.h)
|
||||
|
||||
if (!MarkdownRender || !createApp || !h)
|
||||
throw new Error('无法找到 Vue 3 渲染器入口。')
|
||||
|
||||
const host = document.createElement('div')
|
||||
host.className = 'sandbox-canvas__host'
|
||||
mountPoint.replaceChildren(host)
|
||||
|
||||
const app = createApp({
|
||||
render() {
|
||||
return h(MarkdownRender, {
|
||||
content,
|
||||
batchRendering: false,
|
||||
typewriter: false,
|
||||
viewportPriority: false,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
app.mount(host)
|
||||
|
||||
return () => {
|
||||
app.unmount()
|
||||
teardownRuntime()
|
||||
}
|
||||
}
|
||||
|
||||
async function mountVue2Sandbox(current: SandboxSelection, content: string, mountPoint: HTMLElement) {
|
||||
const [vueModule, rendererModule] = await Promise.all([
|
||||
importRemote<any>(createEsmPackageUrl('vue', current.framework.runtimeVersion)),
|
||||
importRemote<any>(createEsmPackageUrl(
|
||||
current.framework.packageName,
|
||||
current.version,
|
||||
[
|
||||
`vue@${current.framework.runtimeVersion}`,
|
||||
'@vue/composition-api@1.7.2',
|
||||
],
|
||||
)),
|
||||
])
|
||||
|
||||
const Vue2 = vueModule.default || vueModule
|
||||
const MarkdownRender = rendererModule.default || rendererModule.MarkdownRender
|
||||
const teardownRuntime = setupCommonRendererRuntime(rendererModule)
|
||||
|
||||
if (!Vue2 || !MarkdownRender)
|
||||
throw new Error('无法找到 Vue 2 渲染器入口。')
|
||||
|
||||
const host = document.createElement('div')
|
||||
host.className = 'sandbox-canvas__host'
|
||||
mountPoint.replaceChildren(host)
|
||||
|
||||
const app = new Vue2({
|
||||
render(createElement: (component: any, data?: any) => any) {
|
||||
return createElement(MarkdownRender, {
|
||||
props: {
|
||||
content,
|
||||
batchRendering: false,
|
||||
typewriter: false,
|
||||
viewportPriority: false,
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
app.$mount(host)
|
||||
|
||||
return () => {
|
||||
app.$destroy()
|
||||
teardownRuntime()
|
||||
}
|
||||
}
|
||||
|
||||
async function mountReactSandbox(current: SandboxSelection, content: string, mountPoint: HTMLElement) {
|
||||
const runtimeVersion = current.framework.runtimeVersion
|
||||
const [reactModule, reactDomModule, rendererModule] = await Promise.all([
|
||||
importRemote<any>(createEsmPackageUrl('react', runtimeVersion)),
|
||||
importRemote<any>(createEsmPackageSubpathUrl(
|
||||
'react-dom',
|
||||
runtimeVersion,
|
||||
'client',
|
||||
[`react@${runtimeVersion}`],
|
||||
)),
|
||||
importRemote<any>(createEsmPackageUrl(
|
||||
current.framework.packageName,
|
||||
current.version,
|
||||
[`react@${runtimeVersion}`, `react-dom@${runtimeVersion}`],
|
||||
)),
|
||||
])
|
||||
|
||||
const React = reactModule.default || reactModule
|
||||
const ReactDOMClient = reactDomModule.default || reactDomModule
|
||||
const MarkdownRender = rendererModule.default || rendererModule.NodeRenderer
|
||||
|
||||
if (!React?.createElement || !ReactDOMClient?.createRoot || !MarkdownRender)
|
||||
throw new Error('无法找到 React 渲染器入口。')
|
||||
|
||||
const host = document.createElement('div')
|
||||
host.className = 'sandbox-canvas__host'
|
||||
mountPoint.replaceChildren(host)
|
||||
|
||||
const root = ReactDOMClient.createRoot(host)
|
||||
root.render(
|
||||
React.createElement(MarkdownRender, {
|
||||
content,
|
||||
batchRendering: false,
|
||||
typewriter: false,
|
||||
viewportPriority: false,
|
||||
}),
|
||||
)
|
||||
|
||||
return () => {
|
||||
root.unmount()
|
||||
}
|
||||
}
|
||||
|
||||
async function mountAngularSandbox(current: SandboxSelection, content: string, mountPoint: HTMLElement) {
|
||||
const runtimeVersion = current.framework.runtimeVersion
|
||||
const rendererModule = current.source === 'workspace'
|
||||
? await import('markstream-angular')
|
||||
: await importRemote<any>(createEsmPackageUrl(
|
||||
current.framework.packageName,
|
||||
current.version,
|
||||
[`@angular/common@${runtimeVersion}`, `@angular/core@${runtimeVersion}`],
|
||||
))
|
||||
|
||||
const renderMarkdownToHtml = rendererModule.renderMarkdownToHtml
|
||||
const enhanceRenderedHtml = rendererModule.enhanceRenderedHtml
|
||||
if (typeof renderMarkdownToHtml !== 'function')
|
||||
throw new Error('无法找到 Angular 渲染器入口。')
|
||||
|
||||
const host = document.createElement('div')
|
||||
host.className = 'sandbox-canvas__host'
|
||||
|
||||
const shell = document.createElement('div')
|
||||
shell.className = 'markstream-angular markdown-renderer'
|
||||
shell.innerHTML = renderMarkdownToHtml({
|
||||
content,
|
||||
final: true,
|
||||
allowHtml: true,
|
||||
})
|
||||
const enhancementHandle = await enhanceRenderedHtml?.(shell, { final: true })
|
||||
|
||||
host.replaceChildren(shell)
|
||||
mountPoint.replaceChildren(host)
|
||||
|
||||
return () => {
|
||||
enhancementHandle?.dispose?.()
|
||||
mountPoint.replaceChildren()
|
||||
}
|
||||
}
|
||||
|
||||
async function mountSandbox(current: SandboxSelection, content: string, mountPoint: HTMLElement) {
|
||||
if (current.frameworkId === 'vue3')
|
||||
return await mountVue3Sandbox(current, content, mountPoint)
|
||||
if (current.frameworkId === 'vue2')
|
||||
return await mountVue2Sandbox(current, content, mountPoint)
|
||||
if (current.frameworkId === 'angular')
|
||||
return await mountAngularSandbox(current, content, mountPoint)
|
||||
return await mountReactSandbox(current, content, mountPoint)
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-angular-test-sandbox',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="sandbox-page">
|
||||
<header class="sandbox-hero">
|
||||
<div>
|
||||
<span class="sandbox-eyebrow">Version sandbox</span>
|
||||
<h1>Isolated Renderer</h1>
|
||||
<p>当前 iframe 正在独立加载对应 framework 与包版本,用于和左侧 workspace 预览做对照。</p>
|
||||
</div>
|
||||
|
||||
<div class="sandbox-summary">
|
||||
<div class="sandbox-summary__item">
|
||||
<span>选择</span>
|
||||
<strong>{{ selectionSummary() }}</strong>
|
||||
</div>
|
||||
<div class="sandbox-summary__item">
|
||||
<span>状态</span>
|
||||
<strong>{{ statusLabel() }}</strong>
|
||||
</div>
|
||||
<div class="sandbox-summary__item">
|
||||
<span>字符数</span>
|
||||
<strong>{{ markdown().length }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div *ngIf="status() === 'error'" class="sandbox-banner sandbox-banner--error">
|
||||
<strong>沙箱加载失败。</strong>
|
||||
<span>{{ errorMessage() || '请检查版本号是否存在,或稍后重试。' }}</span>
|
||||
</div>
|
||||
<div *ngIf="status() !== 'error' && selection().source === 'workspace' && !selection().framework.supportsWorkspace" class="sandbox-banner sandbox-banner--warning">
|
||||
当前框架不支持在这个 iframe 中直接跑 workspace 源码,已自动回退到 npm 包模式。
|
||||
</div>
|
||||
<div *ngIf="status() === 'loading'" class="sandbox-banner sandbox-banner--info">
|
||||
正在加载隔离渲染器与对应版本资源...
|
||||
</div>
|
||||
|
||||
<section class="sandbox-canvas">
|
||||
<div #canvasRef class="sandbox-canvas__mount"></div>
|
||||
</section>
|
||||
</div>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TestSandboxComponent implements AfterViewInit, OnDestroy {
|
||||
@ViewChild('canvasRef', { static: true }) private canvasRef?: ElementRef<HTMLElement>
|
||||
|
||||
readonly status = signal<SandboxStatus>('idle')
|
||||
readonly selection = signal<SandboxSelection>(parseSandboxSelection(
|
||||
typeof window === 'undefined' ? '' : window.location.search,
|
||||
testSandboxFrameworks,
|
||||
))
|
||||
|
||||
readonly markdown = signal(typeof window === 'undefined' ? '' : (decodeMarkdownHash(window.location.hash || '') ?? ''))
|
||||
readonly errorMessage = signal('')
|
||||
readonly statusLabel = computed(() => {
|
||||
if (this.status() === 'loading')
|
||||
return '加载中'
|
||||
if (this.status() === 'error')
|
||||
return '加载失败'
|
||||
if (this.status() === 'ready')
|
||||
return 'Ready'
|
||||
return '待命'
|
||||
})
|
||||
|
||||
readonly selectionSummary = computed(() => {
|
||||
const current = this.selection()
|
||||
return `${current.framework.label} / ${current.source === 'workspace' ? 'workspace' : 'npm'} / ${current.version}`
|
||||
})
|
||||
|
||||
private cleanupMountedRuntime: (() => void) | null = null
|
||||
private renderToken = 0
|
||||
|
||||
ngAfterViewInit() {
|
||||
void this.renderSandbox()
|
||||
if (typeof window !== 'undefined')
|
||||
window.addEventListener('hashchange', this.handleHashChange)
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
if (typeof window !== 'undefined')
|
||||
window.removeEventListener('hashchange', this.handleHashChange)
|
||||
this.cleanupRuntime()
|
||||
}
|
||||
|
||||
private readonly handleHashChange = () => {
|
||||
void this.renderSandbox()
|
||||
}
|
||||
|
||||
private cleanupRuntime() {
|
||||
this.cleanupMountedRuntime?.()
|
||||
this.cleanupMountedRuntime = null
|
||||
if (this.canvasRef?.nativeElement)
|
||||
this.canvasRef.nativeElement.innerHTML = ''
|
||||
}
|
||||
|
||||
private async renderSandbox() {
|
||||
const mountPoint = this.canvasRef?.nativeElement
|
||||
if (!mountPoint)
|
||||
return
|
||||
|
||||
this.renderToken += 1
|
||||
const activeToken = this.renderToken
|
||||
|
||||
this.cleanupRuntime()
|
||||
this.selection.set(parseSandboxSelection(window.location.search, testSandboxFrameworks))
|
||||
this.markdown.set(decodeMarkdownHash(window.location.hash || '') ?? '')
|
||||
this.errorMessage.set('')
|
||||
this.status.set('loading')
|
||||
applySandboxStyles(this.selection())
|
||||
|
||||
try {
|
||||
const teardown = await mountSandbox(this.selection(), this.markdown(), mountPoint)
|
||||
if (activeToken !== this.renderToken) {
|
||||
teardown()
|
||||
return
|
||||
}
|
||||
this.cleanupMountedRuntime = teardown
|
||||
this.status.set('ready')
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[test-sandbox] failed to render sandbox', error)
|
||||
this.cleanupRuntime()
|
||||
this.status.set('error')
|
||||
this.errorMessage.set(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
getTestSandboxFramework,
|
||||
TEST_SANDBOX_KATEX_VERSION,
|
||||
TEST_SANDBOX_MERMAID_VERSION,
|
||||
testSandboxFrameworks,
|
||||
} from '../../playground/src/testSandboxConfig'
|
||||
@@ -0,0 +1,303 @@
|
||||
import type { AngularRenderContext } from 'markstream-angular'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { NodeRenderer } from 'markstream-angular'
|
||||
|
||||
@Component({
|
||||
selector: 'app-angular-thinking-node',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NodeRenderer],
|
||||
template: `
|
||||
<div class="thinking-node">
|
||||
<div class="thinking-node__icon-shell" aria-hidden="true">
|
||||
<div class="thinking-node__icon">
|
||||
<svg class="thinking-node__icon-svg" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M12 3C7.03 3 3 6.58 3 11c0 1.86.66 3.57 1.77 4.98L4 21l5.2-1.9C10.06 19.35 11 19.5 12 19.5c4.97 0 9-3.58 9-8.5S16.97 3 12 3z"
|
||||
stroke="currentColor"
|
||||
stroke-width="0.8"
|
||||
fill="currentColor"
|
||||
opacity="0.9"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="thinking-node__body">
|
||||
<div class="thinking-node__meta">
|
||||
<strong>Thinking</strong>
|
||||
<span class="thinking-node__role">(assistant)</span>
|
||||
<span class="thinking-dots" [class.visible]="node?.loading" [class.hidden]="!node?.loading" aria-hidden="true">
|
||||
<span class="dot dot-1"></span>
|
||||
<span class="dot dot-2"></span>
|
||||
<span class="dot dot-3"></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="thinking-node__content">
|
||||
<span *ngIf="node?.loading" class="thinking-node__sr-only" aria-live="polite">Thinking...</span>
|
||||
<div
|
||||
class="thinking-node__content-shell"
|
||||
[class.thinking-node__content-shell--loading]="node?.loading"
|
||||
[class.thinking-node__content-shell--ready]="!node?.loading"
|
||||
>
|
||||
<markstream-angular
|
||||
[content]="resolvedContent"
|
||||
[final]="resolvedFinal"
|
||||
[customId]="context?.customId"
|
||||
[isDark]="context?.isDark"
|
||||
[typewriter]="resolvedTypewriter"
|
||||
[showTooltips]="context?.showTooltips"
|
||||
[allowHtml]="context?.allowHtml"
|
||||
[codeBlockStream]="context?.codeBlockStream"
|
||||
[renderCodeBlocksAsPre]="context?.renderCodeBlocksAsPre"
|
||||
[codeBlockProps]="context?.codeBlockProps"
|
||||
[mermaidProps]="context?.mermaidProps"
|
||||
[d2Props]="context?.d2Props"
|
||||
[infographicProps]="context?.infographicProps"
|
||||
[themes]="context?.codeBlockThemes?.themes"
|
||||
[codeBlockDarkTheme]="context?.codeBlockThemes?.darkTheme"
|
||||
[codeBlockLightTheme]="context?.codeBlockThemes?.lightTheme"
|
||||
[codeBlockMonacoOptions]="context?.codeBlockThemes?.monacoOptions"
|
||||
[codeBlockMinWidth]="context?.codeBlockThemes?.minWidth"
|
||||
[codeBlockMaxWidth]="context?.codeBlockThemes?.maxWidth"
|
||||
[customHtmlTags]="resolvedCustomHtmlTags"
|
||||
[customComponents]="context?.customComponents"
|
||||
[parseOptions]="context?.parseOptions"
|
||||
[customMarkdownIt]="context?.customMarkdownIt"
|
||||
[indexKey]="nestedPrefix"
|
||||
[viewportPriority]="false"
|
||||
[deferNodesUntilVisible]="false"
|
||||
[batchRendering]="false"
|
||||
[maxLiveNodes]="0"
|
||||
></markstream-angular>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.thinking-node {
|
||||
margin: 1rem 0;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
border-left: 4px solid #60a5fa;
|
||||
border-radius: 0.75rem;
|
||||
background: #eff6ff;
|
||||
padding: 1rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
:host-context(.dark) .thinking-node {
|
||||
background: rgba(30, 64, 175, 0.2);
|
||||
color: #e6f0ff;
|
||||
}
|
||||
|
||||
.thinking-node__icon-shell {
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.thinking-node__icon {
|
||||
display: flex;
|
||||
height: 2.25rem;
|
||||
width: 2.25rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
background: #bfdbfe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
:host-context(.dark) .thinking-node__icon {
|
||||
background: #1d4ed8;
|
||||
color: #dbeafe;
|
||||
}
|
||||
|
||||
.thinking-node__icon-svg {
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
}
|
||||
|
||||
.thinking-node__body {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.thinking-node__meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.thinking-node__role {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
:host-context(.dark) .thinking-node__role {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.thinking-node__content {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.thinking-node__content-shell {
|
||||
min-height: 1.25rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.thinking-node__content-shell--loading,
|
||||
.thinking-node__content-shell--ready {
|
||||
animation: thinking-content-fade 160ms ease both;
|
||||
}
|
||||
|
||||
.thinking-node__content-shell--loading {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.thinking-node__content-shell--ready {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.thinking-node__content-shell :where(markstream-angular) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.thinking-node__content-shell :where(.markstream-angular) {
|
||||
content-visibility: visible;
|
||||
contain: content;
|
||||
contain-intrinsic-size: 0 0;
|
||||
}
|
||||
|
||||
.thinking-dots {
|
||||
display: inline-flex;
|
||||
height: 12px;
|
||||
width: 36px;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
opacity: 0;
|
||||
transition: opacity 160ms linear, transform 160ms linear;
|
||||
}
|
||||
|
||||
.thinking-dots .dot {
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
transform: translateY(0);
|
||||
border-radius: 9999px;
|
||||
background: #1e3a8a;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
:host-context(.dark) .thinking-dots .dot {
|
||||
background: #bfdbfe;
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
.thinking-dots.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.thinking-dots.hidden {
|
||||
opacity: 0;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.thinking-dots.visible .dot-1 {
|
||||
animation: think-bounce 1s infinite ease-in-out;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.thinking-dots.visible .dot-2 {
|
||||
animation: think-bounce 1s infinite ease-in-out;
|
||||
animation-delay: 0.12s;
|
||||
}
|
||||
|
||||
.thinking-dots.visible .dot-3 {
|
||||
animation: think-bounce 1s infinite ease-in-out;
|
||||
animation-delay: 0.24s;
|
||||
}
|
||||
|
||||
.thinking-node__sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@keyframes think-bounce {
|
||||
0%, 80%, 100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: translateY(-6px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes thinking-content-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ThinkingNodeComponent {
|
||||
@Input({ required: true }) node!: Record<string, any>
|
||||
@Input() context?: AngularRenderContext
|
||||
@Input() indexKey?: string
|
||||
@Input() typewriter?: boolean
|
||||
|
||||
private readonly fallbackThinkingTags = ['thinking']
|
||||
|
||||
get resolvedFinal() {
|
||||
if (typeof this.node?.loading === 'boolean')
|
||||
return !this.node.loading
|
||||
return this.context?.final
|
||||
}
|
||||
|
||||
get resolvedCustomHtmlTags() {
|
||||
return this.context?.customHtmlTags?.length
|
||||
? this.context.customHtmlTags
|
||||
: this.fallbackThinkingTags
|
||||
}
|
||||
|
||||
get nestedPrefix() {
|
||||
return this.indexKey ? `${this.indexKey}-thinking` : 'thinking'
|
||||
}
|
||||
|
||||
get resolvedContent() {
|
||||
return typeof this.node?.content === 'string' ? this.node.content : ''
|
||||
}
|
||||
|
||||
get resolvedTypewriter() {
|
||||
if (typeof this.typewriter === 'boolean')
|
||||
return this.typewriter
|
||||
if (typeof this.context?.typewriter === 'boolean')
|
||||
return this.context.typewriter
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
function extractThinkingAttrs(rawContent: string) {
|
||||
const match = rawContent.match(/<thinking([^>]*)>/i)
|
||||
if (!match)
|
||||
return []
|
||||
|
||||
const attrString = match[1] || ''
|
||||
const attrs: Array<{ name: string, value: string | boolean }> = []
|
||||
const attrRegex = /([^\s=]+)(?:="([^"]*)")?/g
|
||||
let nextMatch: RegExpExecArray | null
|
||||
|
||||
while ((nextMatch = attrRegex.exec(attrString)) !== null) {
|
||||
const attrName = nextMatch[1]
|
||||
const attrValue = nextMatch[2] || true
|
||||
attrs.push({ name: attrName, value: attrValue })
|
||||
}
|
||||
|
||||
return attrs
|
||||
}
|
||||
|
||||
function stripThinkingWrapper(rawContent: string) {
|
||||
return rawContent
|
||||
.replace(/<thinking[^>]*>/i, '')
|
||||
.replace(/<\/thinking>\s*$/i, '')
|
||||
}
|
||||
|
||||
export const THINKING_PARSE_OPTIONS = {
|
||||
preTransformTokens(tokens: any[]) {
|
||||
return tokens.map((token) => {
|
||||
if (token?.type !== 'inline' || typeof token.content !== 'string' || !token.content.includes('<thinking'))
|
||||
return token
|
||||
|
||||
const children = Array.isArray(token.children) ? token.children : []
|
||||
token.children = children.map((child) => {
|
||||
if (child?.type !== 'html_block' || child?.tag !== 'thinking')
|
||||
return child
|
||||
|
||||
return {
|
||||
type: 'thinking',
|
||||
loading: child.loading,
|
||||
attrs: extractThinkingAttrs(String(child.content || '')),
|
||||
content: stripThinkingWrapper(String(child.content || '')),
|
||||
}
|
||||
})
|
||||
|
||||
return token
|
||||
})
|
||||
},
|
||||
} as const
|
||||
@@ -0,0 +1,4 @@
|
||||
const d2BundledStub = {}
|
||||
|
||||
export default d2BundledStub
|
||||
export const D2 = d2BundledStub
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import '@angular/compiler'
|
||||
|
||||
export * from '../../../packages/markstream-angular/src/index'
|
||||
export { default } from '../../../packages/markstream-angular/src/index'
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["DOM", "ES2022"],
|
||||
"useDefineForClassFields": false,
|
||||
"experimentalDecorators": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["vite/client"],
|
||||
"strict": false,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { Plugin, PluginOption } from 'vite'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import Vue from '@vitejs/plugin-vue'
|
||||
import { defineConfig } from 'vite'
|
||||
import monacoEditorPlugin from 'vite-plugin-monaco-editor-esm'
|
||||
|
||||
const D2_VENDOR_PUBLIC_PATH = '/vendor/d2-browser.js'
|
||||
const D2_VENDOR_FILE_NAME = 'vendor/d2-browser.js'
|
||||
const d2BrowserEntry = path.resolve(__dirname, '../node_modules/@terrastruct/d2/dist/browser/index.js')
|
||||
|
||||
function normalizeId(id: string) {
|
||||
return id.split(path.sep).join('/')
|
||||
}
|
||||
|
||||
function d2VendorPlugin(): Plugin {
|
||||
const vendorSource = fs.readFileSync(d2BrowserEntry)
|
||||
|
||||
return {
|
||||
name: 'playground-angular-d2-vendor',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
const pathname = req.url?.split('?')[0]
|
||||
if (pathname !== D2_VENDOR_PUBLIC_PATH) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/javascript; charset=utf-8')
|
||||
res.end(vendorSource)
|
||||
})
|
||||
},
|
||||
generateBundle() {
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: D2_VENDOR_FILE_NAME,
|
||||
source: vendorSource,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
publicDir: path.resolve(__dirname, '../playground/public'),
|
||||
worker: {
|
||||
format: 'es',
|
||||
},
|
||||
server: {
|
||||
port: 4175,
|
||||
fs: {
|
||||
allow: [path.resolve(__dirname, '..')],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: /^markstream-angular$/,
|
||||
replacement: path.resolve(__dirname, './src/vendor/markstream-angular-jit.ts'),
|
||||
},
|
||||
{
|
||||
find: /^markstream-vue$/,
|
||||
replacement: path.resolve(__dirname, '../src/exports.ts'),
|
||||
},
|
||||
{
|
||||
find: /^stream-markdown-parser$/,
|
||||
replacement: path.resolve(__dirname, '../packages/markdown-parser/src/index.ts'),
|
||||
},
|
||||
{
|
||||
find: /^stream-markdown-parser\//,
|
||||
replacement: `${path.resolve(__dirname, '../packages/markdown-parser/src')}/`,
|
||||
},
|
||||
{
|
||||
find: /^@terrastruct\/d2$/,
|
||||
replacement: path.resolve(__dirname, './src/vendor/d2-bundled-stub.ts'),
|
||||
},
|
||||
],
|
||||
},
|
||||
css: {
|
||||
postcss: path.resolve(__dirname, './postcss.config.cjs'),
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
'katex',
|
||||
'katex/contrib/mhchem',
|
||||
'katex/dist/contrib/mhchem',
|
||||
'mermaid',
|
||||
],
|
||||
exclude: ['stream-monaco'],
|
||||
},
|
||||
plugins: [
|
||||
Vue(),
|
||||
d2VendorPlugin(),
|
||||
monacoEditorPlugin({
|
||||
languageWorkers: [
|
||||
'editorWorkerService',
|
||||
'typescript',
|
||||
'css',
|
||||
'html',
|
||||
'json',
|
||||
],
|
||||
customDistPath(_root, buildOutDir) {
|
||||
return path.resolve(buildOutDir, 'monacoeditorwork')
|
||||
},
|
||||
}) as unknown as PluginOption,
|
||||
],
|
||||
build: {
|
||||
// The Angular playground still ships Monaco as an optional local asset,
|
||||
// so a higher warning budget keeps the build signal focused on true
|
||||
// regressions instead of the expected editor payload.
|
||||
chunkSizeWarningLimit: 2500,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
const normalized = normalizeId(id)
|
||||
|
||||
if (normalized.includes('/node_modules/@angular/compiler/'))
|
||||
return 'angular-compiler'
|
||||
|
||||
if (normalized.includes('/node_modules/@angular/core/'))
|
||||
return 'angular-core'
|
||||
|
||||
if (normalized.includes('/node_modules/@angular/common/'))
|
||||
return 'angular-common'
|
||||
|
||||
if (normalized.includes('/node_modules/@angular/platform-browser/'))
|
||||
return 'angular-platform-browser'
|
||||
|
||||
if (normalized.includes('/node_modules/rxjs/'))
|
||||
return 'angular-rxjs'
|
||||
|
||||
if (normalized.includes('/node_modules/monaco-editor/'))
|
||||
return 'monaco-editor'
|
||||
|
||||
if (normalized.includes('/node_modules/stream-monaco/'))
|
||||
return 'stream-monaco'
|
||||
|
||||
if (normalized.includes('/playground-angular/src/vendor/markstream-angular-jit.ts'))
|
||||
return 'markstream-angular'
|
||||
|
||||
if (normalized.includes('/packages/markstream-angular/src/'))
|
||||
return 'markstream-angular'
|
||||
|
||||
if (normalized.includes('/packages/markdown-parser/src/'))
|
||||
return 'markdown-parser'
|
||||
|
||||
if (normalized.includes('/playground-angular/src/test-lab.component.ts'))
|
||||
return 'angular-test-lab'
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user