diff --git a/.changeset/friendly-charts-localize.md b/.changeset/friendly-charts-localize.md new file mode 100644 index 0000000..1b6e0e1 --- /dev/null +++ b/.changeset/friendly-charts-localize.md @@ -0,0 +1,8 @@ +--- +'@datafe-open/markdown-chart': patch +'@datafe-open/markdown-chart-markdown-it': patch +'@datafe-open/markdown-chart-react': patch +'@datafe-open/markdown-chart-vue': patch +--- + +Allow hosts to localize chart UI labels and render closed streaming chart fences inside blockquotes. diff --git a/README.md b/README.md index c2f4a1d..e5173e9 100644 --- a/README.md +++ b/README.md @@ -169,13 +169,41 @@ Pass the outer document streaming state to the framework component: Closed chart fences render immediately and keep their mounted chart instance as later text arrives. Only the active unterminated tail fence waits for more -input. Pending fences and asynchronous parsing, data resolution, and runtime +input, including when a chart fence is nested in a blockquote. Pending fences +and asynchronous parsing, data resolution, and runtime mounting show a built-in loading indicator instead of a blank placeholder. Use `loadingLabel` to localize its text and `--markdown-chart-loading-color` to align its color. Advanced React applications pass the same state to `MarkdownChartProvider`; advanced Vue applications pass it to `MarkdownChart`. +## Localized labels + +React and Vue hosts can pass a partial `labels` object to localize the +Chart/Data controls, accessibility labels, empty-data text, truncation notice, +and chart error fallback: + +```tsx + + `显示 ${visibleRows}/${totalRows} 行,${visibleColumns}/${totalColumns} 列`, + }} +/> +``` + +The same `MarkdownChartLabelOverrides` type is accepted by +`MarkdownChartProvider`, `MarkdownChartBlock`, the Vue composable/mounting +utility, and the markdown-it plugin. Omitted labels use the English defaults. + ## Advanced setup Create and pass a registry only when adding renderers or resolving host data: diff --git a/README.zh-CN.md b/README.zh-CN.md index 73f3207..1be21e8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -138,7 +138,30 @@ defineProps<{ source: string }>(); ``` -已经闭合的图表代码块会立即渲染;后续文本继续到达时,已挂载的图表实例会保持不变。只有末尾仍未闭合、正在输出的代码块会等待更多输入。等待中的代码块以及异步解析、取数和运行时挂载阶段会显示内置 loading,不再留下空白占位。可以用 `loadingLabel` 本地化文案,用 `--markdown-chart-loading-color` 对齐颜色。React 高级模式把相同状态传给 `MarkdownChartProvider`,Vue 高级模式则传给 `MarkdownChart`。 +已经闭合的图表代码块会立即渲染;后续文本继续到达时,已挂载的图表实例会保持不变。只有末尾仍未闭合、正在输出的代码块会等待更多输入;图表代码块位于引用块中时也遵循相同行为。等待中的代码块以及异步解析、取数和运行时挂载阶段会显示内置 loading,不再留下空白占位。可以用 `loadingLabel` 本地化文案,用 `--markdown-chart-loading-color` 对齐颜色。React 高级模式把相同状态传给 `MarkdownChartProvider`,Vue 高级模式则传给 `MarkdownChart`。 + +## 界面文案本地化 + +React 和 Vue 宿主可以传入部分 `labels`,本地化 Chart/Data 控件、无障碍标签、空数据提示、截断提示和图表错误兜底: + +```tsx + + `显示 ${visibleRows}/${totalRows} 行,${visibleColumns}/${totalColumns} 列`, + }} +/> +``` + +`MarkdownChartProvider`、`MarkdownChartBlock`、Vue composable / 挂载工具和 markdown-it 插件都接受同一个 `MarkdownChartLabelOverrides` 类型;没有提供的文案继续使用英文默认值。 ## 高级配置 diff --git a/packages/core/README.md b/packages/core/README.md index 73d0139..3fff0e0 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -34,3 +34,9 @@ materialization, or runtime mounting is in flight. Set `ChartRenderRequest.loadingLabel` to localize the default text. Custom Markdown adapters that render isolated blocks can use `findUnclosedMarkdownFence` to identify the active tail fence without duplicating fence parsing. + +Set `ChartRenderRequest.labels` to localize chart accessibility labels, the +Chart/Data controls, empty-data text, truncation notice, and adapter error +fallbacks. `MarkdownChartLabelOverrides` is partial; omitted entries retain +the exported `DEFAULT_MARKDOWN_CHART_LABELS`. Framework adapters expose the +same `labels` object. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 50ed020..a2f81d3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -506,11 +506,70 @@ export interface ChartRenderRequest { readonly theme?: unknown; readonly streaming?: boolean; readonly loadingLabel?: string; + readonly labels?: MarkdownChartLabelOverrides; } export const DEFAULT_MARKDOWN_CHART_LOADING_LABEL = 'Rendering chart…'; +export interface MarkdownChartTableNoticeContext { + readonly visibleRows: number; + readonly totalRows: number; + readonly visibleColumns: number; + readonly totalColumns: number; +} + +export interface MarkdownChartLabels { + readonly chartUnavailable: string; + readonly viewMode: string; + readonly chart: string; + readonly data: string; + readonly showChart: string; + readonly showData: string; + readonly noData: string; + readonly tableNotice: (context: MarkdownChartTableNoticeContext) => string; +} + +export type MarkdownChartLabelOverrides = Partial; + +export const DEFAULT_MARKDOWN_CHART_LABELS: Readonly = Object.freeze({ + chartUnavailable: 'Chart unavailable', + viewMode: 'View mode', + chart: 'Chart', + data: 'Data', + showChart: 'Show chart', + showData: 'Show data', + noData: 'No data', + tableNotice: ({ + visibleRows, + totalRows, + visibleColumns, + totalColumns, + }: MarkdownChartTableNoticeContext) => + `Showing ${visibleRows} of ${totalRows} rows and ${visibleColumns} of ${totalColumns} columns.`, +}); + +export function resolveMarkdownChartLabels( + overrides?: MarkdownChartLabelOverrides, +): Readonly { + return overrides + ? Object.freeze({ ...DEFAULT_MARKDOWN_CHART_LABELS, ...overrides }) + : DEFAULT_MARKDOWN_CHART_LABELS; +} + const MARKDOWN_FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/; +const MARKDOWN_BLOCKQUOTE_MARKER = /^ {0,3}>[ \t]?/; + +function stripMarkdownBlockquoteDepth(line: string, depth: number): string | undefined { + let remainder = line; + for (let index = 0; index < depth; index += 1) { + const marker = MARKDOWN_BLOCKQUOTE_MARKER.exec(remainder)?.[0]; + if (!marker) { + return undefined; + } + remainder = remainder.slice(marker.length); + } + return remainder; +} /** * Returns whether a Markdown fragment that starts with a fenced code block @@ -521,7 +580,17 @@ const MARKDOWN_FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/; */ export function isMarkdownFenceClosed(source: string): boolean { const lines = source.replace(/\r\n?/g, '\n').split('\n'); - const opening = MARKDOWN_FENCE_OPEN.exec(lines[0] ?? ''); + let openingLine = lines[0] ?? ''; + let blockquoteDepth = 0; + while (true) { + const marker = MARKDOWN_BLOCKQUOTE_MARKER.exec(openingLine)?.[0]; + if (!marker) { + break; + } + blockquoteDepth += 1; + openingLine = openingLine.slice(marker.length); + } + const opening = MARKDOWN_FENCE_OPEN.exec(openingLine); const marker = opening?.[1]; if (!marker) { return false; @@ -534,7 +603,10 @@ export function isMarkdownFenceClosed(source: string): boolean { const closing = new RegExp( `^ {0,3}${markerCharacter === '`' ? '`' : '~'}{${marker.length},}[\\t ]*$`, ); - return lines.slice(1).some((line) => closing.test(line)); + return lines.slice(1).some((line) => { + const normalized = stripMarkdownBlockquoteDepth(line, blockquoteDepth); + return normalized !== undefined && closing.test(normalized); + }); } export interface UnclosedMarkdownFence { @@ -834,10 +906,14 @@ function removeChartLoading(container: HTMLElement): void { container.removeAttribute('aria-busy'); } -function createViewButton(label: string, icon: SVGSVGElement): HTMLButtonElement { +function createViewButton( + label: string, + ariaLabel: string, + icon: SVGSVGElement, +): HTMLButtonElement { const button = document.createElement('button'); button.type = 'button'; - button.setAttribute('aria-label', `Show ${label.toLowerCase()}`); + button.setAttribute('aria-label', ariaLabel); button.setAttribute('title', label); button.className = 'markdown-chart-toggle-button'; button.append(icon); @@ -870,7 +946,11 @@ function chartViewColors(theme: unknown): ChartViewColors { }; } -function createInlineDataTable(data: InlineChartData, colors: ChartViewColors): HTMLElement { +function createInlineDataTable( + data: InlineChartData, + colors: ChartViewColors, + labels: Readonly, +): HTMLElement { const columns = inlineDataColumns(data); const visibleColumns = columns.slice(0, MAX_VISIBLE_DATA_COLUMNS); const visibleRows = data.source.slice(0, MAX_VISIBLE_DATA_ROWS); @@ -887,7 +967,7 @@ function createInlineDataTable(data: InlineChartData, colors: ChartViewColors): if (columns.length === 0 || data.source.length === 0) { const empty = document.createElement('div'); - empty.textContent = 'No data'; + empty.textContent = labels.noData; setStyles(empty, { padding: '24px', textAlign: 'center', opacity: '0.68' }); wrapper.append(empty); return wrapper; @@ -896,7 +976,12 @@ function createInlineDataTable(data: InlineChartData, colors: ChartViewColors): if (visibleColumns.length < columns.length || visibleRows.length < data.source.length) { const notice = document.createElement('div'); notice.className = 'markdown-chart-data-notice'; - notice.textContent = `Showing ${visibleRows.length} of ${data.source.length} rows and ${visibleColumns.length} of ${columns.length} columns.`; + notice.textContent = labels.tableNotice({ + visibleRows: visibleRows.length, + totalRows: data.source.length, + visibleColumns: visibleColumns.length, + totalColumns: columns.length, + }); setStyles(notice, { position: 'sticky', top: '0', @@ -983,6 +1068,7 @@ function createChartView( chartTitle: string | undefined, onShowChart: () => void, theme: unknown, + labels: Readonly, ): ChartView { const colors = chartViewColors(theme); const hadCardClass = container.classList.contains('markdown-chart-card'); @@ -1040,7 +1126,7 @@ function createChartView( const toggle = document.createElement('div'); toggle.className = 'markdown-chart-toggle'; toggle.setAttribute('role', 'group'); - toggle.setAttribute('aria-label', 'View mode'); + toggle.setAttribute('aria-label', labels.viewMode); setStyles(toggle, { display: 'inline-grid', flex: '0 0 auto', @@ -1053,20 +1139,20 @@ function createChartView( borderRadius: '6px', background: colors.background, }); - const chartButton = createViewButton('Chart', createChartIcon()); - const dataButton = createViewButton('Data', createDataIcon()); + const chartButton = createViewButton(labels.chart, labels.showChart, createChartIcon()); + const dataButton = createViewButton(labels.data, labels.showData, createDataIcon()); const chartContainer = document.createElement('div'); chartContainer.className = 'markdown-chart-chart-view'; chartContainer.dataset.markdownChartChartView = 'true'; chartContainer.setAttribute('role', 'img'); - chartContainer.setAttribute('aria-label', 'Chart'); + chartContainer.setAttribute('aria-label', labels.chart); setStyles(chartContainer, { width: 'calc(100% - 20px)', minHeight: 'inherit', margin: '8px 10px', background: colors.background, }); - const dataContainer = createInlineDataTable(data, colors); + const dataContainer = createInlineDataTable(data, colors, labels); dataContainer.hidden = true; const selectedBackground = 'var(--markdown-chart-accent, #0033ff)'; const selectedForeground = 'var(--markdown-chart-accent-foreground, var(--markdown-chart-background, #ffffff))'; @@ -1187,6 +1273,7 @@ export class ChartController { const inlineData = materialized.data?.kind === 'inline' ? materialized.data : undefined; const chartTitle = prepared.renderer.getTitle?.(materialized.parsed)?.trim() || undefined; + const labels = resolveMarkdownChartLabels(request.labels); const view = inlineData ? createChartView( container, @@ -1194,6 +1281,7 @@ export class ChartController { chartTitle, () => this.#handle?.resize?.(), request.theme, + labels, ) : undefined; this.#view = view; diff --git a/packages/core/test/core.test.ts b/packages/core/test/core.test.ts index 70affd3..137a16a 100644 --- a/packages/core/test/core.test.ts +++ b/packages/core/test/core.test.ts @@ -517,6 +517,73 @@ describe('ChartController', () => { expect(dispose).toHaveBeenCalledOnce(); }); + it('uses host-provided labels for chart and data UI', async () => { + const registry = new ChartRendererRegistry().register({ + id: 'test', + parse: (spec) => spec, + mount() {}, + }); + const controller = new ChartController(registry); + const element = document.createElement('div'); + const dimensions = Array.from({ length: 51 }, (_, index) => `column-${index}`); + const source = Array.from({ length: 501 }, (_, rowIndex) => ( + dimensions.map((_, columnIndex) => `${rowIndex}:${columnIndex}`) + )); + const labels = { + chartUnavailable: '图表不可用', + viewMode: '视图模式', + chart: '图表', + data: '数据', + showChart: '显示图表', + showData: '显示数据', + noData: '暂无数据', + tableNotice: ({ + visibleRows, + totalRows, + visibleColumns, + totalColumns, + }: { + visibleRows: number; + totalRows: number; + visibleColumns: number; + totalColumns: number; + }) => `显示 ${visibleRows}/${totalRows} 行,${visibleColumns}/${totalColumns} 列`, + }; + + await controller.render(element, { + language: 'markdown-chart', + source: JSON.stringify({ + version: 1, + renderer: 'test', + data: { kind: 'inline', dimensions, source }, + spec: {}, + }), + labels, + }); + + expect(element.querySelector('[role="group"]')?.getAttribute('aria-label')).toBe('视图模式'); + expect(element.querySelector('[role="img"]')?.getAttribute('aria-label')).toBe('图表'); + expect(element.querySelector('button[aria-label="显示图表"]')?.title) + .toBe('图表'); + expect(element.querySelector('button[aria-label="显示数据"]')?.title) + .toBe('数据'); + expect(element.querySelector('.markdown-chart-data-notice')?.textContent) + .toBe('显示 500/501 行,50/51 列'); + + await controller.render(element, { + language: 'markdown-chart', + source: JSON.stringify({ + version: 1, + renderer: 'test', + data: { kind: 'inline', source: [] }, + spec: {}, + }), + labels, + }); + expect(element.querySelector('.markdown-chart-data-view')?.textContent).toBe('暂无数据'); + controller.dispose(); + }); + it('reads the card title after materialization and omits empty titles', async () => { const registry = new ChartRendererRegistry().register({ id: 'test', @@ -658,6 +725,14 @@ describe('isMarkdownFenceClosed', () => { expect(isMarkdownFenceClosed('```markdown-chart\n{}')).toBe(false); expect(isMarkdownFenceClosed('````markdown-chart\n{}\n```')).toBe(false); }); + + it('recognizes closed fences inside blockquotes', () => { + expect(isMarkdownFenceClosed('> ```markdown-chart\n> {}\n> ```')).toBe(true); + expect(isMarkdownFenceClosed('> > ~~~markdown-chart\n> > {}\n> > ~~~')).toBe(true); + expect(isMarkdownFenceClosed('> ```markdown-chart\n> {}')).toBe(false); + expect(isMarkdownFenceClosed('> ```markdown-chart\n> {}\n```')).toBe(false); + expect(isMarkdownFenceClosed('> > ```markdown-chart\n> > {}\n> ```')).toBe(false); + }); }); describe('findUnclosedMarkdownFence', () => { diff --git a/packages/markdown-it/README.md b/packages/markdown-it/README.md index 4b40140..aca4029 100644 --- a/packages/markdown-it/README.md +++ b/packages/markdown-it/README.md @@ -25,4 +25,5 @@ Streaming hosts can create the render environment with block then reports `complete`; closed blocks can mount immediately while an unterminated tail block remains pending. Pending placeholders include the package loading indicator; set the plugin `loadingLabel` option to localize its -text. +text. Set the shared `labels` option to localize the placeholder's chart +accessibility label. diff --git a/packages/markdown-it/src/index.ts b/packages/markdown-it/src/index.ts index 0ee2a26..b9c5396 100644 --- a/packages/markdown-it/src/index.ts +++ b/packages/markdown-it/src/index.ts @@ -2,7 +2,9 @@ import type MarkdownIt from 'markdown-it'; import { createMarkdownChartLoadingMarkup, MARKDOWN_CHART_LANGUAGE, + resolveMarkdownChartLabels, type ChartRendererRegistry, + type MarkdownChartLabelOverrides, } from '@datafe-open/markdown-chart'; export const MARKDOWN_CHART_ENV_KEY = 'markdownChart' as const; @@ -32,6 +34,7 @@ export interface MarkdownChartPluginOptions { readonly idPrefix?: string; readonly placeholderClass?: string; readonly loadingLabel?: string; + readonly labels?: MarkdownChartLabelOverrides; } export interface CreateMarkdownChartEnvironmentOptions { @@ -89,6 +92,7 @@ function fenceTokenIsClosed(token: { readonly content: string; readonly map: [nu export function markdownChartPlugin(md: MarkdownIt, options: MarkdownChartPluginOptions = {}): void { const idPrefix = options.idPrefix ?? 'markdown-chart'; const placeholderClass = options.placeholderClass ?? 'markdown-chart-placeholder'; + const chartLabel = md.utils.escapeHtml(resolveMarkdownChartLabels(options.labels).chart); if (!SAFE_TOKEN.test(idPrefix)) { throw new TypeError('idPrefix must contain only letters, numbers, underscores, and hyphens'); } @@ -133,6 +137,6 @@ export function markdownChartPlugin(md: MarkdownIt, options: MarkdownChartPlugin const loading = complete ? '' : createMarkdownChartLoadingMarkup(options.loadingLabel); - return `
${loading}
\n`; + return `
${loading}
\n`; }; } diff --git a/packages/markdown-it/test/markdown-it.test.ts b/packages/markdown-it/test/markdown-it.test.ts index c8d0a04..c42e3db 100644 --- a/packages/markdown-it/test/markdown-it.test.ts +++ b/packages/markdown-it/test/markdown-it.test.ts @@ -26,6 +26,14 @@ describe('markdownChartPlugin', () => { }]); }); + it('uses and escapes the host-provided chart label', () => { + const md = new MarkdownIt({ html: false }).use(markdownChartPlugin, { + labels: { chart: '图表 "预览"' }, + }); + const html = md.render('```markdown-chart\n{}\n```', {}); + expect(html).toContain('aria-label="图表 "预览""'); + }); + it('marks only an unterminated chart fence as incomplete while streaming', () => { const md = new MarkdownIt({ html: false }).use(markdownChartPlugin, { loadingLabel: '绘制图表中…', diff --git a/packages/react/README.md b/packages/react/README.md index 575b5fa..9a1abc7 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -28,6 +28,9 @@ direct `ReactMarkdown` child, so the usual advanced integration needs no extra source prop. Pending and asynchronously mounting charts show the package loading state; pass `loadingLabel` to `MarkdownChart`, `MarkdownChartProvider`, or an individual `MarkdownChartBlock` to localize its text. +Pass `labels` to the same APIs to localize the Chart/Data controls, +accessibility labels, empty/truncated data messages, and the +`Chart unavailable` error fallback. New legacy ChatBI integrations should create one `createLegacySandboxClient` per authenticated principal lifecycle, bind `{ sessionId, requestId, phase, diff --git a/packages/react/src/index.tsx b/packages/react/src/index.tsx index 465bad2..4e8eeb6 100644 --- a/packages/react/src/index.tsx +++ b/packages/react/src/index.tsx @@ -17,6 +17,8 @@ import { ChartRendererRegistry, isMarkdownFenceClosed, MARKDOWN_CHART_LANGUAGE, + resolveMarkdownChartLabels, + type MarkdownChartLabelOverrides, } from '@datafe-open/markdown-chart'; import { createEChartsRenderer, @@ -34,6 +36,7 @@ interface MarkdownChartContextValue { readonly streaming: boolean; readonly source: string | undefined; readonly loadingLabel: string | undefined; + readonly labels: MarkdownChartLabelOverrides | undefined; readonly onError: MarkdownChartReactErrorHandler | undefined; } @@ -46,6 +49,7 @@ export interface MarkdownChartProviderProps { /** Optional when the direct child already receives the Markdown as children. */ readonly source?: string; readonly loadingLabel?: string; + readonly labels?: MarkdownChartLabelOverrides; readonly onError?: MarkdownChartReactErrorHandler; readonly children: ReactNode; } @@ -65,6 +69,7 @@ export function MarkdownChartProvider(props: MarkdownChartProviderProps): ReactE streaming: props.streaming ?? false, source, loadingLabel: props.loadingLabel, + labels: props.labels, onError: props.onError, }), [ props.registry, @@ -72,6 +77,7 @@ export function MarkdownChartProvider(props: MarkdownChartProviderProps): ReactE props.streaming, source, props.loadingLabel, + props.labels, props.onError, ]); return createElement(MarkdownChartContext.Provider, { value }, props.children); @@ -82,6 +88,7 @@ export interface MarkdownChartBlockProps { readonly source: string; readonly streaming?: boolean; readonly loadingLabel?: string; + readonly labels?: MarkdownChartLabelOverrides; readonly className?: string; readonly style?: CSSProperties; } @@ -94,6 +101,8 @@ export function MarkdownChartBlock(props: MarkdownChartBlockProps): ReactElement const containerRef = useRef(null); const streaming = props.streaming ?? configuration.streaming; const loadingLabel = props.loadingLabel ?? configuration.loadingLabel; + const labels = props.labels ?? configuration.labels; + const resolvedLabels = useMemo(() => resolveMarkdownChartLabels(labels), [labels]); useEffect(() => { const container = containerRef.current; @@ -110,13 +119,14 @@ export function MarkdownChartBlock(props: MarkdownChartBlockProps): ReactElement ...(loadingLabel !== undefined ? { loadingLabel } : {}), + ...(labels !== undefined ? { labels } : {}), }).catch((error: unknown) => { if (disposed) { return; } container.classList.add('markdown-chart-error'); container.setAttribute('role', 'alert'); - container.textContent = 'Chart unavailable'; + container.textContent = resolvedLabels.chartUnavailable; configuration.onError?.(error, { language: props.language, source: props.source, @@ -131,6 +141,8 @@ export function MarkdownChartBlock(props: MarkdownChartBlockProps): ReactElement configuration.theme, configuration.onError, loadingLabel, + labels, + resolvedLabels, props.language, props.source, streaming, @@ -145,7 +157,7 @@ export function MarkdownChartBlock(props: MarkdownChartBlockProps): ReactElement ref: containerRef, className, style: props.style, - 'aria-label': 'Chart', + 'aria-label': resolvedLabels.chart, 'aria-busy': streaming || undefined, 'data-markdown-chart-complete': String(!streaming), }); @@ -174,7 +186,12 @@ function isCompleteChartNode(source: string | undefined, node: PositionedNode | if (source === undefined || start === undefined || end === undefined) { return false; } - return isMarkdownFenceClosed(source.slice(start, end)) + const lineStart = source.lastIndexOf('\n', Math.max(0, start - 1)) + 1; + const leadingContainer = source.slice(lineStart, start); + const fragmentStart = /^(?: {0,3}>[ \t]?)+$/.test(leadingContainer) + ? lineStart + : start; + return isMarkdownFenceClosed(source.slice(fragmentStart, end)) || source.slice(end).trim().length > 0; } @@ -226,6 +243,7 @@ export interface MarkdownChartProps { readonly theme?: unknown; readonly streaming?: boolean; readonly loadingLabel?: string; + readonly labels?: MarkdownChartLabelOverrides; readonly onError?: MarkdownChartReactErrorHandler; readonly chartClassName?: string; readonly chartStyle?: CSSProperties; @@ -251,6 +269,7 @@ export function MarkdownChart(props: MarkdownChartProps): ReactElement { ...(props.theme !== undefined ? { theme: props.theme } : {}), ...(props.streaming !== undefined ? { streaming: props.streaming } : {}), ...(props.loadingLabel !== undefined ? { loadingLabel: props.loadingLabel } : {}), + ...(props.labels !== undefined ? { labels: props.labels } : {}), ...(props.onError ? { onError: props.onError } : {}), }, ); diff --git a/packages/react/test/react-streaming.test.tsx b/packages/react/test/react-streaming.test.tsx index 9672236..813a9eb 100644 --- a/packages/react/test/react-streaming.test.tsx +++ b/packages/react/test/react-streaming.test.tsx @@ -176,6 +176,62 @@ describe('MarkdownChart streaming lifecycle', () => { await act(async () => root.unmount()); }); + it('renders a closed chart fence inside a streaming blockquote', async () => { + const mount = vi.fn(); + const registry = new ChartRendererRegistry().register({ + id: 'test', + parse: (spec) => spec, + mount() { + mount(); + }, + }); + const source = closedChart() + .split('\n') + .map((line) => `> ${line}`) + .join('\n'); + const container = document.createElement('div'); + const root = createRoot(container); + + await act(async () => { + root.render(); + }); + + await vi.waitFor(() => expect(mount).toHaveBeenCalledOnce()); + expect(container.querySelector('.markdown-chart-streaming')).toBeNull(); + await act(async () => root.unmount()); + }); + + it('uses host-provided labels for React placeholders and errors', async () => { + const registry = new ChartRendererRegistry().register({ + id: 'test', + aliases: ['test'], + parse() { + throw new Error('invalid'); + }, + mount() {}, + }); + const source = '```test\n{}\n```'; + const labels = { + chart: '图表', + chartUnavailable: '图表不可用', + }; + const container = document.createElement('div'); + const root = createRoot(container); + + await act(async () => { + root.render( + + {source} + , + ); + }); + + const placeholder = container.querySelector('.markdown-chart-placeholder'); + await vi.waitFor(() => expect(placeholder?.textContent).toBe('图表不可用')); + expect(placeholder?.getAttribute('aria-label')).toBe('图表'); + await act(async () => root.unmount()); + }); + it('shows materialized legacy data in simple and advanced integrations', async () => { const source = '```echarts-chatbi_query_8660210443288600709-0\nvar option = {};\n//#end\n```'; const resolveLegacyArtifactContent = async () => 'name,value\nA,10\nB,20\n'; diff --git a/packages/vue/README.md b/packages/vue/README.md index cab7236..22d50ed 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -27,6 +27,10 @@ immediately, and their existing DOM and chart controller are reused as later Markdown is appended. Only the active unterminated tail fence waits. Pending and asynchronously mounting charts show the package loading state; pass `:loading-label="..."` to localize its text. +Pass `:labels="labels"` to localize the Chart/Data controls, accessibility +labels, empty/truncated data messages, and the `Chart unavailable` error +fallback. The same option is available on `useMarkdownChart()` and +`mountMarkdownChartBlocks()`. New legacy ChatBI integrations should create one `createLegacySandboxClient` per authenticated principal lifecycle, compute a binding from `{ sessionId, diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 7c966c2..fdcbf12 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -2,6 +2,8 @@ import MarkdownIt from 'markdown-it'; import { ChartController, ChartRendererRegistry, + resolveMarkdownChartLabels, + type MarkdownChartLabelOverrides, } from '@datafe-open/markdown-chart'; import { createEChartsRenderer, @@ -42,6 +44,7 @@ export interface MountMarkdownChartBlocksOptions { readonly streaming?: boolean; readonly minHeight?: string | number | undefined; readonly loadingLabel?: string; + readonly labels?: MarkdownChartLabelOverrides; readonly onError?: MarkdownChartVueErrorHandler; } @@ -54,6 +57,7 @@ interface MountedMarkdownChartEntry { readonly block: MarkdownChartBlock; readonly registry: ChartRendererRegistry; readonly theme: unknown; + readonly labels: MarkdownChartLabelOverrides | undefined; readonly element: HTMLElement; readonly controller: ChartController; ready: Promise; @@ -115,6 +119,7 @@ class MarkdownChartMountManager { const reusable = existing && existing.registry === registry && Object.is(existing.theme, options.theme) + && existing.labels === options.labels && existing.block.language === block.language && existing.block.rawLanguage === block.rawLanguage && existing.block.source === block.source; @@ -136,6 +141,7 @@ class MarkdownChartMountManager { block, registry, theme: options.theme, + labels: options.labels, element: placeholder, controller, ready: Promise.resolve(), @@ -149,6 +155,7 @@ class MarkdownChartMountManager { ...(options.loadingLabel !== undefined ? { loadingLabel: options.loadingLabel } : {}), + ...(options.labels !== undefined ? { labels: options.labels } : {}), }).catch((error: unknown) => { if (this.#entries.get(block.id) !== entry) { return; @@ -157,7 +164,7 @@ class MarkdownChartMountManager { controller.dispose(); placeholder.classList.add('markdown-chart-error'); placeholder.setAttribute('role', 'alert'); - placeholder.textContent = 'Chart unavailable'; + placeholder.textContent = resolveMarkdownChartLabels(options.labels).chartUnavailable; options.onError?.(error, block); }); tasks.push(entry.ready); @@ -200,6 +207,7 @@ export interface UseMarkdownChartOptions { readonly streaming?: MaybeRef; readonly minHeight?: MaybeRef; readonly loadingLabel?: MaybeRef; + readonly labels?: MaybeRef; readonly onError?: MarkdownChartVueErrorHandler; } @@ -226,6 +234,9 @@ export function useMarkdownChart(options: UseMarkdownChartOptions): UseMarkdownC const currentLoadingLabel = (): string | undefined => options.loadingLabel === undefined ? undefined : toValue(options.loadingLabel); + const currentLabels = (): MarkdownChartLabelOverrides | undefined => options.labels === undefined + ? undefined + : toValue(options.labels); const refresh = async (): Promise => { const localGeneration = ++generation; @@ -240,12 +251,14 @@ export function useMarkdownChart(options: UseMarkdownChartOptions): UseMarkdownC return; } const loadingLabel = currentLoadingLabel(); + const labels = currentLabels(); await manager.reconcile(container.value, blocks, toValue(options.registry), { theme: currentTheme(), minHeight: currentMinHeight(), ...(loadingLabel !== undefined ? { loadingLabel } : {}), + ...(labels !== undefined ? { labels } : {}), ...(options.onError ? { onError: options.onError } : {}), }); }; @@ -259,6 +272,7 @@ export function useMarkdownChart(options: UseMarkdownChartOptions): UseMarkdownC currentStreaming, currentMinHeight, currentLoadingLabel, + currentLabels, ], () => { void refresh(); }, { flush: 'post' }, @@ -292,6 +306,10 @@ export const MarkdownChart = defineComponent({ theme: { type: null as unknown as PropType, required: false }, streaming: { type: Boolean, default: false }, loadingLabel: { type: String, required: false }, + labels: { + type: Object as PropType, + required: false, + }, minHeight: { type: [String, Number] as PropType, default: 360, @@ -312,6 +330,7 @@ export const MarkdownChart = defineComponent({ ...(props.loadingLabel !== undefined ? { loadingLabel: props.loadingLabel } : {}), + ...(props.labels !== undefined ? { labels: props.labels } : {}), }), ); const markdownIt = computed(() => props.markdownIt ?? automaticMarkdownIt.value); @@ -323,6 +342,7 @@ export const MarkdownChart = defineComponent({ streaming: toRef(props, 'streaming'), minHeight: toRef(props, 'minHeight'), loadingLabel: toRef(props, 'loadingLabel'), + labels: toRef(props, 'labels'), onError: (error, block) => props.onError?.(error, block), }); return () => h('div', { diff --git a/packages/vue/test/vue.test.ts b/packages/vue/test/vue.test.ts index c2600c2..4c0b541 100644 --- a/packages/vue/test/vue.test.ts +++ b/packages/vue/test/vue.test.ts @@ -99,6 +99,38 @@ describe('MarkdownChart reactive object props', () => { app.unmount(); }); + it('passes host-provided labels through Vue and markdown-it', async () => { + const source = '```markdown-chart\n{"version":1,"renderer":"test","data":{"kind":"inline","source":[["A",1]]},"spec":{}}\n```'; + const registry = new ChartRendererRegistry().register(testRenderer(() => {})); + const labels = { + chart: '图表', + data: '数据', + viewMode: '视图模式', + showChart: '显示图表', + showData: '显示数据', + }; + const app = createApp(defineComponent({ + setup() { + return () => h(MarkdownChart, { source, registry, labels }); + }, + })); + const root = document.createElement('div'); + app.mount(root); + + await vi.waitFor(() => { + expect(root.querySelector('button[aria-label="显示数据"]')).not.toBeNull(); + }); + expect(root.querySelector('.markdown-chart-toggle')?.getAttribute('aria-label')) + .toBe('视图模式'); + expect(root.querySelector('button[aria-label="显示图表"]')?.getAttribute('title')) + .toBe('图表'); + expect(root.querySelector('button[aria-label="显示数据"]')?.getAttribute('title')) + .toBe('数据'); + expect(root.querySelector('.markdown-chart-placeholder')?.getAttribute('aria-label')) + .toBe('图表'); + app.unmount(); + }); + it('leaves the removed echarts shorthand as code', async () => { const language = 'echarts'; const source = `\`\`\`${language}\n{"series":[]}\n\`\`\``; @@ -270,10 +302,11 @@ describe('MarkdownChart reactive object props', () => { }); const markdownIt = new MarkdownIt().use(markdownChartPlugin, { registry }); const source = '```test\n{}\n```'; + const labels = { chartUnavailable: '图表不可用' }; let state: UseMarkdownChartResult | undefined; const app = createApp(defineComponent({ setup() { - const chartState = useMarkdownChart({ source, markdownIt, registry }); + const chartState = useMarkdownChart({ source, markdownIt, registry, labels }); state = chartState; return () => h('div', { ref: chartState.container, @@ -289,7 +322,7 @@ describe('MarkdownChart reactive object props', () => { expect(mount).toHaveBeenCalledOnce(); expect(placeholder?.classList.contains('markdown-chart-error')).toBe(true); expect(placeholder?.getAttribute('role')).toBe('alert'); - expect(placeholder?.textContent).toBe('Chart unavailable'); + expect(placeholder?.textContent).toBe('图表不可用'); }); await state?.refresh(); @@ -299,7 +332,7 @@ describe('MarkdownChart reactive object props', () => { expect(placeholder?.dataset.mounted).toBe('true'); expect(placeholder?.classList.contains('markdown-chart-error')).toBe(false); expect(placeholder?.hasAttribute('role')).toBe(false); - expect(placeholder?.textContent).not.toContain('Chart unavailable'); + expect(placeholder?.textContent).not.toContain('图表不可用'); }); app.unmount(); });