Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/friendly-charts-localize.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<MarkdownChart
source={source}
labels={{
chartUnavailable: '图表不可用',
viewMode: '视图模式',
chart: '图表',
data: '数据',
showChart: '显示图表',
showData: '显示数据',
noData: '暂无数据',
tableNotice: ({ visibleRows, totalRows, visibleColumns, totalColumns }) =>
`显示 ${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:
Expand Down
25 changes: 24 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,30 @@ defineProps<{ source: string }>();
<MarkdownChart :source="source" :streaming="isStreaming" />
```

已经闭合的图表代码块会立即渲染;后续文本继续到达时,已挂载的图表实例会保持不变。只有末尾仍未闭合、正在输出的代码块会等待更多输入。等待中的代码块以及异步解析、取数和运行时挂载阶段会显示内置 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
<MarkdownChart
source={source}
labels={{
chartUnavailable: '图表不可用',
viewMode: '视图模式',
chart: '图表',
data: '数据',
showChart: '显示图表',
showData: '显示数据',
noData: '暂无数据',
tableNotice: ({ visibleRows, totalRows, visibleColumns, totalColumns }) =>
`显示 ${visibleRows}/${totalRows} 行,${visibleColumns}/${totalColumns} 列`,
}}
/>
```

`MarkdownChartProvider`、`MarkdownChartBlock`、Vue composable / 挂载工具和 markdown-it 插件都接受同一个 `MarkdownChartLabelOverrides` 类型;没有提供的文案继续使用英文默认值。

## 高级配置

Expand Down
6 changes: 6 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
112 changes: 100 additions & 12 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MarkdownChartLabels>;

export const DEFAULT_MARKDOWN_CHART_LABELS: Readonly<MarkdownChartLabels> = 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<MarkdownChartLabels> {
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
Expand All @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -870,7 +946,11 @@ function chartViewColors(theme: unknown): ChartViewColors {
};
}

function createInlineDataTable(data: InlineChartData, colors: ChartViewColors): HTMLElement {
function createInlineDataTable(
data: InlineChartData,
colors: ChartViewColors,
labels: Readonly<MarkdownChartLabels>,
): HTMLElement {
const columns = inlineDataColumns(data);
const visibleColumns = columns.slice(0, MAX_VISIBLE_DATA_COLUMNS);
const visibleRows = data.source.slice(0, MAX_VISIBLE_DATA_ROWS);
Expand All @@ -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;
Expand All @@ -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',
Expand Down Expand Up @@ -983,6 +1068,7 @@ function createChartView(
chartTitle: string | undefined,
onShowChart: () => void,
theme: unknown,
labels: Readonly<MarkdownChartLabels>,
): ChartView {
const colors = chartViewColors(theme);
const hadCardClass = container.classList.contains('markdown-chart-card');
Expand Down Expand Up @@ -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',
Expand All @@ -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))';
Expand Down Expand Up @@ -1187,13 +1273,15 @@ 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,
inlineData,
chartTitle,
() => this.#handle?.resize?.(),
request.theme,
labels,
)
: undefined;
this.#view = view;
Expand Down
75 changes: 75 additions & 0 deletions packages/core/test/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLButtonElement>('button[aria-label="显示图表"]')?.title)
.toBe('图表');
expect(element.querySelector<HTMLButtonElement>('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',
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading