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,116 @@
|
||||
export type HeightEstimationWorkload = 'text' | 'code'
|
||||
|
||||
interface HeightEstimationTranscriptOptions {
|
||||
total?: number
|
||||
}
|
||||
|
||||
function buildCodeFence(language: string, lines: string[]) {
|
||||
return [`\`\`\`${language}`, ...lines, '```'].join('\n')
|
||||
}
|
||||
|
||||
function buildPlainParagraph(index: number) {
|
||||
const variants = [
|
||||
'This keeps the structure simple enough for conservative measurement.',
|
||||
'It also mirrors restored chat history where adjacent messages can have noticeably different lengths.',
|
||||
'Some paragraphs stay compact while others expand with additional context about resize, restore anchoring, and viewport math.',
|
||||
'The goal is to avoid a perfectly uniform document so baseline average-height fallback has a more realistic distribution to approximate.',
|
||||
]
|
||||
const repeat = (index % 4) + 1
|
||||
const suffix = Array.from({ length: repeat }, (_, variantIndex) =>
|
||||
variants[(index + variantIndex) % variants.length]).join(' ')
|
||||
|
||||
return `Paragraph ${index} is intentionally plain text with enough words to wrap over multiple lines as the viewport moves between 375 and 1280 pixels. ${suffix}`
|
||||
}
|
||||
|
||||
function buildCodeHeavyLines(index: number, count: number) {
|
||||
return Array.from({ length: count }, (_, lineIndex) => {
|
||||
const label = `section-${index}-${lineIndex}`
|
||||
if (lineIndex % 6 === 0)
|
||||
return `export const ${label.replace(/-/g, '_')} = ${index + lineIndex}`
|
||||
if (lineIndex % 6 === 1)
|
||||
return `export function render${index}_${lineIndex}(input: string) {`
|
||||
if (lineIndex % 6 === 2)
|
||||
return ` return input.split(/\\r?\\n/).map((line, idx) => \`\${idx}:\${line.trim()}\`)`
|
||||
if (lineIndex % 6 === 3)
|
||||
return '}'
|
||||
if (lineIndex % 6 === 4)
|
||||
return `const tuple${lineIndex} = ['${label}', ${index}, ${lineIndex}] as const`
|
||||
return `console.log('${label}', tuple${Math.max(0, lineIndex - 1)})`
|
||||
})
|
||||
}
|
||||
|
||||
export function buildHeightEstimationTranscript(workload: HeightEstimationWorkload, options: HeightEstimationTranscriptOptions = {}) {
|
||||
const blocks: string[] = [
|
||||
`# Height Estimation ${workload === 'text' ? 'Text' : 'Code'} Workload`,
|
||||
'This transcript is generated for cross-framework height-estimation experiments.',
|
||||
]
|
||||
|
||||
const total = Math.max(32, Math.round(options.total ?? (workload === 'text' ? 960 : 720)))
|
||||
|
||||
for (let index = 1; index <= total; index += 1) {
|
||||
if (index % 64 === 0) {
|
||||
blocks.push(`## Section ${index / 64}`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (workload === 'text') {
|
||||
if (index % 33 === 0) {
|
||||
blocks.push([
|
||||
'- A simple list item that keeps the inline structure predictable for measurement.',
|
||||
'- Another simple list item that should still go through the conservative text estimator.',
|
||||
'- Final list item used to validate list wrapper overhead.',
|
||||
].join('\n'))
|
||||
continue
|
||||
}
|
||||
|
||||
if (index % 29 === 0) {
|
||||
blocks.push(`Paragraph ${index} mixes [links](https://example.com/${index}) with **strong text** and \`inline code\` so conservative text estimation should skip it.`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (index % 87 === 0) {
|
||||
blocks.push(buildCodeFence('ts', [
|
||||
`export function helper${index}(source: string) {`,
|
||||
' return source.split(/\\r?\\n/).map((line, lineIndex) => ({ lineIndex, line }))',
|
||||
'}',
|
||||
]))
|
||||
continue
|
||||
}
|
||||
|
||||
blocks.push(buildPlainParagraph(index))
|
||||
continue
|
||||
}
|
||||
|
||||
if (index % 3 === 0) {
|
||||
const lineCount = 8 + (index % 5) * 6
|
||||
blocks.push(buildCodeFence('ts', [
|
||||
`export const record${index} = {`,
|
||||
` id: ${index},`,
|
||||
` label: 'code-heavy-${index}',`,
|
||||
` values: Array.from({ length: 8 }, (_, i) => i + ${index}),`,
|
||||
'}',
|
||||
'',
|
||||
...buildCodeHeavyLines(index, lineCount),
|
||||
]))
|
||||
continue
|
||||
}
|
||||
|
||||
if (index % 11 === 0) {
|
||||
const diffLines = 4 + (index % 4) * 3
|
||||
blocks.push(buildCodeFence('diff-ts', [
|
||||
'<<<<<<< original',
|
||||
`export const diff${index} = () => 'before-${index}'`,
|
||||
...Array.from({ length: diffLines }, (_, lineIndex) => `const before_${index}_${lineIndex} = ${lineIndex}`),
|
||||
'=======',
|
||||
`export const diff${index} = () => 'after-${index}'`,
|
||||
...Array.from({ length: diffLines }, (_, lineIndex) => `const after_${index}_${lineIndex} = ${lineIndex + 1}`),
|
||||
'>>>>>>> updated',
|
||||
]))
|
||||
continue
|
||||
}
|
||||
|
||||
blocks.push(`Code workload paragraph ${index} still provides text around code blocks so the renderer has mixed content and realistic spacing between editors. ${index % 2 === 0 ? 'Some of these paragraphs intentionally stay short.' : 'Others stretch longer to keep the surrounding non-code heights uneven across the transcript.'}`)
|
||||
}
|
||||
|
||||
return blocks.join('\n\n')
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
interface TextareaSelectionLike {
|
||||
value: string
|
||||
selectionStart: number | null
|
||||
selectionEnd: number | null
|
||||
}
|
||||
|
||||
export interface MarkdownPasteResult {
|
||||
nextValue: string
|
||||
selectionStart: number
|
||||
selectionEnd: number
|
||||
}
|
||||
|
||||
export function normalizePastedMarkdownNewlines(content: string) {
|
||||
if (!content.includes('\\n') || /[\r\n]/.test(content))
|
||||
return content
|
||||
|
||||
let normalized = ''
|
||||
let changed = false
|
||||
|
||||
for (let index = 0; index < content.length; index++) {
|
||||
if (content[index] !== '\\') {
|
||||
normalized += content[index]
|
||||
continue
|
||||
}
|
||||
|
||||
let slashEnd = index
|
||||
while (slashEnd < content.length && content[slashEnd] === '\\')
|
||||
slashEnd++
|
||||
|
||||
const slashCount = slashEnd - index
|
||||
const nextChar = content[slashEnd]
|
||||
|
||||
if (nextChar === 'n' && slashCount % 2 === 1) {
|
||||
normalized += '\\'.repeat((slashCount - 1) / 2)
|
||||
normalized += '\n'
|
||||
changed = true
|
||||
index = slashEnd
|
||||
continue
|
||||
}
|
||||
|
||||
normalized += '\\'.repeat(slashCount)
|
||||
index = slashEnd - 1
|
||||
}
|
||||
|
||||
return changed ? normalized : content
|
||||
}
|
||||
|
||||
export function resolveMarkdownTextareaPaste(
|
||||
textarea: TextareaSelectionLike,
|
||||
pasted: string,
|
||||
): MarkdownPasteResult | null {
|
||||
if (!pasted || !pasted.includes('\\n') || /[\r\n]/.test(pasted))
|
||||
return null
|
||||
|
||||
const normalized = normalizePastedMarkdownNewlines(pasted)
|
||||
const selectionStart = textarea.selectionStart ?? textarea.value.length
|
||||
const selectionEnd = textarea.selectionEnd ?? textarea.value.length
|
||||
const nextValue = `${textarea.value.slice(0, selectionStart)}${normalized}${textarea.value.slice(selectionEnd)}`
|
||||
const cursor = selectionStart + normalized.length
|
||||
|
||||
return {
|
||||
nextValue,
|
||||
selectionStart: cursor,
|
||||
selectionEnd: cursor,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
export type TestLabSampleId = 'baseline' | 'thinking' | 'diff' | 'stress'
|
||||
export type TestLabFrameworkId = 'vue3' | 'vue2' | 'react' | 'angular'
|
||||
|
||||
export interface TestLabFrameworkCard {
|
||||
id: TestLabFrameworkId
|
||||
label: string
|
||||
note: string
|
||||
origin: string
|
||||
localPort: number | null
|
||||
}
|
||||
|
||||
export interface TestLabSampleCard {
|
||||
id: TestLabSampleId
|
||||
title: string
|
||||
summary: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export const TEST_LAB_FRAMEWORKS: ReadonlyArray<TestLabFrameworkCard> = [
|
||||
{
|
||||
id: 'vue3',
|
||||
label: 'Vue 3',
|
||||
note: '完整特性 / 主 playground',
|
||||
origin: 'https://markstream-vue.simonhe.me',
|
||||
localPort: null,
|
||||
},
|
||||
{
|
||||
id: 'vue2',
|
||||
label: 'Vue 2',
|
||||
note: '兼容层回归 / 老项目',
|
||||
origin: 'https://markstream-vue2.pages.dev',
|
||||
localPort: 3334,
|
||||
},
|
||||
{
|
||||
id: 'react',
|
||||
label: 'React',
|
||||
note: 'hooks / 渲染对照',
|
||||
origin: 'https://markstream-react.pages.dev',
|
||||
localPort: 4174,
|
||||
},
|
||||
{
|
||||
id: 'angular',
|
||||
label: 'Angular',
|
||||
note: 'standalone / parity lab',
|
||||
origin: 'https://markstream-angular.pages.dev',
|
||||
localPort: 4175,
|
||||
},
|
||||
] as const
|
||||
|
||||
export const TEST_LAB_SAMPLES: ReadonlyArray<TestLabSampleCard> = [
|
||||
{
|
||||
id: 'baseline',
|
||||
title: '基础回归',
|
||||
summary: '标题、强调、数学、Mermaid、infographic 和 D2 一次看全。',
|
||||
content: `# Markstream Test Lab
|
||||
|
||||
在这里可以快速验证 **Vue 3 / Vue 2 / React / Angular** 四套渲染器的表现是否一致。
|
||||
|
||||
## 基础格式
|
||||
|
||||
- **加粗**
|
||||
- *斜体*
|
||||
- \`inline code\`
|
||||
- [链接](https://github.com/Simon-He95/markstream-vue)
|
||||
|
||||
## 数学
|
||||
|
||||
行内公式:$E = mc^2$
|
||||
|
||||
块级公式:
|
||||
|
||||
$$
|
||||
\\int_0^1 x^2 dx = \\frac{1}{3}
|
||||
$$
|
||||
|
||||
## Mermaid
|
||||
|
||||
\`\`\`mermaid
|
||||
flowchart LR
|
||||
Prompt --> Parser --> Renderer --> Preview
|
||||
\`\`\`
|
||||
|
||||
## 代码块
|
||||
|
||||
\`\`\`ts
|
||||
export function compareFramework(name: string) {
|
||||
return \`\${name} test page is ready.\`
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## Infographic
|
||||
|
||||
\`\`\`infographic
|
||||
infographic list-row-simple-horizontal-arrow
|
||||
data
|
||||
items
|
||||
- label 输入
|
||||
desc markdown
|
||||
- label 渲染
|
||||
desc 解析与增量更新
|
||||
- label 对照
|
||||
desc 跨框架检查
|
||||
\`\`\`
|
||||
|
||||
## D2
|
||||
|
||||
\`\`\`d2
|
||||
App: Angular
|
||||
Parser: Markdown AST
|
||||
Renderer: Enhanced HTML
|
||||
Lab: Playground/Test
|
||||
|
||||
App -> Parser -> Renderer -> Lab
|
||||
\`\`\`
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'thinking',
|
||||
title: 'Thinking 嵌套重节点',
|
||||
summary: '检查 custom thinking 节点里是否还能稳定渲染 Mermaid、代码块和列表。',
|
||||
content: `# Thinking / Nested Blocks
|
||||
|
||||
<thinking>
|
||||
在这个 thinking 容器里,我们希望 Angular 和 Vue / React 一样,仍然按正常 markdown 节点树渲染。
|
||||
|
||||
## Nested Mermaid
|
||||
|
||||
\`\`\`mermaid
|
||||
flowchart TD
|
||||
Thinking --> Parse
|
||||
Parse --> Mermaid
|
||||
Parse --> CodeBlock
|
||||
\`\`\`
|
||||
|
||||
## Nested Code Block
|
||||
|
||||
\`\`\`ts
|
||||
export function ensureParity(name: string) {
|
||||
return \`nested renderer ok: \${name}\`
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
- nested list item
|
||||
- another item
|
||||
</thinking>
|
||||
|
||||
外层正文不应该被吞掉。
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'diff',
|
||||
title: 'Diff 与代码流',
|
||||
summary: '观察 diff code block、长文本和流式更新的稳定性。',
|
||||
content: `# Diff Regression
|
||||
|
||||
下面这个样例更适合观察 **code block stream** 和折叠逻辑:
|
||||
|
||||
\`\`\`diff typescript
|
||||
diff --git a/src/render.ts b/src/render.ts
|
||||
index 0000000..1111111 100644
|
||||
--- a/src/render.ts
|
||||
+++ b/src/render.ts
|
||||
@@ -1,7 +1,12 @@
|
||||
-export function render(input) {
|
||||
- return input
|
||||
+export function render(input: string) {
|
||||
+ if (!input.trim())
|
||||
+ return 'empty'
|
||||
+
|
||||
+ const normalized = input.replace(/\\r\\n/g, '\\n')
|
||||
+ return normalized
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
再加一段普通代码,方便对比 Monaco / MarkdownCodeBlock / PreCodeNode:
|
||||
|
||||
\`\`\`tsx
|
||||
export function TestHarness() {
|
||||
return (
|
||||
<section>
|
||||
<h2>Regression</h2>
|
||||
<p>Streaming should remain smooth.</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
\`\`\`
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'stress',
|
||||
title: '结构压力',
|
||||
summary: '列表、表格、引用、HTML 和长段落一起压一遍。',
|
||||
content: `# Structural Stress
|
||||
|
||||
> 这个样例用于检查复杂结构在 streaming 中是否抖动、错位或丢节点。
|
||||
|
||||
## 列表
|
||||
|
||||
1. 第一层
|
||||
- 第二层
|
||||
- 第三层
|
||||
2. 继续
|
||||
|
||||
## 表格
|
||||
|
||||
| Framework | Route | Purpose |
|
||||
| --- | --- | --- |
|
||||
| Vue 3 | \`/test\` | 主调试台 |
|
||||
| Vue 2 | \`/test\` | 兼容回归 |
|
||||
| React | \`/test\` | 跨框架对照 |
|
||||
| Angular | \`/test\` | baseline 对照 |
|
||||
|
||||
## HTML
|
||||
|
||||
<details>
|
||||
<summary>展开看一段 HTML</summary>
|
||||
<p>如果这里的结构错了,通常说明 HTML block / inline 的边界处理有问题。</p>
|
||||
</details>
|
||||
|
||||
## 长段落
|
||||
|
||||
Markstream 现在不仅要处理单次完整渲染,还要处理 AI 场景下不断追加的 markdown 内容,所以这个页面更像一个回归驾驶舱。你可以一边编辑左侧输入,一边切换 Vue 2、React 或 Angular 的 test page,用同一段内容观察差异,判断问题是解析层、组件层,还是框架适配层。
|
||||
`,
|
||||
},
|
||||
] as const
|
||||
@@ -0,0 +1,268 @@
|
||||
import { deflateSync, inflateSync } from 'fflate'
|
||||
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string'
|
||||
|
||||
export interface FrameworkTarget {
|
||||
id: string
|
||||
origin: string
|
||||
localPort: number | null
|
||||
}
|
||||
|
||||
export interface LocationLike {
|
||||
hostname: string
|
||||
protocol: string
|
||||
}
|
||||
|
||||
export type TestPageViewMode = 'lab' | 'preview'
|
||||
|
||||
const FALLBACK_ORIGIN = 'https://markstream.local'
|
||||
const RAW_PREFIX = 'raw:'
|
||||
const DEFLATE_PREFIX = 'z:'
|
||||
const BROTLI_PREFIX = 'br:'
|
||||
const BASE64_CHUNK_SIZE = 0x8000
|
||||
|
||||
interface BrotliCompressModule {
|
||||
compress: (input: Uint8Array, options?: { quality?: number }) => Promise<Uint8Array>
|
||||
decompress: (input: Uint8Array) => Promise<Uint8Array>
|
||||
default?: BrotliCompressModule
|
||||
}
|
||||
|
||||
let brotliCompressPromise: Promise<BrotliCompressModule> | null = null
|
||||
|
||||
async function loadBrotliCompress() {
|
||||
brotliCompressPromise ||= import('brotli-compress').then((module) => {
|
||||
const loaded = module as BrotliCompressModule
|
||||
return loaded.compress ? loaded : loaded.default!
|
||||
})
|
||||
return brotliCompressPromise
|
||||
}
|
||||
|
||||
function encodeBase64Url(bytes: Uint8Array) {
|
||||
let binary = ''
|
||||
for (let index = 0; index < bytes.length; index += BASE64_CHUNK_SIZE)
|
||||
binary += String.fromCharCode(...bytes.subarray(index, index + BASE64_CHUNK_SIZE))
|
||||
|
||||
return btoa(binary)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
function decodeBase64Url(payload: string) {
|
||||
const normalized = payload
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/')
|
||||
.padEnd(payload.length + ((4 - payload.length % 4) % 4), '=')
|
||||
|
||||
const binary = atob(normalized)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let index = 0; index < binary.length; index++)
|
||||
bytes[index] = binary.charCodeAt(index)
|
||||
|
||||
return bytes
|
||||
}
|
||||
|
||||
function encodeDeflatedPayload(markdown: string) {
|
||||
try {
|
||||
const compressed = deflateSync(new TextEncoder().encode(markdown), { level: 9 })
|
||||
return `${DEFLATE_PREFIX}${encodeBase64Url(compressed)}`
|
||||
}
|
||||
catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
async function encodeBrotliPayload(markdown: string) {
|
||||
try {
|
||||
const brotli = await loadBrotliCompress()
|
||||
const compressed = await brotli.compress(new TextEncoder().encode(markdown), { quality: 11 })
|
||||
return `${BROTLI_PREFIX}${encodeBase64Url(compressed)}`
|
||||
}
|
||||
catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function isLocalHost(hostname: string) {
|
||||
return hostname === 'localhost' || hostname === '127.0.0.1'
|
||||
}
|
||||
|
||||
export function encodeMarkdownPayload(markdown: string) {
|
||||
if (!markdown)
|
||||
return ''
|
||||
|
||||
const encodedRaw = encodeURIComponent(markdown)
|
||||
const rawPayload = `${RAW_PREFIX}${encodedRaw}`
|
||||
const legacyCompressed = compressToEncodedURIComponent(markdown)
|
||||
const deflated = encodeDeflatedPayload(markdown)
|
||||
|
||||
const candidates = [rawPayload, legacyCompressed, deflated].filter(Boolean)
|
||||
if (!candidates.length)
|
||||
return ''
|
||||
|
||||
return candidates.reduce((shortest, candidate) => candidate.length < shortest.length ? candidate : shortest)
|
||||
}
|
||||
|
||||
export async function encodeMarkdownPayloadAsync(markdown: string) {
|
||||
if (!markdown)
|
||||
return ''
|
||||
|
||||
const currentPayload = encodeMarkdownPayload(markdown)
|
||||
const brotliPayload = await encodeBrotliPayload(markdown)
|
||||
const candidates = [currentPayload, brotliPayload].filter(Boolean)
|
||||
if (!candidates.length)
|
||||
return ''
|
||||
|
||||
return candidates.reduce((shortest, candidate) => candidate.length < shortest.length ? candidate : shortest)
|
||||
}
|
||||
|
||||
export function createMarkdownHash(markdown: string) {
|
||||
const payload = encodeMarkdownPayload(markdown)
|
||||
return payload ? `data=${payload}` : ''
|
||||
}
|
||||
|
||||
export async function createMarkdownHashAsync(markdown: string) {
|
||||
const payload = await encodeMarkdownPayloadAsync(markdown)
|
||||
return payload ? `data=${payload}` : ''
|
||||
}
|
||||
|
||||
export function decodeMarkdownHash(hash: string) {
|
||||
const matched = (hash || '').match(/^#?data=([^&]+)/)
|
||||
if (!matched?.[1])
|
||||
return null
|
||||
|
||||
const payload = matched[1]
|
||||
if (payload.startsWith(RAW_PREFIX)) {
|
||||
try {
|
||||
return decodeURIComponent(payload.slice(RAW_PREFIX.length))
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.startsWith(DEFLATE_PREFIX)) {
|
||||
try {
|
||||
return new TextDecoder().decode(inflateSync(decodeBase64Url(payload.slice(DEFLATE_PREFIX.length))))
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.startsWith(BROTLI_PREFIX))
|
||||
return null
|
||||
|
||||
try {
|
||||
return decompressFromEncodedURIComponent(payload) || null
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function decodeMarkdownHashAsync(hash: string) {
|
||||
const matched = (hash || '').match(/^#?data=([^&]+)/)
|
||||
if (!matched?.[1])
|
||||
return null
|
||||
|
||||
const payload = matched[1]
|
||||
if (!payload.startsWith(BROTLI_PREFIX))
|
||||
return decodeMarkdownHash(hash)
|
||||
|
||||
try {
|
||||
const brotli = await loadBrotliCompress()
|
||||
return new TextDecoder().decode(await brotli.decompress(decodeBase64Url(payload.slice(BROTLI_PREFIX.length))))
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function withMarkdownHash(baseUrl: string, markdown: string) {
|
||||
const nextHash = createMarkdownHash(markdown)
|
||||
if (!nextHash)
|
||||
return baseUrl
|
||||
|
||||
const isAbsolute = /^[a-z]+:\/\//i.test(baseUrl)
|
||||
const url = new URL(baseUrl, FALLBACK_ORIGIN)
|
||||
url.hash = nextHash
|
||||
|
||||
if (isAbsolute)
|
||||
return url.toString()
|
||||
|
||||
return `${url.pathname}${url.search}${url.hash}`
|
||||
}
|
||||
|
||||
export async function withMarkdownHashAsync(baseUrl: string, markdown: string) {
|
||||
const nextHash = await createMarkdownHashAsync(markdown)
|
||||
if (!nextHash)
|
||||
return baseUrl
|
||||
|
||||
const isAbsolute = /^[a-z]+:\/\//i.test(baseUrl)
|
||||
const url = new URL(baseUrl, FALLBACK_ORIGIN)
|
||||
url.hash = nextHash
|
||||
|
||||
if (isAbsolute)
|
||||
return url.toString()
|
||||
|
||||
return `${url.pathname}${url.search}${url.hash}`
|
||||
}
|
||||
|
||||
export function resolveTestPageViewMode(search: string): TestPageViewMode {
|
||||
const params = new URLSearchParams(search)
|
||||
return params.get('view') === 'preview' ? 'preview' : 'lab'
|
||||
}
|
||||
|
||||
export function withTestPageViewMode(baseUrl: string, viewMode: TestPageViewMode = 'lab') {
|
||||
const isAbsolute = /^[a-z]+:\/\//i.test(baseUrl)
|
||||
const url = new URL(baseUrl, FALLBACK_ORIGIN)
|
||||
|
||||
if (viewMode === 'preview')
|
||||
url.searchParams.set('view', 'preview')
|
||||
else
|
||||
url.searchParams.delete('view')
|
||||
|
||||
if (isAbsolute)
|
||||
return url.toString()
|
||||
|
||||
return `${url.pathname}${url.search}${url.hash}`
|
||||
}
|
||||
|
||||
export function buildTestPageHref(
|
||||
baseUrl: string,
|
||||
markdown: string,
|
||||
viewMode: TestPageViewMode = 'lab',
|
||||
) {
|
||||
return withMarkdownHash(withTestPageViewMode(baseUrl, viewMode), markdown)
|
||||
}
|
||||
|
||||
export async function buildTestPageHrefAsync(
|
||||
baseUrl: string,
|
||||
markdown: string,
|
||||
viewMode: TestPageViewMode = 'lab',
|
||||
) {
|
||||
return withMarkdownHashAsync(withTestPageViewMode(baseUrl, viewMode), markdown)
|
||||
}
|
||||
|
||||
export function resolveFrameworkTestHref(
|
||||
framework: FrameworkTarget,
|
||||
currentFrameworkId: string,
|
||||
markdown: string,
|
||||
locationLike?: LocationLike,
|
||||
viewMode: TestPageViewMode = 'lab',
|
||||
) {
|
||||
let baseUrl = framework.id === currentFrameworkId
|
||||
? '/test'
|
||||
: `${framework.origin}/test`
|
||||
|
||||
if (
|
||||
framework.id !== currentFrameworkId
|
||||
&& locationLike
|
||||
&& framework.localPort
|
||||
&& isLocalHost(locationLike.hostname)
|
||||
) {
|
||||
baseUrl = `${locationLike.protocol}//${locationLike.hostname}:${framework.localPort}/test`
|
||||
}
|
||||
|
||||
return buildTestPageHref(baseUrl, markdown, viewMode)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { withMarkdownHash } from './testPageState'
|
||||
|
||||
export type SandboxFrameworkId = 'vue3' | 'vue2' | 'react18' | 'react19' | 'angular'
|
||||
export type SandboxRenderSource = 'workspace' | 'npm'
|
||||
|
||||
export interface SandboxFrameworkTarget {
|
||||
id: SandboxFrameworkId
|
||||
label: string
|
||||
packageName: string
|
||||
defaultVersion: string
|
||||
runtimeVersion: string
|
||||
supportsWorkspace: boolean
|
||||
}
|
||||
|
||||
export interface SandboxSelectionInput {
|
||||
frameworkId?: string | null
|
||||
source?: string | null
|
||||
version?: string | null
|
||||
}
|
||||
|
||||
export interface SandboxSelection {
|
||||
framework: SandboxFrameworkTarget
|
||||
frameworkId: SandboxFrameworkId
|
||||
source: SandboxRenderSource
|
||||
version: string
|
||||
}
|
||||
|
||||
export function normalizeSandboxVersion(version: string | null | undefined, fallback: string) {
|
||||
const trimmed = typeof version === 'string' ? version.trim() : ''
|
||||
return trimmed || fallback
|
||||
}
|
||||
|
||||
export function resolveSandboxFramework(
|
||||
frameworks: ReadonlyArray<SandboxFrameworkTarget>,
|
||||
frameworkId?: string | null,
|
||||
) {
|
||||
return frameworks.find(item => item.id === frameworkId) ?? frameworks[0]
|
||||
}
|
||||
|
||||
export function normalizeSandboxSource(
|
||||
framework: SandboxFrameworkTarget,
|
||||
source?: string | null,
|
||||
): SandboxRenderSource {
|
||||
if (framework.supportsWorkspace && source === 'workspace')
|
||||
return 'workspace'
|
||||
return 'npm'
|
||||
}
|
||||
|
||||
export function resolveSandboxSelection(
|
||||
frameworks: ReadonlyArray<SandboxFrameworkTarget>,
|
||||
input: SandboxSelectionInput = {},
|
||||
): SandboxSelection {
|
||||
const framework = resolveSandboxFramework(frameworks, input.frameworkId)
|
||||
const source = normalizeSandboxSource(framework, input.source)
|
||||
const version = normalizeSandboxVersion(input.version, framework.defaultVersion)
|
||||
|
||||
return {
|
||||
framework,
|
||||
frameworkId: framework.id,
|
||||
source,
|
||||
version,
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSandboxSelection(
|
||||
search: string,
|
||||
frameworks: ReadonlyArray<SandboxFrameworkTarget>,
|
||||
) {
|
||||
const params = new URLSearchParams(search)
|
||||
return resolveSandboxSelection(frameworks, {
|
||||
frameworkId: params.get('framework'),
|
||||
source: params.get('source'),
|
||||
version: params.get('version'),
|
||||
})
|
||||
}
|
||||
|
||||
export function buildSandboxSearch(selection: SandboxSelection) {
|
||||
const params = new URLSearchParams()
|
||||
params.set('framework', selection.frameworkId)
|
||||
params.set('source', selection.source)
|
||||
params.set('version', selection.version)
|
||||
return `?${params.toString()}`
|
||||
}
|
||||
|
||||
export function buildTestSandboxHref(
|
||||
selection: SandboxSelection,
|
||||
markdown: string,
|
||||
basePath = '/test-sandbox',
|
||||
) {
|
||||
return withMarkdownHash(`${basePath}${buildSandboxSearch(selection)}`, markdown)
|
||||
}
|
||||
Reference in New Issue
Block a user