Compare commits

...

4 Commits

Author SHA1 Message Date
dependabot[bot]
db9fe42b1d chore(deps): bump github.com/jackc/pgx/v5 from 5.8.0 to 5.9.2
Bumps [github.com/jackc/pgx/v5](https://github.com/jackc/pgx) from 5.8.0 to 5.9.2.
- [Changelog](https://github.com/jackc/pgx/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jackc/pgx/compare/v5.8.0...v5.9.2)

---
updated-dependencies:
- dependency-name: github.com/jackc/pgx/v5
  dependency-version: 5.9.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-22 21:36:53 +00:00
Abhishek Kumar Singh
30d3f754b5 feat: markdown renderer (#10682)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* chore: custom notifiers in alert manager

* chore: lint fixs

* chore: fix email linter

* chore: added tracing to msteamsv2 notifier

* feat: alert manager template to template title and notification body

* chore: updated test name + code for timeout errors

* chore: added utils for using variables with $ notation

* chore: exposed templates for alertmanager types

* feat: added preprocessor for alert templater

* chore: hooked preProcess function in expandTitle and body, added labels and annotations in alertdata

* chore: fix lint issues

* chore: added handling for missing variable used in template

* feat: converted alerttemplater to interface and updated tests

* refactor: added extractCommonKV instead of 2 different functions

* test: fix preprocessor test case

* feat: added support for  and  in templating

* chore: lint fix

* chore: renamed the interface

* chore: added test for missing function

* refactor: test case and sb related changed

* refactor: comments and test improvements

* chore: lint fix

* chore: updated comments

* feat: added basic html markdown templater

* chore: updated newline to markdown format

* feat: slack blockkit renderer using goldmark

* test: added test for html rendering

* feat: integrated slack blockit in markdownrenderer package and removed plaintext format

* chore: updated br with new line in test and logs added

* refactor: review comments

* refactor: lint fixes

* chore: updated licenses for notifiers

* chore: updated email notifier from upstream

* feat: return single templating result from  with flag for template type

* fix: variables with symbols in template

* feat: slack mrkdwn renderer

* feat: custom raw html renderer to escape <no value>

* chore: integrated slack mrkdwn renderer and added NoOp formatter

* chore: removed notifier test files

* fix: concurrent rendering in markdown renderer

* refactor: changes as per internal review

* chore: lint issue

* chore: removed special handling for softline break

* refactor: removed logger as markdown renderer dependency

* refactor: changed markdown renderer from interface to package-level functions

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-04-22 21:22:45 +00:00
Abhi kumar
d5dcdf382c chore: added changes for pinning tooltip with a shortcut key (#10953)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: added changes for pinning tooltip with a shortcut key

* chore: updated tooltipplugin tests

* chore: added support for onclick in tooltip

* chore: fixed minor issue

* chore: updated tooltip pinning desings

* chore: minor changes

* chore: updated failing test

* chore: updated pr review changes

* chore: fixed tooltip tests

* chore: fixed module css issues

* chore: pr review fixes

* chore: replaced kbd component with component from signozui

* chore: updated the tokens

* chore: updated tokens

* chore: updated pinned color

* chore: updated footer styles

* chore: fixed linter issue
2026-04-22 17:37:38 +00:00
aniketio-ctrl
ce5e3e7943 feat(billing): increase zeus http client timeout (#11061)
* feat(billing): add zeus put meters api

* feat(billing): add zeus put meters api

* feat(billing): increase zeuss http client timeour
2026-04-22 17:27:39 +00:00
43 changed files with 3384 additions and 281 deletions

View File

@@ -7,6 +7,7 @@ import (
"io"
"net/http"
"net/url"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
@@ -37,6 +38,7 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
providerSettings.MeterProvider,
client.WithRequestResponseLog(true),
client.WithRetryCount(3),
client.WithTimeout(30*time.Second),
)
if err != nil {
return nil, err

View File

@@ -52,7 +52,7 @@
"@signozhq/design-tokens": "2.1.4",
"@signozhq/icons": "0.1.0",
"@signozhq/resizable": "0.0.2",
"@signozhq/ui": "0.0.9",
"@signozhq/ui": "0.0.10",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
"@uiw/codemirror-theme-copilot": "4.23.11",
@@ -266,4 +266,4 @@
"tmp": "0.2.4",
"vite": "npm:rolldown-vite@7.3.1"
}
}
}

View File

@@ -37,6 +37,7 @@ export default function BarChart(props: BarChartProps): JSX.Element {
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
isStackedBarChart: isStackedBarChart,
canPinTooltip: rest.canPinTooltip,
};
return <BarChartTooltip {...tooltipProps} />;
},
@@ -46,6 +47,7 @@ export default function BarChart(props: BarChartProps): JSX.Element {
rest.yAxisUnit,
rest.decimalPrecision,
isStackedBarChart,
rest.canPinTooltip,
],
);

View File

@@ -25,6 +25,8 @@ export default function ChartWrapper({
showTooltip = true,
showLegend = true,
canPinTooltip = false,
pinKey,
onClick,
syncMode,
syncKey,
onDestroy = noop,
@@ -101,6 +103,8 @@ export default function ChartWrapper({
<TooltipPlugin
config={config}
canPinTooltip={canPinTooltip}
pinKey={pinKey}
onClick={onClick}
syncMode={syncMode}
maxWidth={Math.max(
TOOLTIP_MIN_WIDTH,

View File

@@ -26,10 +26,11 @@ export default function Histogram(props: HistogramChartProps): JSX.Element {
...props,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
canPinTooltip: rest.canPinTooltip,
};
return <HistogramTooltip {...tooltipProps} />;
},
[customTooltip, rest.yAxisUnit, rest.decimalPrecision],
[customTooltip, rest.yAxisUnit, rest.decimalPrecision, rest.canPinTooltip],
);
return (

View File

@@ -21,10 +21,17 @@ export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
timezone: rest.timezone,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
canPinTooltip: rest.canPinTooltip,
};
return <TimeSeriesTooltip {...tooltipProps} />;
},
[customTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
[
customTooltip,
rest.timezone,
rest.yAxisUnit,
rest.decimalPrecision,
rest.canPinTooltip,
],
);
return (

View File

@@ -13,6 +13,12 @@ interface BaseChartProps {
showTooltip?: boolean;
showLegend?: boolean;
canPinTooltip?: boolean;
/** Key that pins the tooltip while hovering. Defaults to DEFAULT_PIN_TOOLTIP_KEY ('l'). */
pinKey?: string;
/** Called when the user clicks the uPlot overlay. Receives resolved click data. */
onClick?: (clickData: TooltipClickData) => void;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
pinnedTooltipElement?: (clickData: TooltipClickData) => React.ReactNode;
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
'data-testid'?: string;

View File

@@ -121,6 +121,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
}}
canPinTooltip
plotRef={onPlotRef}
onDestroy={onPlotDestroy}
data={chartData as uPlot.AlignedData}

View File

@@ -89,6 +89,7 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
onDestroy={(): void => {
uPlotRef.current = null;
}}
canPinTooltip
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
isQueriesMerged={widget.mergeAllActiveQueries}

View File

@@ -112,6 +112,7 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
}}
canPinTooltip
timezone={timezone}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}

View File

@@ -1,73 +1,22 @@
.uplot-tooltip-container {
.container {
font-family: 'Inter';
font-size: 12px;
background: var(--bg-ink-300);
background: var(--l2-background);
-webkit-font-smoothing: antialiased;
color: var(--bg-vanilla-100);
color: var(--l2-foreground);
border-radius: 6px;
border: 1px solid var(--bg-ink-100);
border: 1px solid var(--l2-border);
display: flex;
flex-direction: column;
gap: 8px;
&.lightMode {
background: var(--bg-vanilla-100);
color: var(--bg-ink-500);
border: 1px solid var(--bg-vanilla-300);
.uplot-tooltip-list {
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-400);
}
}
.uplot-tooltip-divider {
background-color: var(--bg-vanilla-300);
}
&.pinned {
border-color: var(--ring);
}
.uplot-tooltip-header-container {
padding: 1rem 1rem 0 1rem;
display: flex;
flex-direction: column;
gap: 8px;
&:last-child {
padding-bottom: 1rem;
}
.uplot-tooltip-header {
font-size: 13px;
font-weight: 500;
}
}
.uplot-tooltip-divider {
.divider {
width: 100%;
height: 1px;
background-color: var(--bg-ink-100);
}
.uplot-tooltip-list {
// Virtuoso absolutely positions its item rows; left: 0 prevents accidental
// horizontal offset when the scroller has padding or transform applied.
div[data-viewport-type='element'] {
left: 0;
box-sizing: border-box;
padding: 4px 12px 4px 16px;
}
&::-webkit-scrollbar {
width: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-100);
border-radius: 0.5rem;
}
background-color: var(--l2-border);
}
}

View File

@@ -1,71 +1,28 @@
import { useMemo, useState } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { useMemo } from 'react';
import cx from 'classnames';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useTimezone } from 'providers/Timezone';
import { TooltipProps } from '../types';
import TooltipItem from './components/TooltipItem/TooltipItem';
import TooltipFooter from './components/TooltipFooter/TooltipFooter';
import TooltipHeader from './components/TooltipHeader/TooltipHeader';
import TooltipList from './components/TooltipList/TooltipList';
import Styles from './Tooltip.module.scss';
// Fallback per-item height used for the initial size estimate before
// Virtuoso reports the real total height via totalListHeightChanged.
const TOOLTIP_ITEM_HEIGHT = 38;
const LIST_MAX_HEIGHT = 300;
export default function Tooltip({
uPlotInstance,
timezone,
content,
showTooltipHeader = true,
isPinned,
canPinTooltip,
dismiss,
}: TooltipProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const { timezone: userTimezone } = useTimezone();
const [totalListHeight, setTotalListHeight] = useState(0);
const tooltipContent = useMemo(() => content ?? [], [content]);
const resolvedTimezone = timezone?.value ?? userTimezone.value;
const headerTitle = useMemo(() => {
if (!showTooltipHeader) {
return null;
}
const cursorIdx = uPlotInstance.cursor.idx;
if (cursorIdx == null) {
return null;
}
const timestamp = uPlotInstance.data[0]?.[cursorIdx];
if (timestamp == null) {
return null;
}
return dayjs(timestamp * 1000)
.tz(resolvedTimezone)
.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS);
}, [
resolvedTimezone,
uPlotInstance.data,
uPlotInstance.cursor.idx,
showTooltipHeader,
]);
const activeItem = useMemo(
() => tooltipContent.find((item) => item.isActive) ?? null,
[tooltipContent],
);
// Use the measured height from Virtuoso when available; fall back to a
// per-item estimate on the first render. Math.ceil prevents a 1 px
// subpixel rounding gap from triggering a spurious scrollbar.
const virtuosoHeight = useMemo(() => {
return totalListHeight > 0
? Math.ceil(Math.min(totalListHeight, LIST_MAX_HEIGHT))
: Math.min(tooltipContent.length * TOOLTIP_ITEM_HEIGHT, LIST_MAX_HEIGHT);
}, [totalListHeight, tooltipContent.length]);
const showHeader = showTooltipHeader || activeItem != null;
// With a single series the active item is fully represented in the header —
// hide the divider and list to avoid showing a duplicate row.
@@ -74,46 +31,24 @@ export default function Tooltip({
return (
<div
className={cx(Styles.uplotTooltipContainer, !isDarkMode && Styles.lightMode)}
className={cx(Styles.container, isPinned && Styles.pinned)}
data-testid="uplot-tooltip-container"
>
{showHeader && (
<div className={Styles.uplotTooltipHeaderContainer}>
{showTooltipHeader && headerTitle && (
<div
className={Styles.uplotTooltipHeader}
data-testid="uplot-tooltip-header"
>
<span>{headerTitle}</span>
</div>
)}
{activeItem && (
<TooltipItem
item={activeItem}
isItemActive={true}
containerTestId="uplot-tooltip-pinned"
markerTestId="uplot-tooltip-pinned-marker"
contentTestId="uplot-tooltip-pinned-content"
/>
)}
</div>
)}
{showDivider && <span className={Styles.uplotTooltipDivider} />}
{showList && (
<Virtuoso
className={Styles.uplotTooltipList}
data-testid="uplot-tooltip-list"
data={tooltipContent}
style={{ height: virtuosoHeight, width: '100%' }}
totalListHeightChanged={setTotalListHeight}
itemContent={(_, item): JSX.Element => (
<TooltipItem item={item} isItemActive={false} />
)}
<TooltipHeader
uPlotInstance={uPlotInstance}
timezone={timezone}
showTooltipHeader={showTooltipHeader}
isPinned={isPinned}
activeItem={activeItem}
/>
)}
{showDivider && <span className={Styles.divider} />}
{showList && <TooltipList content={tooltipContent} />}
{canPinTooltip && <TooltipFooter isPinned={isPinned} dismiss={dismiss} />}
</div>
);
}

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { VirtuosoMockContext } from 'react-virtuoso';
import userEvent from '@testing-library/user-event';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
@@ -92,7 +93,6 @@ function renderTooltip(props: Partial<TooltipTestProps> = {}): RenderResult {
isPinned: false,
dismiss: jest.fn(),
viaSync: false,
clickData: null,
} as TooltipTestProps;
return render(
@@ -191,3 +191,85 @@ describe('Tooltip', () => {
expect(list).toHaveStyle({ height: '76px' });
});
});
describe('Tooltip footer hint', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseIsDarkMode.mockReturnValue(false);
});
it('renders footer with "Press P to pin the tooltip" hint when not pinned', () => {
renderTooltip({ isPinned: false, canPinTooltip: true });
const footer = screen.getByTestId('uplot-tooltip-footer');
expect(footer).toBeInTheDocument();
expect(footer).toHaveTextContent('Press');
expect(footer).toHaveTextContent('P');
expect(footer).toHaveTextContent('to pin the tooltip');
});
it('renders footer with "Press P or Esc to unpin" hint when pinned', () => {
renderTooltip({ isPinned: true, canPinTooltip: true });
const footer = screen.getByTestId('uplot-tooltip-footer');
expect(footer).toHaveTextContent('Press');
expect(footer).toHaveTextContent('P');
expect(footer).toHaveTextContent('Esc');
expect(footer).toHaveTextContent('to unpin');
});
it('does not render Unpin button when not pinned', () => {
renderTooltip({ isPinned: false, canPinTooltip: true });
expect(screen.queryByTestId('uplot-tooltip-unpin')).not.toBeInTheDocument();
});
it('renders Unpin button when pinned', () => {
renderTooltip({ isPinned: true, canPinTooltip: true });
const unpinBtn = screen.getByTestId('uplot-tooltip-unpin');
expect(unpinBtn).toBeInTheDocument();
expect(unpinBtn).toHaveAttribute('aria-label', 'Unpin tooltip');
});
it('calls dismiss when Unpin button is clicked', async () => {
const dismiss = jest.fn();
renderTooltip({ isPinned: true, canPinTooltip: true, dismiss });
const user = userEvent.setup();
const unpinBtn = screen.getByTestId('uplot-tooltip-unpin');
await user.click(unpinBtn);
expect(dismiss).toHaveBeenCalledTimes(1);
});
it('footer has role="status" for screen reader announcements', () => {
renderTooltip({ canPinTooltip: true });
const footer = screen.getByRole('status');
expect(footer).toBeInTheDocument();
});
});
describe('Tooltip header status pill', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseIsDarkMode.mockReturnValue(false);
});
it('shows Pinned status when pinned and header is visible', () => {
const uPlotInstance = createUPlotInstance(0);
renderTooltip({ uPlotInstance, isPinned: true });
expect(screen.getByText('Pinned')).toBeInTheDocument();
});
it('does not render status pill when showTooltipHeader is false', () => {
const uPlotInstance = createUPlotInstance(0);
renderTooltip({ uPlotInstance, showTooltipHeader: false, isPinned: false });
expect(screen.queryByTestId('uplot-tooltip-status')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,18 @@
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 7px 12px;
border-top: 1px solid var(--l2-border);
background: var(--l2-background);
border-radius: 0 0 6px 6px;
}
.hint {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: var(--l2-foreground);
}

View File

@@ -0,0 +1,58 @@
import { Button } from '@signozhq/ui';
import { Kbd } from '@signozhq/ui';
import { DEFAULT_PIN_TOOLTIP_KEY } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { X } from 'lucide-react';
import Styles from './TooltipFooter.module.scss';
interface TooltipFooterProps {
pinKey?: string;
isPinned: boolean;
dismiss: () => void;
}
export default function TooltipFooter({
pinKey = DEFAULT_PIN_TOOLTIP_KEY,
isPinned,
dismiss,
}: TooltipFooterProps): JSX.Element {
return (
<div
className={Styles.footer}
role="status"
data-testid="uplot-tooltip-footer"
>
<div className={Styles.hint}>
{isPinned ? (
<>
<span>Press</span>
<Kbd active>{pinKey.toUpperCase()}</Kbd>
<span>or</span>
<Kbd active>Esc</Kbd>
<span>to unpin</span>
</>
) : (
<>
<span>Press</span>
<Kbd>{pinKey.toUpperCase()}</Kbd>
<span>to pin the tooltip</span>
</>
)}
</div>
{isPinned && (
<Button
variant="outlined"
color="secondary"
size="sm"
onClick={dismiss}
aria-label="Unpin tooltip"
data-testid="uplot-tooltip-unpin"
>
<X size={10} />
<span>Unpin</span>
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,32 @@
.headerContainer {
padding: 1rem 1rem 0 1rem;
display: flex;
flex-direction: column;
gap: var(--spacing-4);
&:last-child {
padding-bottom: var(--spacing-4);
}
}
.headerRow {
font-size: var(--font-size-sm);
font-weight: 500;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-2);
}
.status {
display: flex;
align-items: center;
gap: var(--spacing-1);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--callout-primary-title);
flex-shrink: 0;
}

View File

@@ -0,0 +1,85 @@
import { useMemo } from 'react';
import cx from 'classnames';
import type { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { Pin } from 'lucide-react';
import { useTimezone } from 'providers/Timezone';
import type uPlot from 'uplot';
import { TooltipContentItem } from '../../../types';
import TooltipItem from '../TooltipItem/TooltipItem';
import Styles from './TooltipHeader.module.scss';
interface TooltipHeaderProps {
uPlotInstance: uPlot;
timezone?: Timezone;
showTooltipHeader: boolean;
isPinned: boolean;
activeItem: TooltipContentItem | null;
}
export default function TooltipHeader({
uPlotInstance,
timezone,
showTooltipHeader,
isPinned,
activeItem,
}: TooltipHeaderProps): JSX.Element {
const { timezone: userTimezone } = useTimezone();
const resolvedTimezone = timezone?.value ?? userTimezone.value;
const headerTitle = useMemo(() => {
if (!showTooltipHeader) {
return null;
}
const cursorIdx = uPlotInstance.cursor.idx;
if (cursorIdx == null) {
return null;
}
const timestamp = uPlotInstance.data[0]?.[cursorIdx];
if (timestamp == null) {
return null;
}
return dayjs(timestamp * 1000)
.tz(resolvedTimezone)
.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS);
}, [
resolvedTimezone,
uPlotInstance.data,
uPlotInstance.cursor.idx,
showTooltipHeader,
]);
return (
<div
className={Styles.headerContainer}
data-testid="uplot-tooltip-header-container"
>
{showTooltipHeader && headerTitle && (
<div className={Styles.headerRow}>
<span>{headerTitle}</span>
{isPinned && (
<div className={cx(Styles.status)} data-testid="uplot-tooltip-status">
<>
<Pin size={12} />
<span>Pinned</span>
</>
</div>
)}
</div>
)}
{activeItem && (
<TooltipItem
item={activeItem}
isItemActive={true}
containerTestId="uplot-tooltip-pinned"
markerTestId="uplot-tooltip-pinned-marker"
contentTestId="uplot-tooltip-pinned-content"
/>
)}
</div>
);
}

View File

@@ -0,0 +1,27 @@
.list {
width: 100%;
:global(div[data-viewport-type='element']) {
left: 0;
box-sizing: border-box;
padding: 4px 12px 4px 16px;
}
&::-webkit-scrollbar {
width: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-100);
border-radius: 0.5rem;
}
}
.listLightMode {
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-400);
}
}

View File

@@ -0,0 +1,48 @@
import { useMemo, useState } from 'react';
import { Virtuoso } from 'react-virtuoso';
import cx from 'classnames';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { TooltipContentItem } from '../../../types';
import TooltipItem from '../TooltipItem/TooltipItem';
import Styles from './TooltipList.module.scss';
// Fallback per-item height before Virtuoso reports the real total.
const TOOLTIP_ITEM_HEIGHT = 38;
const LIST_MAX_HEIGHT = 300;
interface TooltipListProps {
content: TooltipContentItem[];
}
export default function TooltipList({
content,
}: TooltipListProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const [totalListHeight, setTotalListHeight] = useState(0);
// Use the measured height from Virtuoso when available; fall back to a
// per-item estimate on first render. Math.ceil prevents a 1 px
// subpixel rounding gap from triggering a spurious scrollbar.
const height = useMemo(
() =>
totalListHeight > 0
? Math.ceil(Math.min(totalListHeight, LIST_MAX_HEIGHT))
: Math.min(content.length * TOOLTIP_ITEM_HEIGHT, LIST_MAX_HEIGHT),
[totalListHeight, content.length],
);
return (
<Virtuoso
className={cx(Styles.list, !isDarkMode && Styles.listLightMode)}
data-testid="uplot-tooltip-list"
data={content}
style={{ height }}
totalListHeightChanged={setTotalListHeight}
itemContent={(_, item): JSX.Element => (
<TooltipItem item={item} isItemActive={false} />
)}
/>
);
}

View File

@@ -62,6 +62,7 @@ export interface TooltipRenderArgs {
export interface BaseTooltipProps {
showTooltipHeader?: boolean;
canPinTooltip?: boolean;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
content?: TooltipContentItem[];

View File

@@ -1,4 +1,4 @@
.tooltip-plugin-container {
.tooltipPluginContainer {
top: 0;
left: 0;
width: 100%;
@@ -10,13 +10,9 @@
transform: translate(-1000px, -1000px); // hide the tooltip initially
opacity: 0;
pointer-events: none;
&.pinned {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
&.visible {
opacity: 1;
pointer-events: all;
}
}
.visible {
opacity: 1;
pointer-events: all;
}

View File

@@ -1,7 +1,6 @@
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import cx from 'classnames';
import { getFocusedSeriesAtPosition } from 'lib/uPlotLib/plugins/onClickPlugin';
import uPlot from 'uplot';
import { syncCursorRegistry } from './syncCursorRegistry';
@@ -17,18 +16,21 @@ import {
} from './tooltipController';
import {
DashboardCursorSync,
TooltipClickData,
DEFAULT_PIN_TOOLTIP_KEY,
TooltipControllerContext,
TooltipControllerState,
TooltipLayoutInfo,
TooltipPluginProps,
TooltipViewState,
} from './types';
import { createInitialViewState, createLayoutObserver } from './utils';
import {
buildClickData,
createInitialViewState,
createLayoutObserver,
} from './utils';
import './TooltipPlugin.styles.scss';
import Styles from './TooltipPlugin.module.scss';
const INTERACTIVE_CONTAINER_CLASSNAME = '.tooltip-plugin-container';
// Delay before hiding an unpinned tooltip when the cursor briefly leaves
// the plot this avoids flicker when moving between nearby points.
const HOVER_DISMISS_DELAY_MS = 100;
@@ -44,6 +46,8 @@ export default function TooltipPlugin({
syncMetadata,
pinnedTooltipElement,
canPinTooltip = false,
pinKey = DEFAULT_PIN_TOOLTIP_KEY,
onClick,
}: TooltipPluginProps): JSX.Element | null {
const containerRef = useRef<HTMLDivElement>(null);
const rafId = useRef<number | null>(null);
@@ -131,8 +135,8 @@ export default function TooltipPlugin({
// Dismiss the tooltip when the user clicks / presses a key
// outside the tooltip container while it is pinned.
const onOutsideInteraction = (event: Event): void => {
const target = event.target as HTMLElement;
if (!target.closest(INTERACTIVE_CONTAINER_CLASSNAME)) {
const target = event.target as Node;
if (!containerRef.current?.contains(target)) {
dismissTooltip();
}
};
@@ -159,13 +163,14 @@ export default function TooltipPlugin({
// Attach / detach global listeners when pin state changes so
// we can detect when the user interacts outside the tooltip.
// Keyboard unpinning is handled exclusively in handleKeyDown so
// that only P (toggle) and Escape (release) can dismiss — not
// arbitrary keystrokes like arrow keys or Tab.
function toggleOutsideListeners(enable: boolean): void {
if (enable) {
document.addEventListener('mousedown', onOutsideInteraction, true);
document.addEventListener('keydown', onOutsideInteraction, true);
} else {
document.removeEventListener('mousedown', onOutsideInteraction, true);
document.removeEventListener('keydown', onOutsideInteraction, true);
}
}
@@ -283,66 +288,84 @@ export default function TooltipPlugin({
}
};
// When pinning is enabled, a click on the plot overlay while
// hovering converts the transient tooltip into a pinned one.
// Uses getPlot(controller) to avoid closing over u (plot), which
// would retain the plot and detached canvases across unmounts.
const handleUPlotOverClick = (event: MouseEvent): void => {
// Handles all tooltip-pin keyboard interactions:
// Escape — always releases the tooltip when pinned (never steals Escape
// from other handlers since we do not call stopPropagation).
// pinKey — toggles: pins when hovering+unpinned, unpins when pinned.
const handleKeyDown = (event: KeyboardEvent): void => {
// Escape: release-only (never toggles on).
if (event.key === 'Escape') {
if (controller.pinned) {
dismissTooltip();
}
return;
}
if (event.key.toLowerCase() !== pinKey.toLowerCase()) {
return;
}
// Toggle off: P pressed while already pinned.
if (controller.pinned) {
dismissTooltip();
return;
}
// Toggle on: P pressed while hovering.
const plot = getPlot(controller);
if (
!plot ||
!controller.hoverActive ||
controller.focusedSeriesIndex == null
) {
return;
}
const cursorLeft = plot.cursor.left ?? -1;
const cursorTop = plot.cursor.top ?? -1;
if (cursorLeft < 0 || cursorTop < 0) {
return;
}
const plotRect = plot.over.getBoundingClientRect();
const syntheticEvent = ({
clientX: plotRect.left + cursorLeft,
clientY: plotRect.top + cursorTop,
target: plot.over,
offsetX: cursorLeft,
offsetY: cursorTop,
} as unknown) as MouseEvent;
controller.clickData = buildClickData(syntheticEvent, plot);
controller.pinned = true;
scheduleRender(true);
};
// Forward overlay clicks to the consumer-provided onClick callback.
const handleOverClick = (event: MouseEvent): void => {
const plot = getPlot(controller);
/**
* Only trigger onClick if the click happened on the plot overlay and there is a focused series.
* It also ensures that clicks only trigger onClick when there is a relevant data point (i.e. a focused series) to provide context for the click.
*/
if (
plot &&
event.target === plot.over &&
controller.hoverActive &&
!controller.pinned &&
controller.focusedSeriesIndex != null
) {
const xValue = plot.posToVal(event.offsetX, 'x');
const yValue = plot.posToVal(event.offsetY, 'y');
const focusedSeries = getFocusedSeriesAtPosition(event, plot);
let clickedDataTimestamp = xValue;
if (focusedSeries) {
const dataIndex = plot.posToIdx(event.offsetX);
const xSeriesData = plot.data[0];
if (
xSeriesData &&
dataIndex >= 0 &&
dataIndex < xSeriesData.length &&
xSeriesData[dataIndex] !== undefined
) {
clickedDataTimestamp = xSeriesData[dataIndex];
}
}
const clickData: TooltipClickData = {
xValue,
yValue,
focusedSeries,
clickedDataTimestamp,
mouseX: event.offsetX,
mouseY: event.offsetY,
absoluteMouseX: event.clientX,
absoluteMouseY: event.clientY,
};
controller.clickData = clickData;
setTimeout(() => {
controller.pinned = true;
scheduleRender(true);
}, 0);
const clickData = buildClickData(event, plot);
onClick?.(clickData);
}
};
let overClickHandler: ((event: MouseEvent) => void) | null = null;
// Called once per uPlot instance; used to store the instance
// on the controller and optionally attach the pinning handler.
// Called once per uPlot instance; used to store the instance on the controller.
const handleInit = (u: uPlot): void => {
controller.plot = u;
updateState({ hasPlot: true });
if (canPinTooltip) {
overClickHandler = handleUPlotOverClick;
if (onClick) {
overClickHandler = handleOverClick;
u.over.addEventListener('click', overClickHandler);
}
};
@@ -389,13 +412,18 @@ export default function TooltipPlugin({
window.addEventListener('resize', handleWindowResize);
window.addEventListener('scroll', handleScroll, true);
if (canPinTooltip) {
document.addEventListener('keydown', handleKeyDown, true);
}
return (): void => {
layoutRef.current?.observer.disconnect();
window.removeEventListener('resize', handleWindowResize);
window.removeEventListener('scroll', handleScroll, true);
document.removeEventListener('mousedown', onOutsideInteraction, true);
document.removeEventListener('keydown', onOutsideInteraction, true);
if (canPinTooltip) {
document.removeEventListener('keydown', handleKeyDown, true);
}
cancelPendingRender();
removeReadyHook();
removeInitHook();
@@ -405,9 +433,7 @@ export default function TooltipPlugin({
removeSetCursorHook();
if (overClickHandler) {
const plot = getPlot(controller);
if (plot) {
plot.over.removeEventListener('click', overClickHandler);
}
plot?.over.removeEventListener('click', overClickHandler);
overClickHandler = null;
}
clearPlotReferences();
@@ -447,8 +473,12 @@ export default function TooltipPlugin({
}, [isHovering, hasPlot]);
const tooltipBody = useMemo(() => {
if (isPinned && pinnedTooltipElement != null && viewState.clickData != null) {
return pinnedTooltipElement(viewState.clickData);
if (isPinned) {
if (pinnedTooltipElement != null && viewState.clickData != null) {
return pinnedTooltipElement(viewState.clickData);
}
// No custom pinned element — keep showing the last hover contents.
return contents ?? null;
}
if (isHovering) {
@@ -471,9 +501,8 @@ export default function TooltipPlugin({
return createPortal(
<div
className={cx('tooltip-plugin-container', {
pinned: isPinned,
visible: isTooltipVisible,
className={cx(Styles.tooltipPluginContainer, {
[Styles.visible]: isTooltipVisible,
})}
style={{
...style,
@@ -484,6 +513,7 @@ export default function TooltipPlugin({
aria-atomic="true"
aria-hidden={!isTooltipVisible}
ref={containerRef}
data-pinned={isPinned}
data-testid="tooltip-plugin-container"
>
{tooltipBody}

View File

@@ -102,6 +102,12 @@ export function updateHoverState(
controller: TooltipControllerState,
syncTooltipWithDashboard: boolean,
): void {
// When pinned, keep hoverActive stable so the tooltip stays visible
// until explicitly dismissed — the cursor lock fires asynchronously
// and setSeries/setLegend can otherwise race and clear hoverActive.
if (controller.pinned) {
return;
}
// When the cursor is driven by dashboardlevel sync, we only show
// the tooltip if the plot is in viewport and at least one series
// is active. Otherwise we fall back to local interaction logic.

View File

@@ -11,6 +11,9 @@ import type { UPlotConfigBuilder } from '../../config/UPlotConfigBuilder';
export const TOOLTIP_OFFSET = 10;
// Default key that pins the tooltip while hovering over the chart.
export const DEFAULT_PIN_TOOLTIP_KEY = 'p';
export enum DashboardCursorSync {
Crosshair,
None,
@@ -41,6 +44,10 @@ export interface TooltipSyncMetadata {
export interface TooltipPluginProps {
config: UPlotConfigBuilder;
canPinTooltip?: boolean;
/** Key that pins the tooltip while hovering. Defaults to DEFAULT_PIN_TOOLTIP_KEY ('l'). */
pinKey?: string;
/** Called when the user clicks the uPlot overlay. Receives resolved click data. */
onClick?: (clickData: TooltipClickData) => void;
syncMode?: DashboardCursorSync;
syncKey?: string;
syncMetadata?: TooltipSyncMetadata;

View File

@@ -1,4 +1,11 @@
import { TOOLTIP_OFFSET, TooltipLayoutInfo, TooltipViewState } from './types';
import { getFocusedSeriesAtPosition } from 'lib/uPlotLib/plugins/onClickPlugin';
import {
TOOLTIP_OFFSET,
TooltipClickData,
TooltipLayoutInfo,
TooltipViewState,
} from './types';
export function isPlotInViewport(
rect: uPlot.BBox,
@@ -158,3 +165,40 @@ export function createLayoutObserver(
};
return layout;
}
/**
* Resolves a TooltipClickData snapshot from a MouseEvent (real or synthetic)
* and the current uPlot instance. Shared by the overlay click handler and the
* keyboard-pin handler (which synthesises an event from the cursor position).
*/
export function buildClickData(
event: MouseEvent,
plot: uPlot,
): TooltipClickData {
const xValue = plot.posToVal(event.offsetX, 'x');
const yValue = plot.posToVal(event.offsetY, 'y');
const focusedSeries = getFocusedSeriesAtPosition(event, plot);
const dataIndex = plot.posToIdx(event.offsetX);
let clickedDataTimestamp = xValue;
const xSeriesData = plot.data[0];
if (
xSeriesData &&
dataIndex >= 0 &&
dataIndex < xSeriesData.length &&
xSeriesData[dataIndex] !== undefined
) {
clickedDataTimestamp = xSeriesData[dataIndex];
}
return {
xValue,
yValue,
focusedSeries,
clickedDataTimestamp,
mouseX: event.offsetX,
mouseY: event.offsetY,
absoluteMouseX: event.clientX,
absoluteMouseY: event.clientY,
};
}

View File

@@ -7,7 +7,10 @@ import type uPlot from 'uplot';
import { TooltipRenderArgs } from '../../components/types';
import { UPlotConfigBuilder } from '../../config/UPlotConfigBuilder';
import TooltipPlugin from '../TooltipPlugin/TooltipPlugin';
import { DashboardCursorSync } from '../TooltipPlugin/types';
import {
DashboardCursorSync,
DEFAULT_PIN_TOOLTIP_KEY,
} from '../TooltipPlugin/types';
// Avoid depending on the full uPlot + onClickPlugin behaviour in these tests.
// We only care that pinning logic runs without throwing, not which series is focused.
@@ -60,7 +63,7 @@ function getHandler(config: ConfigMock, hookName: string): HookHandler {
function createFakePlot(): {
over: HTMLDivElement;
setCursor: jest.Mock<void, [uPlot.Cursor]>;
cursor: { event: Record<string, unknown> };
cursor: { event: Record<string, unknown>; left: number; top: number };
posToVal: jest.Mock<number, [value: number]>;
posToIdx: jest.Mock<number, []>;
data: [number[], number[]];
@@ -71,7 +74,9 @@ function createFakePlot(): {
return {
over,
setCursor: jest.fn(),
cursor: { event: {} },
// left / top are set to valid values so keyboard-pin tests do not
// hit the "cursor off-screen" guard inside handleKeyDown.
cursor: { event: {}, left: 50, top: 50 },
// In real uPlot these map overlay coordinates to data-space values.
posToVal: jest.fn((value: number) => value),
posToIdx: jest.fn(() => 0),
@@ -144,7 +149,7 @@ describe('TooltipPlugin', () => {
}),
);
expect(document.querySelector('.tooltip-plugin-container')).toBeNull();
expect(screen.queryByTestId('tooltip-plugin-container')).toBeNull();
});
it('registers all required uPlot hooks on mount', () => {
@@ -182,9 +187,7 @@ describe('TooltipPlugin', () => {
expect(renderTooltip).toHaveBeenCalled();
expect(screen.getByText('tooltip-body')).toBeInTheDocument();
const container = document.querySelector(
'.tooltip-plugin-container',
) as HTMLElement;
const container = screen.getByTestId('tooltip-plugin-container');
expect(container).not.toBeNull();
expect(container.parentElement).toBe(document.body);
});
@@ -203,9 +206,7 @@ describe('TooltipPlugin', () => {
renderAndActivateHover(config);
const container = document.querySelector(
'.tooltip-plugin-container',
) as HTMLElement;
const container = screen.getByTestId('tooltip-plugin-container');
expect(container.parentElement).toBe(document.body);
const fullscreenRoot = document.createElement('div');
@@ -245,24 +246,27 @@ describe('TooltipPlugin', () => {
// ---- Pin behaviour ----------------------------------------------------------
describe('pin behaviour', () => {
it('pins the tooltip when canPinTooltip is true and overlay is clicked', () => {
it('pins the tooltip when canPinTooltip is true and the pinKey is pressed while hovering', () => {
const config = createConfigMock();
const fakePlot = renderAndActivateHover(config, undefined, {
canPinTooltip: true,
});
renderAndActivateHover(config, undefined, { canPinTooltip: true });
const container = screen.getByTestId('tooltip-plugin-container');
expect(container.classList.contains('pinned')).toBe(false);
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
act(() => {
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
});
return waitFor(() => {
const updated = screen.getByTestId('tooltip-plugin-container');
expect(updated).toBeInTheDocument();
expect(updated.classList.contains('pinned')).toBe(true);
expect(updated.getAttribute('data-pinned') === 'true').toBe(true);
});
});
@@ -272,7 +276,7 @@ describe('TooltipPlugin', () => {
React.createElement('div', null, 'pinned-tooltip'),
);
const fakePlot = renderAndActivateHover(
renderAndActivateHover(
config,
() => React.createElement('div', null, 'hover-tooltip'),
{
@@ -284,7 +288,12 @@ describe('TooltipPlugin', () => {
expect(screen.getByText('hover-tooltip')).toBeInTheDocument();
act(() => {
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
});
await waitFor(() => {
@@ -318,18 +327,20 @@ describe('TooltipPlugin', () => {
getHandler(config, 'setSeries')(fakePlot, 1, { focus: true });
});
// Pin the tooltip.
// Pin the tooltip via the keyboard shortcut.
act(() => {
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
});
// Wait until the tooltip is actually pinned (pointer events enabled)
// Wait until the tooltip is actually pinned.
await waitFor(() => {
const container = document.querySelector(
'.tooltip-plugin-container',
) as HTMLElement | null;
expect(container).not.toBeNull();
expect(container?.classList.contains('pinned')).toBe(true);
const container = screen.getByTestId('tooltip-plugin-container');
expect(container.getAttribute('data-pinned') === 'true').toBe(true);
});
const button = await screen.findByRole('button', { name: 'Dismiss' });
@@ -342,8 +353,7 @@ describe('TooltipPlugin', () => {
expect(container).toBeInTheDocument();
expect(container.getAttribute('aria-hidden')).toBe('true');
expect(container.classList.contains('visible')).toBe(false);
expect(container.classList.contains('pinned')).toBe(false);
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
expect(container.textContent).toBe('');
});
});
@@ -369,16 +379,21 @@ describe('TooltipPlugin', () => {
jest.runAllTimers();
});
// Pin.
// Pin via keyboard.
act(() => {
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
jest.runAllTimers();
});
expect(
(document.querySelector(
'.tooltip-plugin-container',
) as HTMLElement)?.classList.contains('pinned'),
screen
.getByTestId('tooltip-plugin-container')
.getAttribute('data-pinned') === 'true',
).toBe(true);
// Simulate data update should dismiss the pinned tooltip.
@@ -390,8 +405,7 @@ describe('TooltipPlugin', () => {
const container = screen.getByTestId('tooltip-plugin-container');
expect(container).toBeInTheDocument();
expect(container.getAttribute('aria-hidden')).toBe('true');
expect(container.classList.contains('visible')).toBe(false);
expect(container.classList.contains('pinned')).toBe(false);
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
jest.useRealTimers();
});
@@ -417,15 +431,21 @@ describe('TooltipPlugin', () => {
jest.runAllTimers();
});
// Pin via keyboard.
act(() => {
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
jest.runAllTimers();
});
expect(
document
.querySelector('.tooltip-plugin-container')
?.classList.contains('pinned'),
screen
.getByTestId('tooltip-plugin-container')
.getAttribute('data-pinned') === 'true',
).toBe(true);
// Click outside the tooltip container.
@@ -439,14 +459,13 @@ describe('TooltipPlugin', () => {
expect(container).toBeInTheDocument();
expect(container.getAttribute('aria-hidden')).toBe('true');
expect(container.classList.contains('visible')).toBe(false);
expect(container.classList.contains('pinned')).toBe(false);
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
});
jest.useRealTimers();
});
it('unpins the tooltip on outside keydown', async () => {
it('unpins the tooltip when Escape is pressed while pinned', async () => {
jest.useFakeTimers();
const config = createConfigMock();
@@ -467,18 +486,24 @@ describe('TooltipPlugin', () => {
jest.runAllTimers();
});
// Pin via keyboard.
act(() => {
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
jest.runAllTimers();
});
expect(
document
.querySelector('.tooltip-plugin-container')
?.classList.contains('pinned'),
screen
.getByTestId('tooltip-plugin-container')
.getAttribute('data-pinned') === 'true',
).toBe(true);
// Press a key outside the tooltip.
// Press Escape to release.
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
@@ -490,12 +515,282 @@ describe('TooltipPlugin', () => {
const container = screen.getByTestId('tooltip-plugin-container');
expect(container).toBeInTheDocument();
expect(container.getAttribute('aria-hidden')).toBe('true');
expect(container.classList.contains('visible')).toBe(false);
expect(container.classList.contains('pinned')).toBe(false);
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
});
jest.useRealTimers();
});
it('unpins the tooltip when the pin key is pressed a second time (toggle off)', async () => {
jest.useFakeTimers();
const config = createConfigMock();
renderAndActivateHover(config, undefined, { canPinTooltip: true });
jest.runAllTimers();
// First press — pin.
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
jest.runAllTimers();
});
await waitFor(() => {
expect(
screen
.getByTestId('tooltip-plugin-container')
.getAttribute('data-pinned') === 'true',
).toBe(true);
});
// Second press — unpin (toggle off).
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
jest.runAllTimers();
});
await waitFor(() => {
const container = screen.getByTestId('tooltip-plugin-container');
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
});
jest.useRealTimers();
});
it('does not unpin on Escape when tooltip is not pinned', () => {
const config = createConfigMock();
renderAndActivateHover(config, undefined, { canPinTooltip: true });
// Escape without pinning first — should be a no-op.
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
);
});
const container = screen.getByTestId('tooltip-plugin-container');
// Tooltip should still be hovering (visible), not dismissed.
expect(container.getAttribute('aria-hidden')).toBe('false');
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
});
it('does not unpin on arbitrary keys that are not Escape or the pin key', async () => {
jest.useFakeTimers();
const config = createConfigMock();
renderAndActivateHover(config, undefined, { canPinTooltip: true });
jest.runAllTimers();
// Pin.
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
jest.runAllTimers();
});
await waitFor(() => {
expect(
screen
.getByTestId('tooltip-plugin-container')
.getAttribute('data-pinned') === 'true',
).toBe(true);
});
// Arrow key — should NOT unpin.
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
);
jest.runAllTimers();
});
await waitFor(() => {
expect(
screen
.getByTestId('tooltip-plugin-container')
.getAttribute('data-pinned') === 'true',
).toBe(true);
});
jest.useRealTimers();
});
});
// ---- Keyboard pin edge cases ------------------------------------------------
describe('keyboard pin edge cases', () => {
it('does not pin when cursor coordinates are negative (cursor off-screen)', () => {
const config = createConfigMock();
render(
React.createElement(TooltipPlugin, {
config,
render: () => React.createElement('div', null, 'tooltip-body'),
syncMode: DashboardCursorSync.None,
canPinTooltip: true,
}),
);
// Negative cursor coords — handleKeyDown bails out before pinning.
const fakePlot = {
...createFakePlot(),
cursor: { event: {}, left: -1, top: -1 },
};
act(() => {
getHandler(config, 'init')(fakePlot);
getHandler(config, 'setSeries')(fakePlot, 1, { focus: true });
});
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
});
const container = screen.getByTestId('tooltip-plugin-container');
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
});
it('does not pin when hover is not active', () => {
const config = createConfigMock();
render(
React.createElement(TooltipPlugin, {
config,
render: () => React.createElement('div', null, 'tooltip-body'),
syncMode: DashboardCursorSync.None,
canPinTooltip: true,
}),
);
const fakePlot = createFakePlot();
act(() => {
// Initialise the plot but do NOT call setSeries hoverActive stays false.
getHandler(config, 'init')(fakePlot);
});
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
});
// The container exists once the plot is initialised, but it should
// be hidden and not pinned since hover was never activated.
const container = screen.getByTestId('tooltip-plugin-container');
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
expect(container.getAttribute('aria-hidden')).toBe('true');
});
it('ignores other keys and only pins on the configured pinKey', async () => {
const config = createConfigMock();
renderAndActivateHover(config, undefined, {
canPinTooltip: true,
pinKey: 'p',
});
// 'l' should NOT pin when pinKey is 'p'.
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'l',
bubbles: true,
}),
);
});
await waitFor(() => {
expect(
screen
.getByTestId('tooltip-plugin-container')
.getAttribute('data-pinned') === 'true',
).toBe(false);
});
// Custom pin key 'p' SHOULD pin.
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', { key: 'p', bubbles: true }),
);
});
await waitFor(() => {
expect(
screen
.getByTestId('tooltip-plugin-container')
.getAttribute('data-pinned') === 'true',
).toBe(true);
});
});
it('does not register a keydown listener when canPinTooltip is false', () => {
const config = createConfigMock();
const addSpy = jest.spyOn(document, 'addEventListener');
render(
React.createElement(TooltipPlugin, {
config,
render: () => null,
syncMode: DashboardCursorSync.None,
canPinTooltip: false,
}),
);
const keydownCalls = addSpy.mock.calls.filter(
([type]) => type === 'keydown',
);
expect(keydownCalls).toHaveLength(0);
});
it('removes the keydown pin listener on unmount', () => {
const config = createConfigMock();
const addSpy = jest.spyOn(document, 'addEventListener');
const removeSpy = jest.spyOn(document, 'removeEventListener');
const { unmount } = render(
React.createElement(TooltipPlugin, {
config,
render: () => null,
syncMode: DashboardCursorSync.None,
canPinTooltip: true,
}),
);
const pinListenerCall = addSpy.mock.calls.find(
([type]) => type === 'keydown',
);
expect(pinListenerCall).toBeDefined();
if (!pinListenerCall) {
return;
}
const [, pinListener, pinOptions] = pinListenerCall;
unmount();
expect(removeSpy).toHaveBeenCalledWith('keydown', pinListener, pinOptions);
});
});
// ---- Cursor sync ------------------------------------------------------------

View File

@@ -5593,10 +5593,10 @@
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
"@signozhq/ui@0.0.9":
version "0.0.9"
resolved "https://registry.yarnpkg.com/@signozhq/ui/-/ui-0.0.9.tgz#e00f2ec86c5528eea91d1669510a702c4253de0d"
integrity sha512-L9DV0OF69Z2sMnxwPEGpSTiDxI/liT6+QfzngGVnxMl/9t3WKcPr1/p8dbPOcAS6bU9lQEBOiYlsoIejM8DsXw==
"@signozhq/ui@0.0.10":
version "0.0.10"
resolved "https://registry.yarnpkg.com/@signozhq/ui/-/ui-0.0.10.tgz#cdbab838f8cb543cf5b483a86e9d9b65265b81ff"
integrity sha512-XLeET+PgSP7heqKMsb9YZOSRT3TpfMPHNQRnY1I4SK8mXSct7BYWwK0Q3Je0uf4Z3aWOcpRYoRUPHWZQBpweFQ==
dependencies:
"@chenglou/pretext" "^0.0.5"
"@radix-ui/react-checkbox" "^1.2.3"

3
go.mod
View File

@@ -30,7 +30,7 @@ require (
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
github.com/huandu/go-sqlbuilder v1.39.1
github.com/jackc/pgx/v5 v5.8.0
github.com/jackc/pgx/v5 v5.9.2
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12
github.com/knadh/koanf v1.5.0
github.com/knadh/koanf/v2 v2.3.2
@@ -66,6 +66,7 @@ require (
github.com/uptrace/bun/dialect/pgdialect v1.2.9
github.com/uptrace/bun/dialect/sqlitedialect v1.2.9
github.com/uptrace/bun/extra/bunotel v1.2.9
github.com/yuin/goldmark v1.7.16
go.opentelemetry.io/collector/confmap v1.51.0
go.opentelemetry.io/collector/otelcol v0.144.0
go.opentelemetry.io/collector/pdata v1.51.0

6
go.sum
View File

@@ -675,8 +675,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4=
@@ -1153,6 +1153,8 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zeebo/assert v1.3.1 h1:vukIABvugfNMZMQO1ABsyQDJDTVQbn+LWSMy1ol1h6A=

View File

@@ -0,0 +1,755 @@
package blockkit
import (
"bytes"
"encoding/json"
"strings"
"github.com/SigNoz/signoz/pkg/types/templatingtypes"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
extensionast "github.com/yuin/goldmark/extension/ast"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/util"
)
// Extender is a goldmark.Extender that registers the Block Kit node renderer
// together with the GFM extensions it relies on (tables, strikethrough, task
// lists).
var Extender goldmark.Extender = &extender{}
type extender struct{}
func (e *extender) Extend(m goldmark.Markdown) {
extension.Table.Extend(m)
extension.Strikethrough.Extend(m)
extension.TaskList.Extend(m)
m.Renderer().AddOptions(
renderer.WithNodeRenderers(util.Prioritized(newRenderer(), 1)),
)
}
// listFrame tracks state for a single level of list nesting.
type listFrame struct {
style string // "bullet" or "ordered"
indent int
itemCount int
}
// listContext holds all state while processing a list tree.
type listContext struct {
result []templatingtypes.RichTextList
stack []listFrame
current *templatingtypes.RichTextList
currentItemInlines []interface{}
}
// tableContext holds state while processing a table.
type tableContext struct {
rows [][]templatingtypes.TableCell
currentRow []templatingtypes.TableCell
currentCellInlines []interface{}
isHeader bool
}
// nodeRenderer converts Markdown AST to Slack Block Kit JSON.
type nodeRenderer struct {
blocks []interface{}
mrkdwn strings.Builder
// holds active styles for the current rich text element
styleStack []templatingtypes.RichTextStyle
// holds the current list context while processing a list tree.
listCtx *listContext
// holds the current table context while processing a table.
tableCtx *tableContext
// stores the current blockquote depth while processing a blockquote.
// so blockquote with nested list can be rendered correctly.
blockquoteDepth int
}
func newRenderer() renderer.NodeRenderer {
return &nodeRenderer{}
}
// RegisterFuncs registers node rendering functions.
func (r *nodeRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
// Blocks
reg.Register(ast.KindDocument, r.renderDocument)
reg.Register(ast.KindHeading, r.renderHeading)
reg.Register(ast.KindParagraph, r.renderParagraph)
reg.Register(ast.KindThematicBreak, r.renderThematicBreak)
reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
reg.Register(ast.KindFencedCodeBlock, r.renderFencedCodeBlock)
reg.Register(ast.KindBlockquote, r.renderBlockquote)
reg.Register(ast.KindList, r.renderList)
reg.Register(ast.KindListItem, r.renderListItem)
reg.Register(ast.KindImage, r.renderImage)
// Inlines
reg.Register(ast.KindText, r.renderText)
reg.Register(ast.KindEmphasis, r.renderEmphasis)
reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
reg.Register(ast.KindLink, r.renderLink)
// Extensions
reg.Register(extensionast.KindStrikethrough, r.renderStrikethrough)
reg.Register(extensionast.KindTable, r.renderTable)
reg.Register(extensionast.KindTableHeader, r.renderTableHeader)
reg.Register(extensionast.KindTableRow, r.renderTableRow)
reg.Register(extensionast.KindTableCell, r.renderTableCell)
reg.Register(extensionast.KindTaskCheckBox, r.renderTaskCheckBox)
}
// inRichTextMode returns true when we're inside a list or table context
// in slack blockkit list and table items are rendered as rich_text elements
// if more cases are found in future those needs to be added here.
func (r *nodeRenderer) inRichTextMode() bool {
return r.listCtx != nil || r.tableCtx != nil
}
// currentStyle merges the stored style stack into templatingtypes.RichTextStyle
// which can be applied on rich text elements.
func (r *nodeRenderer) currentStyle() *templatingtypes.RichTextStyle {
s := templatingtypes.RichTextStyle{}
for _, f := range r.styleStack {
s.Bold = s.Bold || f.Bold
s.Italic = s.Italic || f.Italic
s.Strike = s.Strike || f.Strike
s.Code = s.Code || f.Code
}
if s == (templatingtypes.RichTextStyle{}) {
return nil
}
return &s
}
// flushMrkdwn collects markdown text and adds it as a templatingtypes.SectionBlock with mrkdwn text
// whenever starting a new block we flush markdown to render it as a separate block.
func (r *nodeRenderer) flushMrkdwn() {
text := strings.TrimSpace(r.mrkdwn.String())
if text != "" {
r.blocks = append(r.blocks, templatingtypes.SectionBlock{
Type: "section",
Text: &templatingtypes.TextObject{
Type: "mrkdwn",
Text: text,
},
})
}
r.mrkdwn.Reset()
}
// addInline adds an inline element to the appropriate context.
func (r *nodeRenderer) addInline(el interface{}) {
if r.listCtx != nil {
r.listCtx.currentItemInlines = append(r.listCtx.currentItemInlines, el)
} else if r.tableCtx != nil {
r.tableCtx.currentCellInlines = append(r.tableCtx.currentCellInlines, el)
}
}
// --- Document ---
func (r *nodeRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.blocks = nil
r.mrkdwn.Reset()
r.styleStack = nil
r.listCtx = nil
r.tableCtx = nil
r.blockquoteDepth = 0
} else {
// on exiting the document node write the json for the collected blocks.
r.flushMrkdwn()
var data []byte
var err error
if len(r.blocks) > 0 {
data, err = json.Marshal(r.blocks)
if err != nil {
return ast.WalkStop, err
}
} else {
// if no blocks are collected, write an empty array.
data = []byte("[]")
}
_, err = w.Write(data)
if err != nil {
return ast.WalkStop, err
}
}
return ast.WalkContinue, nil
}
// --- Heading ---
func (r *nodeRenderer) renderHeading(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.mrkdwn.WriteString("*")
} else {
r.mrkdwn.WriteString("*\n")
}
return ast.WalkContinue, nil
}
// --- Paragraph ---
func (r *nodeRenderer) renderParagraph(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
if r.mrkdwn.Len() > 0 {
text := r.mrkdwn.String()
if !strings.HasSuffix(text, "\n") {
r.mrkdwn.WriteString("\n")
}
}
// handling of nested blockquotes
if r.blockquoteDepth > 0 {
r.mrkdwn.WriteString(strings.Repeat("> ", r.blockquoteDepth))
}
}
return ast.WalkContinue, nil
}
// --- ThematicBreak ---
func (r *nodeRenderer) renderThematicBreak(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.flushMrkdwn()
r.blocks = append(r.blocks, templatingtypes.DividerBlock{Type: "divider"})
}
return ast.WalkContinue, nil
}
// --- CodeBlock (indented) ---
func (r *nodeRenderer) renderCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
r.flushMrkdwn()
var buf bytes.Buffer
lines := node.Lines()
for i := 0; i < lines.Len(); i++ {
line := lines.At(i)
buf.Write(line.Value(source))
}
text := buf.String()
// Remove trailing newline
text = strings.TrimRight(text, "\n")
// Slack API rejects empty text in rich_text_preformatted elements
if text == "" {
text = " "
}
elements := []interface{}{
templatingtypes.RichTextInline{Type: "text", Text: text},
}
r.blocks = append(r.blocks, templatingtypes.RichTextBlock{
Type: "rich_text",
Elements: []interface{}{
templatingtypes.RichTextPreformatted{
Type: "rich_text_preformatted",
Elements: elements,
Border: 0,
},
},
})
return ast.WalkContinue, nil
}
// --- FencedCodeBlock ---
func (r *nodeRenderer) renderFencedCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
r.flushMrkdwn()
n := node.(*ast.FencedCodeBlock)
var buf bytes.Buffer
lines := node.Lines()
for i := 0; i < lines.Len(); i++ {
line := lines.At(i)
buf.Write(line.Value(source))
}
text := buf.String()
text = strings.TrimRight(text, "\n")
// Slack API rejects empty text in rich_text_preformatted elements
if text == "" {
text = " "
}
elements := []interface{}{
templatingtypes.RichTextInline{Type: "text", Text: text},
}
// If language is specified, collect it.
var language string
lang := n.Language(source)
if len(lang) > 0 {
language = string(lang)
}
// Add the preformatted block to the blocks slice with the collected language.
r.blocks = append(r.blocks, templatingtypes.RichTextBlock{
Type: "rich_text",
Elements: []interface{}{
templatingtypes.RichTextPreformatted{
Type: "rich_text_preformatted",
Elements: elements,
Border: 0,
Language: language,
},
},
})
return ast.WalkSkipChildren, nil
}
// --- Blockquote ---
func (r *nodeRenderer) renderBlockquote(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.blockquoteDepth++
} else {
r.blockquoteDepth--
}
return ast.WalkContinue, nil
}
// --- List ---
func (r *nodeRenderer) renderList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
list := node.(*ast.List)
if entering {
style := "bullet"
if list.IsOrdered() {
style = "ordered"
}
if r.listCtx == nil {
// Top-level list: flush mrkdwn and create context
r.flushMrkdwn()
r.listCtx = &listContext{}
} else {
// Nested list: check if we already have some collected list items that needs to be flushed.
// in slack blockkit, list items with different levels of indentation are added as different rich_text_list blocks.
if len(r.listCtx.currentItemInlines) > 0 {
sec := templatingtypes.RichTextBlock{
Type: "rich_text_section",
Elements: r.listCtx.currentItemInlines,
}
if r.listCtx.current != nil {
r.listCtx.current.Elements = append(r.listCtx.current.Elements, sec)
}
r.listCtx.currentItemInlines = nil
// Increment parent's itemCount
if len(r.listCtx.stack) > 0 {
r.listCtx.stack[len(r.listCtx.stack)-1].itemCount++
}
}
// Finalize current list to result only if items were collected
if r.listCtx.current != nil && len(r.listCtx.current.Elements) > 0 {
r.listCtx.result = append(r.listCtx.result, *r.listCtx.current)
}
}
// the stack accumulated till this level derives hte indentation
// the stack get's collected as we go in more nested levels of list
// and as we get our of the nesting we remove the items from the slack
indent := len(r.listCtx.stack)
r.listCtx.stack = append(r.listCtx.stack, listFrame{
style: style,
indent: indent,
itemCount: 0,
})
newList := &templatingtypes.RichTextList{
Type: "rich_text_list",
Style: style,
Indent: indent,
Border: 0,
Elements: []interface{}{},
}
// Handle ordered list with start > 1
if list.IsOrdered() && list.Start > 1 {
newList.Offset = list.Start - 1
}
r.listCtx.current = newList
} else {
// Leaving list: finalize current list
if r.listCtx.current != nil && len(r.listCtx.current.Elements) > 0 {
r.listCtx.result = append(r.listCtx.result, *r.listCtx.current)
}
// Pop stack to so upcoming indentations can be handled correctly.
r.listCtx.stack = r.listCtx.stack[:len(r.listCtx.stack)-1]
if len(r.listCtx.stack) > 0 {
// Resume parent: start a new list segment at parent indent/style
parent := &r.listCtx.stack[len(r.listCtx.stack)-1]
newList := &templatingtypes.RichTextList{
Type: "rich_text_list",
Style: parent.style,
Indent: parent.indent,
Border: 0,
Elements: []interface{}{},
}
// Set offset for ordered parent continuation
if parent.style == "ordered" && parent.itemCount > 0 {
newList.Offset = parent.itemCount
}
r.listCtx.current = newList
} else {
// Top-level list is done since all stack are popped: build templatingtypes.RichTextBlock if non-empty
if len(r.listCtx.result) > 0 {
elements := make([]interface{}, len(r.listCtx.result))
for i, l := range r.listCtx.result {
elements[i] = l
}
r.blocks = append(r.blocks, templatingtypes.RichTextBlock{
Type: "rich_text",
Elements: elements,
})
}
r.listCtx = nil
}
}
return ast.WalkContinue, nil
}
// --- ListItem ---
func (r *nodeRenderer) renderListItem(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.listCtx.currentItemInlines = nil
} else {
// Only add if there are inlines (might be empty after nested list consumed them)
if len(r.listCtx.currentItemInlines) > 0 {
sec := templatingtypes.RichTextBlock{
Type: "rich_text_section",
Elements: r.listCtx.currentItemInlines,
}
if r.listCtx.current != nil {
r.listCtx.current.Elements = append(r.listCtx.current.Elements, sec)
}
r.listCtx.currentItemInlines = nil
// Increment parent frame's itemCount
if len(r.listCtx.stack) > 0 {
r.listCtx.stack[len(r.listCtx.stack)-1].itemCount++
}
}
}
return ast.WalkContinue, nil
}
// --- Table ---
// when table is encountered, we flush the markdown and create a table context.
// when header row is encountered, we set the isHeader flag to true
// when each row ends in renderTableRow we add that row to rows array of table context.
// when table cell is encountered, we apply header related styles to the collected inline items,
// all inline items are parsed as separate AST items like list item, links, text, etc. are collected
// using the addInline function and wrapped in a rich_text_section block.
func (r *nodeRenderer) renderTable(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.flushMrkdwn()
r.tableCtx = &tableContext{}
} else {
// Pad short rows to match header column count for valid Block Kit payload
// without this slack blockkit attachment is invalid and the API fails
rows := r.tableCtx.rows
if len(rows) > 0 {
maxCols := len(rows[0])
for i, row := range rows {
for len(row) < maxCols {
emptySec := templatingtypes.RichTextBlock{
Type: "rich_text_section",
Elements: []interface{}{templatingtypes.RichTextInline{Type: "text", Text: " "}},
}
row = append(row, templatingtypes.TableCell{
Type: "rich_text",
Elements: []interface{}{emptySec},
})
}
rows[i] = row
}
}
r.blocks = append(r.blocks, templatingtypes.TableBlock{
Type: "table",
Rows: rows,
})
r.tableCtx = nil
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderTableHeader(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.tableCtx.isHeader = true
r.tableCtx.currentRow = nil
} else {
r.tableCtx.rows = append(r.tableCtx.rows, r.tableCtx.currentRow)
r.tableCtx.currentRow = nil
r.tableCtx.isHeader = false
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderTableRow(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.tableCtx.currentRow = nil
} else {
r.tableCtx.rows = append(r.tableCtx.rows, r.tableCtx.currentRow)
r.tableCtx.currentRow = nil
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderTableCell(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.tableCtx.currentCellInlines = nil
} else {
// If header, make text bold for the collected inline items.
if r.tableCtx.isHeader {
for i, el := range r.tableCtx.currentCellInlines {
if inline, ok := el.(templatingtypes.RichTextInline); ok {
if inline.Style == nil {
inline.Style = &templatingtypes.RichTextStyle{Bold: true}
} else {
inline.Style.Bold = true
}
r.tableCtx.currentCellInlines[i] = inline
}
}
}
// Ensure cell has at least one element for valid Block Kit payload
if len(r.tableCtx.currentCellInlines) == 0 {
r.tableCtx.currentCellInlines = []interface{}{
templatingtypes.RichTextInline{Type: "text", Text: " "},
}
}
// All inline items that are collected for a table cell are wrapped in a rich_text_section block.
sec := templatingtypes.RichTextBlock{
Type: "rich_text_section",
Elements: r.tableCtx.currentCellInlines,
}
// The rich_text_section block is wrapped in a rich_text block.
cell := templatingtypes.TableCell{
Type: "rich_text",
Elements: []interface{}{sec},
}
r.tableCtx.currentRow = append(r.tableCtx.currentRow, cell)
r.tableCtx.currentCellInlines = nil
}
return ast.WalkContinue, nil
}
// --- TaskCheckBox ---
func (r *nodeRenderer) renderTaskCheckBox(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*extensionast.TaskCheckBox)
text := "[ ] "
if n.IsChecked {
text = "[x] "
}
if r.inRichTextMode() {
r.addInline(templatingtypes.RichTextInline{Type: "text", Text: text})
} else {
r.mrkdwn.WriteString(text)
}
return ast.WalkContinue, nil
}
// --- Inline: Text ---
func (r *nodeRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*ast.Text)
value := string(n.Segment.Value(source))
if r.inRichTextMode() {
r.addInline(templatingtypes.RichTextInline{
Type: "text",
Text: value,
Style: r.currentStyle(),
})
if n.HardLineBreak() || n.SoftLineBreak() {
r.addInline(templatingtypes.RichTextInline{Type: "text", Text: "\n"})
}
} else {
r.mrkdwn.WriteString(value)
if n.HardLineBreak() || n.SoftLineBreak() {
r.mrkdwn.WriteString("\n")
if r.blockquoteDepth > 0 {
r.mrkdwn.WriteString(strings.Repeat("> ", r.blockquoteDepth))
}
}
}
return ast.WalkContinue, nil
}
// --- Inline: Emphasis ---
func (r *nodeRenderer) renderEmphasis(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*ast.Emphasis)
if r.inRichTextMode() {
if entering {
s := templatingtypes.RichTextStyle{}
if n.Level == 1 {
s.Italic = true
} else {
s.Bold = true
}
r.styleStack = append(r.styleStack, s)
} else {
// the collected style gets used by the rich text element using currentStyle()
// so we remove this style from the stack.
if len(r.styleStack) > 0 {
r.styleStack = r.styleStack[:len(r.styleStack)-1]
}
}
} else {
if n.Level == 1 {
r.mrkdwn.WriteString("_")
} else {
r.mrkdwn.WriteString("*")
}
}
return ast.WalkContinue, nil
}
// --- Inline: Strikethrough ---
func (r *nodeRenderer) renderStrikethrough(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if r.inRichTextMode() {
if entering {
r.styleStack = append(r.styleStack, templatingtypes.RichTextStyle{Strike: true})
} else {
// the collected style gets used by the rich text element using currentStyle()
// so we remove this style from the stack.
if len(r.styleStack) > 0 {
r.styleStack = r.styleStack[:len(r.styleStack)-1]
}
}
} else {
r.mrkdwn.WriteString("~")
}
return ast.WalkContinue, nil
}
// --- Inline: CodeSpan ---
func (r *nodeRenderer) renderCodeSpan(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
if r.inRichTextMode() {
// Collect all child text
var buf bytes.Buffer
for c := node.FirstChild(); c != nil; c = c.NextSibling() {
if t, ok := c.(*ast.Text); ok {
v := t.Segment.Value(source)
if bytes.HasSuffix(v, []byte("\n")) {
buf.Write(v[:len(v)-1])
buf.WriteByte(' ')
} else {
buf.Write(v)
}
} else if s, ok := c.(*ast.String); ok {
buf.Write(s.Value)
}
}
style := r.currentStyle()
if style == nil {
style = &templatingtypes.RichTextStyle{Code: true}
} else {
style.Code = true
}
r.addInline(templatingtypes.RichTextInline{
Type: "text",
Text: buf.String(),
Style: style,
})
return ast.WalkSkipChildren, nil
}
// mrkdwn mode
r.mrkdwn.WriteByte('`')
for c := node.FirstChild(); c != nil; c = c.NextSibling() {
if t, ok := c.(*ast.Text); ok {
v := t.Segment.Value(source)
if bytes.HasSuffix(v, []byte("\n")) {
r.mrkdwn.Write(v[:len(v)-1])
r.mrkdwn.WriteByte(' ')
} else {
r.mrkdwn.Write(v)
}
} else if s, ok := c.(*ast.String); ok {
r.mrkdwn.Write(s.Value)
}
}
r.mrkdwn.WriteByte('`')
return ast.WalkSkipChildren, nil
}
// --- Inline: Link ---
func (r *nodeRenderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*ast.Link)
if r.inRichTextMode() {
if entering {
// Walk the entire subtree to collect text from all descendants,
// including nested inline nodes like emphasis, strong, code spans, etc.
var buf bytes.Buffer
_ = ast.Walk(node, func(child ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering || child == node {
return ast.WalkContinue, nil
}
if t, ok := child.(*ast.Text); ok {
buf.Write(t.Segment.Value(source))
} else if s, ok := child.(*ast.String); ok {
buf.Write(s.Value)
}
return ast.WalkContinue, nil
})
// Once we've collected the text for the link (given it was present)
// let's add the link to the rich text block.
r.addInline(templatingtypes.RichTextLink{
Type: "link",
URL: string(n.Destination),
Text: buf.String(),
Style: r.currentStyle(),
})
return ast.WalkSkipChildren, nil
}
} else {
if entering {
r.mrkdwn.WriteString("<")
r.mrkdwn.Write(n.Destination)
r.mrkdwn.WriteString("|")
} else {
r.mrkdwn.WriteString(">")
}
}
return ast.WalkContinue, nil
}
// --- Image (skip) ---
func (r *nodeRenderer) renderImage(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
return ast.WalkSkipChildren, nil
}

View File

@@ -0,0 +1,542 @@
package blockkit
import (
"bytes"
"encoding/json"
"testing"
"github.com/yuin/goldmark"
)
func jsonEqual(a, b string) bool {
var va, vb interface{}
if err := json.Unmarshal([]byte(a), &va); err != nil {
return false
}
if err := json.Unmarshal([]byte(b), &vb); err != nil {
return false
}
ja, _ := json.Marshal(va)
jb, _ := json.Marshal(vb)
return string(ja) == string(jb)
}
func prettyJSON(s string) string {
var v interface{}
if err := json.Unmarshal([]byte(s), &v); err != nil {
return s
}
b, _ := json.MarshalIndent(v, "", " ")
return string(b)
}
func TestRenderer(t *testing.T) {
tests := []struct {
name string
markdown string
expected string
}{
{
name: "empty input",
markdown: "",
expected: `[]`,
},
{
name: "simple paragraph",
markdown: "Hello world",
expected: `[
{
"type": "section",
"text": { "type": "mrkdwn", "text": "Hello world" }
}
]`,
},
{
name: "heading",
markdown: "# My Heading",
expected: `[
{
"type": "section",
"text": { "type": "mrkdwn", "text": "*My Heading*" }
}
]`,
},
{
name: "multiple paragraphs",
markdown: "First paragraph\n\nSecond paragraph",
expected: `[
{
"type": "section",
"text": { "type": "mrkdwn", "text": "First paragraph\nSecond paragraph" }
}
]`,
},
{
name: "todo list ",
markdown: "- [ ] item 1\n- [x] item 2",
expected: `[
{
"type": "rich_text",
"elements": [
{
"border": 0,
"elements": [
{ "elements": [ { "text": "[ ] ", "type": "text" }, { "text": "item 1", "type": "text" } ], "type": "rich_text_section" },
{ "elements": [ { "text": "[x] ", "type": "text" }, { "text": "item 2", "type": "text" } ], "type": "rich_text_section" }
],
"indent": 0,
"style": "bullet",
"type": "rich_text_list"
}
]
}
]`,
},
{
name: "thematic break between paragraphs",
markdown: "Before\n\n---\n\nAfter",
expected: `[
{ "type": "section", "text": { "type": "mrkdwn", "text": "Before" } },
{ "type": "divider" },
{ "type": "section", "text": { "type": "mrkdwn", "text": "After" } }
]`,
},
{
name: "fenced code block with language",
markdown: "```go\nfmt.Println(\"hello\")\n```",
expected: `[
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_preformatted",
"border": 0,
"language": "go",
"elements": [
{ "type": "text", "text": "fmt.Println(\"hello\")" }
]
}
]
}
]`,
},
{
name: "indented code block",
markdown: " code line 1\n code line 2",
expected: `[
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_preformatted",
"border": 0,
"elements": [
{ "type": "text", "text": "code line 1\ncode line 2" }
]
}
]
}
]`,
},
{
name: "empty fenced code block",
markdown: "```\n```",
expected: `[
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_preformatted",
"border": 0,
"elements": [
{ "type": "text", "text": " " }
]
}
]
}
]`,
},
{
name: "simple bullet list",
markdown: "- item 1\n- item 2\n- item 3",
expected: `[
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
"elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 1" }] },
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 2" }] },
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 3" }] }
]
}
]
}
]`,
},
{
name: "simple ordered list",
markdown: "1. first\n2. second\n3. third",
expected: `[
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0,
"elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "first" }] },
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "second" }] },
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "third" }] }
]
}
]
}
]`,
},
{
name: "nested bullet list (2 levels)",
markdown: "- item 1\n- item 2\n - sub a\n - sub b\n- item 3",
expected: `[
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
"elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 1" }] },
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 2" }] }
]
},
{
"type": "rich_text_list", "style": "bullet", "indent": 1, "border": 0,
"elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "sub a" }] },
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "sub b" }] }
]
},
{
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
"elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 3" }] }
]
}
]
}
]`,
},
{
name: "nested ordered list with offset",
markdown: "1. first\n 1. nested-a\n 2. nested-b\n2. second\n3. third",
expected: `[
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0,
"elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "first" }] }
]
},
{
"type": "rich_text_list", "style": "ordered", "indent": 1, "border": 0,
"elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "nested-a" }] },
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "nested-b" }] }
]
},
{
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0, "offset": 1,
"elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "second" }] },
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "third" }] }
]
}
]
}
]`,
},
{
name: "mixed ordered/bullet nesting",
markdown: "1. ordered\n - bullet child\n2. ordered again",
expected: `[
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0,
"elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "ordered" }] }
]
},
{
"type": "rich_text_list", "style": "bullet", "indent": 1, "border": 0,
"elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "bullet child" }] }
]
},
{
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0, "offset": 1,
"elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "ordered again" }] }
]
}
]
}
]`,
},
{
name: "list items with bold/italic/link/code",
markdown: "- **bold item**\n- _italic item_\n- [link](http://example.com)\n- `code item`",
expected: `[
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
"elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "bold item", "style": { "bold": true } }] },
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "italic item", "style": { "italic": true } }] },
{ "type": "rich_text_section", "elements": [{ "type": "link", "url": "http://example.com", "text": "link" }] },
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "code item", "style": { "code": true } }] }
]
}
]
}
]`,
},
{
name: "table with header and body",
markdown: "| Name | Age |\n|------|-----|\n| Alice | 30 |",
expected: `[
{
"type": "table",
"rows": [
[
{ "type": "rich_text", "elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "Name", "style": { "bold": true } }] }
]},
{ "type": "rich_text", "elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "Age", "style": { "bold": true } }] }
]}
],
[
{ "type": "rich_text", "elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "Alice" }] }
]},
{ "type": "rich_text", "elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "30" }] }
]}
]
]
}
]`,
},
{
name: "blockquote",
markdown: "> quoted text",
expected: `[
{
"type": "section",
"text": { "type": "mrkdwn", "text": "> quoted text" }
}
]`,
},
{
name: "blockquote with nested list",
markdown: "> item 1\n> > item 2\n> > item 3",
expected: `[
{
"text": {
"text": "> item 1\n> > item 2\n> > item 3",
"type": "mrkdwn"
},
"type": "section"
}
]`,
},
{
name: "inline formatting in paragraph",
markdown: "This is **bold** and _italic_ and ~strike~ and `code`",
expected: `[
{
"type": "section",
"text": { "type": "mrkdwn", "text": "This is *bold* and _italic_ and ~strike~ and ` + "`code`" + `" }
}
]`,
},
{
name: "link in paragraph",
markdown: "Visit [Google](http://google.com)",
expected: `[
{
"type": "section",
"text": { "type": "mrkdwn", "text": "Visit <http://google.com|Google>" }
}
]`,
},
{
name: "image is skipped",
markdown: "![alt](http://example.com/image.png)",
// For image skip the block and return empty array
expected: `[]`,
},
{
name: "paragraph then list then paragraph",
markdown: "Before\n\n- item\n\nAfter",
expected: `[
{ "type": "section", "text": { "type": "mrkdwn", "text": "Before" } },
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
"elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item" }] }
]
}
]
},
{ "type": "section", "text": { "type": "mrkdwn", "text": "After" } }
]`,
},
{
name: "ordered list with start > 1",
markdown: "5. fifth\n6. sixth",
expected: `[
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0, "offset": 4,
"elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "fifth" }] },
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "sixth" }] }
]
}
]
}
]`,
},
{
name: "deeply nested ordered list (3 levels) with offsets",
markdown: "1. Some things\n\t1. are best left\n2. to the fate\n\t1. of the world\n\t\t1. and then\n\t\t2. this is how\n3. it turns out to be",
expected: `[
{
"type": "rich_text",
"elements": [
{ "type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0,
"elements": [{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "Some things" }] }] },
{ "type": "rich_text_list", "style": "ordered", "indent": 1, "border": 0,
"elements": [{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "are best left" }] }] },
{ "type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0, "offset": 1,
"elements": [{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "to the fate" }] }] },
{ "type": "rich_text_list", "style": "ordered", "indent": 1, "border": 0,
"elements": [{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "of the world" }] }] },
{ "type": "rich_text_list", "style": "ordered", "indent": 2, "border": 0,
"elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "and then" }] },
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "this is how" }] }
]
},
{ "type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0, "offset": 2,
"elements": [{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "it turns out to be" }] }] }
]
}
]`,
},
{
name: "link with bold label in list item",
markdown: "- [**docs**](http://example.com)",
expected: `[
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
"elements": [
{ "type": "rich_text_section", "elements": [{ "type": "link", "url": "http://example.com", "text": "docs" }] }
]
}
]
}
]`,
},
{
name: "table with empty cell",
markdown: "| A | B |\n|---|---|\n| 1 | |",
expected: `[
{
"type": "table",
"rows": [
[
{ "type": "rich_text", "elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "A", "style": { "bold": true } }] }
]},
{ "type": "rich_text", "elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "B", "style": { "bold": true } }] }
]}
],
[
{ "type": "rich_text", "elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "1" }] }
]},
{ "type": "rich_text", "elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": " " }] }
]}
]
]
}
]`,
},
{
name: "table with missing column in row",
markdown: "| A | B |\n|---|---|\n| 1 |",
expected: `[
{
"type": "table",
"rows": [
[
{ "type": "rich_text", "elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "A", "style": { "bold": true } }] }
]},
{ "type": "rich_text", "elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "B", "style": { "bold": true } }] }
]}
],
[
{ "type": "rich_text", "elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "1" }] }
]},
{ "type": "rich_text", "elements": [
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": " " }] }
]}
]
]
}
]`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
md := goldmark.New(
goldmark.WithExtensions(Extender),
)
var buf bytes.Buffer
if err := md.Convert([]byte(tt.markdown), &buf); err != nil {
t.Fatalf("convert error: %v", err)
}
got := buf.String()
if !jsonEqual(got, tt.expected) {
t.Errorf("JSON mismatch\n\nMarkdown:\n%s\n\nExpected:\n%s\n\nGot:\n%s",
tt.markdown, prettyJSON(tt.expected), prettyJSON(got))
}
})
}
}

View File

@@ -0,0 +1,9 @@
// Package blockkit provides a goldmark extension that renders markdown as
// Slack Block Kit JSON (an array of blocks suitable for the Slack API's
// `blocks` payload field).
//
// The current Slack notifier uses the sibling mrkdwn package instead, because
// Block Kit's hard limits (one table per message, 50 blocks per message,
// 12k-character total text) would silently truncate or reject notifications
// with rich bodies. This package is kept in tree for future use.
package blockkit

View File

@@ -0,0 +1,47 @@
package markdownrenderer
import (
"sync"
"testing"
)
// TestConcurrentRender exercises every render entry point from many
// goroutines. Run with `go test -race` to catch shared-state regressions
// in the HTML, Block Kit or mrkdwn paths.
func TestConcurrentRender(t *testing.T) {
const goroutines = 32
const iterations = 20
markdowns := []string{
"# Heading\n\nparagraph **bold** *em* `code`",
"- item 1\n- item 2\n - nested\n- item 3",
"| a | b |\n| - | - |\n| 1 | 2 |\n| 3 | 4 |",
"> quote\n>> nested quote",
"```go\npackage main\n```",
"[link](https://example.com) and ![img](https://example.com/i.png)",
}
renderers := map[string]func(string) (string, error){
"html": RenderHTML,
"blockkit": RenderSlackBlockKit,
"mrkdwn": RenderSlackMrkdwn,
}
var wg sync.WaitGroup
for name, render := range renderers {
for g := 0; g < goroutines; g++ {
wg.Add(1)
go func(name string, render func(string) (string, error), g int) {
defer wg.Done()
for i := 0; i < iterations; i++ {
md := markdowns[(g+i)%len(markdowns)]
if _, err := render(md); err != nil {
t.Errorf("%s render failed: %v", name, err)
return
}
}
}(name, render, g)
}
}
wg.Wait()
}

View File

@@ -0,0 +1,3 @@
// Package markdownrenderer renders markdown to the formats that alert
// notifications are delivered in: HTML, Slack Block Kit JSON, and Slack mrkdwn.
package markdownrenderer

View File

@@ -0,0 +1,166 @@
package markdownrenderer
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var testMarkdown = `# 🔥 FIRING: High CPU Usage on api-gateway
https://signoz.example.com/alerts/123
https://runbooks.example.com/cpu-high
## Alert Details
**Status:** **FIRING** | *api-gateway* service is experiencing high CPU usage. ~~resolved~~ previously.
Alert triggered because ` + "`cpu_usage_percent`" + ` exceeded threshold ` + "`90`" + `.
[View Alert in SigNoz](https://signoz.example.com/alerts/123) | [View Logs](https://signoz.example.com/logs?service=api-gateway) | [View Traces](https://signoz.example.com/traces?service=api-gateway)
![critical](https://signoz.example.com/badges/critical.svg "Critical Alert")
## Alert Labels
| Label | Value |
| -------- | ----------- |
| service | api-gateway |
| instance | pod-5a8b3c |
| severity | critical |
| region | us-east-1 |
## Remediation Steps
1. Check current CPU usage on the pod
2. Review recent deployments for regressions
3. Scale horizontally if load-related
1. Increase replica count
2. Verify HPA configuration
## Affected Services
* api-gateway
* auth-service
* payment-service
* payment-processor
* payment-validator
## Incident Checklist
- [x] Alert acknowledged
- [x] On-call notified
- [ ] Root cause identified
- [ ] Fix deployed
## Alert Rule Description
> This alert fires when CPU usage exceeds 90% for more than 5 minutes on any pod in the api-gateway service.
>
>> For capacity planning guidelines, see the infrastructure runbook section on horizontal pod autoscaling.
## Triggered Query
` + "```promql\navg(rate(container_cpu_usage_seconds_total{service=\"api-gateway\"}[5m])) by (pod) > 0.9\n```" + `
## Inline Details
This alert was generated by SigNoz using ` + "`alertmanager`" + ` rules engine.
`
func TestRenderHTML_Composite(t *testing.T) {
html, err := RenderHTML(testMarkdown)
require.NoError(t, err)
expected := "<h1>🔥 FIRING: High CPU Usage on api-gateway</h1>\n" +
"<p><a href=\"https://signoz.example.com/alerts/123\">https://signoz.example.com/alerts/123</a>\n<a href=\"https://runbooks.example.com/cpu-high\">https://runbooks.example.com/cpu-high</a></p>\n" +
"<h2>Alert Details</h2>\n" +
"<p><strong>Status:</strong> <strong>FIRING</strong> | <em>api-gateway</em> service is experiencing high CPU usage. <del>resolved</del> previously.</p>\n" +
"<p>Alert triggered because <code>cpu_usage_percent</code> exceeded threshold <code>90</code>.</p>\n" +
"<p><a href=\"https://signoz.example.com/alerts/123\">View Alert in SigNoz</a> | <a href=\"https://signoz.example.com/logs?service=api-gateway\">View Logs</a> | <a href=\"https://signoz.example.com/traces?service=api-gateway\">View Traces</a></p>\n" +
"<p><img src=\"https://signoz.example.com/badges/critical.svg\" alt=\"critical\" title=\"Critical Alert\"></p>\n" +
"<h2>Alert Labels</h2>\n" +
"<table>\n<thead>\n<tr>\n<th>Label</th>\n<th>Value</th>\n</tr>\n</thead>\n" +
"<tbody>\n<tr>\n<td>service</td>\n<td>api-gateway</td>\n</tr>\n" +
"<tr>\n<td>instance</td>\n<td>pod-5a8b3c</td>\n</tr>\n" +
"<tr>\n<td>severity</td>\n<td>critical</td>\n</tr>\n" +
"<tr>\n<td>region</td>\n<td>us-east-1</td>\n</tr>\n</tbody>\n</table>\n" +
"<h2>Remediation Steps</h2>\n" +
"<ol>\n<li>Check current CPU usage on the pod</li>\n<li>Review recent deployments for regressions</li>\n<li>Scale horizontally if load-related\n" +
"<ol>\n<li>Increase replica count</li>\n<li>Verify HPA configuration</li>\n</ol>\n</li>\n</ol>\n" +
"<h2>Affected Services</h2>\n" +
"<ul>\n<li>api-gateway</li>\n<li>auth-service</li>\n<li>payment-service\n" +
"<ul>\n<li>payment-processor</li>\n<li>payment-validator</li>\n</ul>\n</li>\n</ul>\n" +
"<h2>Incident Checklist</h2>\n" +
"<ul>\n<li><input checked=\"\" disabled=\"\" type=\"checkbox\"> Alert acknowledged</li>\n" +
"<li><input checked=\"\" disabled=\"\" type=\"checkbox\"> On-call notified</li>\n" +
"<li><input disabled=\"\" type=\"checkbox\"> Root cause identified</li>\n" +
"<li><input disabled=\"\" type=\"checkbox\"> Fix deployed</li>\n</ul>\n" +
"<h2>Alert Rule Description</h2>\n" +
"<blockquote>\n<p>This alert fires when CPU usage exceeds 90% for more than 5 minutes on any pod in the api-gateway service.</p>\n" +
"<blockquote>\n<p>For capacity planning guidelines, see the infrastructure runbook section on horizontal pod autoscaling.</p>\n</blockquote>\n</blockquote>\n" +
"<h2>Triggered Query</h2>\n" +
"<pre><code class=\"language-promql\">avg(rate(container_cpu_usage_seconds_total{service=&quot;api-gateway&quot;}[5m])) by (pod) &gt; 0.9\n</code></pre>\n" +
"<h2>Inline Details</h2>\n" +
"<p>This alert was generated by SigNoz using <code>alertmanager</code> rules engine.</p>\n"
assert.Equal(t, expected, html)
}
func TestRenderHTML_InlineFormatting(t *testing.T) {
input := `# 🔥 FIRING: High CPU on api-gateway
## Alert Status
**FIRING** alert for *api-gateway* service — ~~resolved~~ previously.
Metric ` + "`cpu_usage_percent`" + ` exceeded threshold. [View in SigNoz](https://signoz.example.com/alerts/123)
![critical](https://signoz.example.com/badges/critical.svg "Critical Alert")`
html, err := RenderHTML(input)
require.NoError(t, err)
expected := "<h1>🔥 FIRING: High CPU on api-gateway</h1>\n<h2>Alert Status</h2>\n" +
"<p><strong>FIRING</strong> alert for <em>api-gateway</em> service — <del>resolved</del> previously.</p>\n" +
"<p>Metric <code>cpu_usage_percent</code> exceeded threshold. <a href=\"https://signoz.example.com/alerts/123\">View in SigNoz</a></p>\n" +
"<p><img src=\"https://signoz.example.com/badges/critical.svg\" alt=\"critical\" title=\"Critical Alert\"></p>\n"
assert.Equal(t, expected, html)
}
func TestRenderHTML_BlockElements(t *testing.T) {
input := `1. Check CPU usage on the pod
2. Review recent deployments
3. Scale horizontally if needed
* api-gateway
* auth-service
* payment-service
- [x] Alert acknowledged
- [ ] Root cause identified
> This alert fires when CPU usage exceeds 90% for more than 5 minutes.
| Label | Value |
| -------- | ----------- |
| service | api-gateway |
| severity | <no value> |
` + "```promql\navg(rate(container_cpu_usage_seconds_total{service=\"api-gateway\"}[5m])) by (pod) > 0.9\n```"
html, err := RenderHTML(input)
require.NoError(t, err)
expected := "<ol>\n<li>Check CPU usage on the pod</li>\n<li>Review recent deployments</li>\n<li>Scale horizontally if needed</li>\n</ol>\n" +
"<ul>\n<li>api-gateway</li>\n<li>auth-service</li>\n<li>payment-service</li>\n</ul>\n" +
"<ul>\n<li><input checked=\"\" disabled=\"\" type=\"checkbox\"> Alert acknowledged</li>\n" +
"<li><input disabled=\"\" type=\"checkbox\"> Root cause identified</li>\n</ul>\n" +
"<blockquote>\n<p>This alert fires when CPU usage exceeds 90% for more than 5 minutes.</p>\n</blockquote>\n" +
"<table>\n<thead>\n<tr>\n<th>Label</th>\n<th>Value</th>\n</tr>\n</thead>\n" +
"<tbody>\n<tr>\n<td>service</td>\n<td>api-gateway</td>\n</tr>\n" +
"<tr>\n<td>severity</td>\n<td>&lt;no value&gt;</td>\n</tr>\n</tbody>\n</table>\n" +
"<pre><code class=\"language-promql\">avg(rate(container_cpu_usage_seconds_total{service=&quot;api-gateway&quot;}[5m])) by (pod) &gt; 0.9\n</code></pre>\n"
assert.Equal(t, expected, html)
}

View File

@@ -0,0 +1,62 @@
package markdownrenderer
import (
"bytes"
"sync"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/blockkit"
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/mrkdwn"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
)
// htmlRenderer is built once and shared. Goldmark's built-in HTML path and
// the novalue extension carry no mutable state, so a single goldmark.Markdown
// is safe to Convert concurrently.
var htmlRenderer = goldmark.New(goldmark.WithExtensions(extension.GFM, escapeNoValue))
// The Slack renderers hold per-document state on the node renderer (list
// context, table context, style stack, blockquote/list prefixes). Two
// goroutines calling Convert on the same goldmark.Markdown would corrupt
// that state. A sync.Pool gives each concurrent caller its own instance
// while still amortising the cost of building the pipeline.
var (
blockkitPool = sync.Pool{
New: func() any {
return goldmark.New(goldmark.WithExtensions(blockkit.Extender))
},
}
mrkdwnPool = sync.Pool{
New: func() any {
return goldmark.New(goldmark.WithExtensions(mrkdwn.Extender))
},
}
)
// RenderHTML converts markdown to HTML.
func RenderHTML(markdown string) (string, error) {
return render(htmlRenderer, markdown, "HTML")
}
// RenderSlackBlockKit converts markdown to a Slack Block Kit JSON array.
func RenderSlackBlockKit(markdown string) (string, error) {
md := blockkitPool.Get().(goldmark.Markdown)
defer blockkitPool.Put(md)
return render(md, markdown, "Slack Block Kit")
}
// RenderSlackMrkdwn converts markdown to Slack's mrkdwn format.
func RenderSlackMrkdwn(markdown string) (string, error) {
md := mrkdwnPool.Get().(goldmark.Markdown)
defer mrkdwnPool.Put(md)
return render(md, markdown, "Slack mrkdwn")
}
func render(md goldmark.Markdown, markdown string, format string) (string, error) {
var buf bytes.Buffer
if err := md.Convert([]byte(markdown), &buf); err != nil {
return "", errors.WrapInternalf(err, errors.CodeInternal, "failed to convert markdown to %s", format)
}
return buf.String(), nil
}

View File

@@ -0,0 +1,4 @@
// Package mrkdwn provides a goldmark extension that renders markdown as
// Slack's "mrkdwn" text format (the legacy text field used in attachments
// and message payloads).
package mrkdwn

View File

@@ -0,0 +1,404 @@
package mrkdwn
import (
"bytes"
"fmt"
"strings"
"unicode/utf8"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
extensionast "github.com/yuin/goldmark/extension/ast"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/util"
)
// Extender is a goldmark.Extender that registers the Slack mrkdwn node
// renderer together with the GFM extensions it relies on (tables,
// strikethrough).
var Extender goldmark.Extender = &extender{}
type extender struct{}
func (e *extender) Extend(m goldmark.Markdown) {
extension.Table.Extend(m)
extension.Strikethrough.Extend(m)
m.Renderer().AddOptions(
renderer.WithNodeRenderers(util.Prioritized(newRenderer(), 1)),
)
}
// nodeRenderer renders nodes as Slack mrkdwn.
type nodeRenderer struct {
prefixes []string
}
func newRenderer() renderer.NodeRenderer {
return &nodeRenderer{}
}
// RegisterFuncs implements NodeRenderer.RegisterFuncs.
func (r *nodeRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
// Blocks
reg.Register(ast.KindDocument, r.renderDocument)
reg.Register(ast.KindHeading, r.renderHeading)
reg.Register(ast.KindBlockquote, r.renderBlockquote)
reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
reg.Register(ast.KindFencedCodeBlock, r.renderCodeBlock)
reg.Register(ast.KindList, r.renderList)
reg.Register(ast.KindListItem, r.renderListItem)
reg.Register(ast.KindParagraph, r.renderParagraph)
reg.Register(ast.KindTextBlock, r.renderTextBlock)
reg.Register(ast.KindRawHTML, r.renderRawHTML)
reg.Register(ast.KindThematicBreak, r.renderThematicBreak)
// Inlines
reg.Register(ast.KindAutoLink, r.renderAutoLink)
reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
reg.Register(ast.KindEmphasis, r.renderEmphasis)
reg.Register(ast.KindImage, r.renderImage)
reg.Register(ast.KindLink, r.renderLink)
reg.Register(ast.KindText, r.renderText)
// Extensions
reg.Register(extensionast.KindStrikethrough, r.renderStrikethrough)
reg.Register(extensionast.KindTable, r.renderTable)
}
func (r *nodeRenderer) writePrefix(w util.BufWriter) {
for _, p := range r.prefixes {
_, _ = w.WriteString(p)
}
}
// writeLineSeparator writes a newline followed by the current prefix.
// Used for tight separations (e.g., between list items or text blocks).
func (r *nodeRenderer) writeLineSeparator(w util.BufWriter) {
_ = w.WriteByte('\n')
r.writePrefix(w)
}
// writeBlockSeparator writes a blank line separator between block-level elements,
// respecting any active prefixes for proper nesting (e.g., inside blockquotes).
func (r *nodeRenderer) writeBlockSeparator(w util.BufWriter) {
r.writeLineSeparator(w)
r.writeLineSeparator(w)
}
// separateFromPrevious writes a block separator if the node has a previous sibling.
func (r *nodeRenderer) separateFromPrevious(w util.BufWriter, n ast.Node) {
if n.PreviousSibling() != nil {
r.writeBlockSeparator(w)
}
}
func (r *nodeRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
// The renderer is pooled, so wipe any prefix stack left over from a
// prior document (e.g. one that errored mid-walk and left push/pop
// unbalanced) before starting a fresh convert.
r.prefixes = r.prefixes[:0]
} else {
_, _ = w.WriteString("\n\n")
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderHeading(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.separateFromPrevious(w, node)
}
_, _ = w.WriteString("*")
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderBlockquote(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.separateFromPrevious(w, n)
r.prefixes = append(r.prefixes, "> ")
_, _ = w.WriteString("> ")
} else {
r.prefixes = r.prefixes[:len(r.prefixes)-1]
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderCodeBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.separateFromPrevious(w, n)
// start code block and write code line by line
_, _ = w.WriteString("```\n")
l := n.Lines().Len()
for i := 0; i < l; i++ {
line := n.Lines().At(i)
v := line.Value(source)
_, _ = w.Write(v)
}
} else {
_, _ = w.WriteString("```")
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
if node.PreviousSibling() != nil {
r.writeLineSeparator(w)
// another line break if not a nested list item and starting another block
if node.Parent() == nil || node.Parent().Kind() != ast.KindListItem {
r.writeLineSeparator(w)
}
}
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderListItem(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
if n.PreviousSibling() != nil {
r.writeLineSeparator(w)
}
parent := n.Parent().(*ast.List)
// compute and write the prefix based on list type and index
var prefixStr string
if parent.IsOrdered() {
index := parent.Start
for c := parent.FirstChild(); c != nil && c != n; c = c.NextSibling() {
index++
}
prefixStr = fmt.Sprintf("%d. ", index)
} else {
prefixStr = "• "
}
_, _ = w.WriteString(prefixStr)
r.prefixes = append(r.prefixes, "\t") // add tab for nested list items
} else {
r.prefixes = r.prefixes[:len(r.prefixes)-1]
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderParagraph(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.separateFromPrevious(w, n)
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderTextBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering && n.PreviousSibling() != nil {
r.writeLineSeparator(w)
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderRawHTML(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
n := n.(*ast.RawHTML)
l := n.Segments.Len()
for i := 0; i < l; i++ {
segment := n.Segments.At(i)
_, _ = w.Write(segment.Value(source))
}
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderThematicBreak(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.separateFromPrevious(w, n)
_, _ = w.WriteString("---")
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderAutoLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*ast.AutoLink)
url := string(n.URL(source))
label := string(n.Label(source))
if n.AutoLinkType == ast.AutoLinkEmail && !strings.HasPrefix(strings.ToLower(url), "mailto:") {
url = "mailto:" + url
}
if url == label {
_, _ = fmt.Fprintf(w, "<%s>", url)
} else {
_, _ = fmt.Fprintf(w, "<%s|%s>", url, label)
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderCodeSpan(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
_ = w.WriteByte('`')
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
segment := c.(*ast.Text).Segment
value := segment.Value(source)
if bytes.HasSuffix(value, []byte("\n")) { // replace newline with space
_, _ = w.Write(value[:len(value)-1])
_ = w.WriteByte(' ')
} else {
_, _ = w.Write(value)
}
}
return ast.WalkSkipChildren, nil
}
_ = w.WriteByte('`')
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderEmphasis(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*ast.Emphasis)
mark := "_"
if n.Level == 2 {
mark = "*"
}
_, _ = w.WriteString(mark)
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*ast.Link)
if entering {
_, _ = w.WriteString("<")
_, _ = w.Write(util.URLEscape(n.Destination, true))
_, _ = w.WriteString("|")
} else {
_, _ = w.WriteString(">")
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderImage(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*ast.Image)
_, _ = w.WriteString("<")
_, _ = w.Write(util.URLEscape(n.Destination, true))
_, _ = w.WriteString("|")
// Write the alt text directly
var altBuf bytes.Buffer
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
if textNode, ok := c.(*ast.Text); ok {
altBuf.Write(textNode.Segment.Value(source))
}
}
_, _ = w.Write(altBuf.Bytes())
_, _ = w.WriteString(">")
return ast.WalkSkipChildren, nil
}
func (r *nodeRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*ast.Text)
segment := n.Segment
value := segment.Value(source)
_, _ = w.Write(value)
if n.HardLineBreak() || n.SoftLineBreak() {
r.writeLineSeparator(w)
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderStrikethrough(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
_, _ = w.WriteString("~")
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderTable(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
r.separateFromPrevious(w, node)
// Collect cells and max widths
var rows [][]string
var colWidths []int
for c := node.FirstChild(); c != nil; c = c.NextSibling() {
if c.Kind() == extensionast.KindTableHeader || c.Kind() == extensionast.KindTableRow {
var row []string
colIdx := 0
for cc := c.FirstChild(); cc != nil; cc = cc.NextSibling() {
if cc.Kind() == extensionast.KindTableCell {
cellText := extractPlainText(cc, source)
row = append(row, cellText)
runeLen := utf8.RuneCountInString(cellText)
if colIdx >= len(colWidths) {
colWidths = append(colWidths, runeLen)
} else if runeLen > colWidths[colIdx] {
colWidths[colIdx] = runeLen
}
colIdx++
}
}
rows = append(rows, row)
}
}
// writing table in code block
_, _ = w.WriteString("```\n")
for i, row := range rows {
for colIdx, cellText := range row {
width := 0
if colIdx < len(colWidths) {
width = colWidths[colIdx]
}
runeLen := utf8.RuneCountInString(cellText)
padding := max(0, width-runeLen)
_, _ = w.WriteString(cellText)
_, _ = w.WriteString(strings.Repeat(" ", padding))
if colIdx < len(row)-1 {
_, _ = w.WriteString(" | ")
}
}
_ = w.WriteByte('\n')
// Print separator after header
if i == 0 {
for colIdx := range row {
width := 0
if colIdx < len(colWidths) {
width = colWidths[colIdx]
}
_, _ = w.WriteString(strings.Repeat("-", width))
if colIdx < len(row)-1 {
_, _ = w.WriteString("-|-")
}
}
_ = w.WriteByte('\n')
}
}
_, _ = w.WriteString("```")
return ast.WalkSkipChildren, nil
}
// extractPlainText extracts all the text content from the given node.
func extractPlainText(n ast.Node, source []byte) string {
var buf bytes.Buffer
_ = ast.Walk(n, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
if textNode, ok := node.(*ast.Text); ok {
buf.Write(textNode.Segment.Value(source))
} else if strNode, ok := node.(*ast.String); ok {
buf.Write(strNode.Value)
}
return ast.WalkContinue, nil
})
return strings.TrimSpace(buf.String())
}

View File

@@ -0,0 +1,115 @@
package mrkdwn
import (
"bytes"
"testing"
"github.com/yuin/goldmark"
)
func TestRenderer(t *testing.T) {
tests := []struct {
name string
markdown string
expected string
}{
{
name: "Heading with Thematic Break",
markdown: "# Title 1\n# Hello World\n---\nthis is sometext",
expected: "*Title 1*\n\n*Hello World*\n\n---\n\nthis is sometext\n\n",
},
{
name: "Blockquote",
markdown: "> This is a quote\n> It continues",
expected: "> This is a quote\n> It continues\n\n",
},
{
name: "Fenced Code Block",
markdown: "```go\npackage main\nfunc main() {}\n```",
expected: "```\npackage main\nfunc main() {}\n```\n\n",
},
{
name: "Unordered List",
markdown: "- item 1\n- item 2\n- item 3",
expected: "• item 1\n• item 2\n• item 3\n\n",
},
{
name: "nested unordered list",
markdown: "- item 1\n- item 2\n\t- item 2.1\n\t\t- item 2.1.1\n\t\t- item 2.1.2\n\t- item 2.2\n- item 3",
expected: "• item 1\n• item 2\n\t• item 2.1\n\t\t• item 2.1.1\n\t\t• item 2.1.2\n\t• item 2.2\n• item 3\n\n",
},
{
name: "Ordered List",
markdown: "1. item 1\n2. item 2\n3. item 3",
expected: "1. item 1\n2. item 2\n3. item 3\n\n",
},
{
name: "nested ordered list",
markdown: "1. item 1\n2. item 2\n\t1. item 2.1\n\t\t1. item 2.1.1\n\t\t2. item 2.1.2\n\t2. item 2.2\n\t3. item 2.3\n3. item 3\n4. item 4",
expected: "1. item 1\n2. item 2\n\t1. item 2.1\n\t\t1. item 2.1.1\n\t\t2. item 2.1.2\n\t2. item 2.2\n\t3. item 2.3\n3. item 3\n4. item 4\n\n",
},
{
name: "Links and AutoLinks",
markdown: "This is a [link](https://example.com) and an autolink <https://test.com>",
expected: "This is a <https://example.com|link> and an autolink <https://test.com>\n\n",
},
{
name: "Images",
markdown: "An image ![alt text](https://example.com/image.png)",
expected: "An image <https://example.com/image.png|alt text>\n\n",
},
{
name: "Emphasis",
markdown: "This is **bold** and *italic* and __bold__ and _italic_",
expected: "This is *bold* and _italic_ and *bold* and _italic_\n\n",
},
{
name: "Strikethrough",
markdown: "This is ~~strike~~",
expected: "This is ~strike~\n\n",
},
{
name: "Code Span",
markdown: "This is `inline code` embedded.",
expected: "This is `inline code` embedded.\n\n",
},
{
name: "Table",
markdown: "Col 1 | Col 2 | Col 3\n--- | --- | ---\nVal 1 | Long Value 2 | 3\nShort | V | 1000",
expected: "```\nCol 1 | Col 2 | Col 3\n------|--------------|------\nVal 1 | Long Value 2 | 3 \nShort | V | 1000 \n```\n\n",
},
{
name: "Mixed Nested Lists",
markdown: "1. first\n\t- nested bullet\n\t- another bullet\n2. second",
expected: "1. first\n\t• nested bullet\n\t• another bullet\n2. second\n\n",
},
{
name: "Email AutoLink",
markdown: "<user@example.com>",
expected: "<mailto:user@example.com|user@example.com>\n\n",
},
{
name: "No value string parsed as is",
markdown: "Service: <no value>",
expected: "Service: <no value>\n\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
md := goldmark.New(goldmark.WithExtensions(Extender))
var buf bytes.Buffer
if err := md.Convert([]byte(tt.markdown), &buf); err != nil {
t.Fatalf("failed to convert: %v", err)
}
// Do exact string matching
actual := buf.String()
if actual != tt.expected {
t.Errorf("\nExpected:\n%q\nGot:\n%q\nRaw Expected:\n%s\nRaw Got:\n%s",
tt.expected, actual, tt.expected, actual)
}
})
}
}

View File

@@ -0,0 +1,61 @@
package markdownrenderer
import (
"github.com/yuin/goldmark"
gast "github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/util"
)
// Go text/template writes the literal string "<no value>" when a referenced
// key is missing. The default goldmark HTML path treats it as raw HTML and
// drops it, hiding the missing value. This renderer escapes it so operators
// can see which variable was unset in the rendered notification.
type noValueHTMLRenderer struct {
html.Config
}
func newNoValueHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
r := &noValueHTMLRenderer{Config: html.NewConfig()}
for _, opt := range opts {
opt.SetHTMLOption(&r.Config)
}
return r
}
func (r *noValueHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(gast.KindRawHTML, r.renderRawHTML)
}
func (r *noValueHTMLRenderer) renderRawHTML(w util.BufWriter, source []byte, node gast.Node, entering bool) (gast.WalkStatus, error) {
if !entering {
return gast.WalkSkipChildren, nil
}
n := node.(*gast.RawHTML)
if r.Unsafe {
for i := 0; i < n.Segments.Len(); i++ {
segment := n.Segments.At(i)
_, _ = w.Write(segment.Value(source))
}
return gast.WalkSkipChildren, nil
}
if string(n.Segments.Value(source)) == "<no value>" {
_, _ = w.WriteString("&lt;no value&gt;")
return gast.WalkSkipChildren, nil
}
_, _ = w.WriteString("<!-- raw HTML omitted -->")
return gast.WalkSkipChildren, nil
}
type escapeNoValueExtension struct{}
// escapeNoValue renders the literal `<no value>` produced by Go's text/template
// as visible escaped text instead of dropping it as raw HTML.
var escapeNoValue = &escapeNoValueExtension{}
func (e *escapeNoValueExtension) Extend(m goldmark.Markdown) {
m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(newNoValueHTMLRenderer(), 500),
))
}

View File

@@ -0,0 +1,66 @@
package markdownrenderer
import (
"bytes"
"testing"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
)
func TestEscapeNoValue(t *testing.T) {
tests := []struct {
name string
markdown string
expected string
}{
{
name: "plain text",
markdown: "Service: <no value>",
expected: "<p>Service: &lt;no value&gt;</p>\n",
},
{
name: "inside strong",
markdown: "Service: **<no value>**",
expected: "<p>Service: <strong>&lt;no value&gt;</strong></p>\n",
},
{
name: "inside emphasis",
markdown: "Service: *<no value>*",
expected: "<p>Service: <em>&lt;no value&gt;</em></p>\n",
},
{
name: "inside strikethrough",
markdown: "Service: ~~<no value>~~",
expected: "<p>Service: <del>&lt;no value&gt;</del></p>\n",
},
{
name: "real html still omitted",
markdown: "hello <div>world</div>",
expected: "<p>hello <!-- raw HTML omitted -->world<!-- raw HTML omitted --></p>\n",
},
{
name: "inside heading",
markdown: "# Title <no value>",
expected: "<h1>Title &lt;no value&gt;</h1>\n",
},
{
name: "inside list item",
markdown: "- item <no value>",
expected: "<ul>\n<li>item &lt;no value&gt;</li>\n</ul>\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gm := goldmark.New(goldmark.WithExtensions(escapeNoValue, extension.Strikethrough))
var buf bytes.Buffer
if err := gm.Convert([]byte(tt.markdown), &buf); err != nil {
t.Fatal(err)
}
if buf.String() != tt.expected {
t.Errorf("expected:\n%s\ngot:\n%s", tt.expected, buf.String())
}
})
}
}

View File

@@ -0,0 +1,145 @@
package markdownrenderer
import (
"encoding/json"
"testing"
)
func jsonEqual(a, b string) bool {
var va, vb any
if err := json.Unmarshal([]byte(a), &va); err != nil {
return false
}
if err := json.Unmarshal([]byte(b), &vb); err != nil {
return false
}
ja, _ := json.Marshal(va)
jb, _ := json.Marshal(vb)
return string(ja) == string(jb)
}
func prettyJSON(s string) string {
var v any
if err := json.Unmarshal([]byte(s), &v); err != nil {
return s
}
b, _ := json.MarshalIndent(v, "", " ")
return string(b)
}
func TestRenderSlackBlockKit(t *testing.T) {
tests := []struct {
name string
markdown string
expected string
}{
{
name: "simple paragraph",
markdown: "Hello world",
expected: `[
{
"type": "section",
"text": { "type": "mrkdwn", "text": "Hello world" }
}
]`,
},
{
name: "alert-themed with heading, list, and code block",
markdown: `# Alert Triggered
- Service: **checkout-api**
- Status: _critical_
` + "```" + `
error: connection timeout after 30s
` + "```",
expected: `[
{
"type": "section",
"text": { "type": "mrkdwn", "text": "*Alert Triggered*" }
},
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
"elements": [
{ "type": "rich_text_section", "elements": [
{ "type": "text", "text": "Service: " },
{ "type": "text", "text": "checkout-api", "style": { "bold": true } }
]},
{ "type": "rich_text_section", "elements": [
{ "type": "text", "text": "Status: " },
{ "type": "text", "text": "critical", "style": { "italic": true } }
]}
]
}
]
},
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_preformatted",
"border": 0,
"elements": [
{ "type": "text", "text": "error: connection timeout after 30s" }
]
}
]
}
]`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := RenderSlackBlockKit(tt.markdown)
if err != nil {
t.Fatalf("Render error: %v", err)
}
if !json.Valid([]byte(got)) {
t.Fatalf("output is not valid JSON:\n%s", got)
}
if !jsonEqual(got, tt.expected) {
t.Errorf("JSON mismatch\n\nMarkdown:\n%s\n\nExpected:\n%s\n\nGot:\n%s",
tt.markdown, prettyJSON(tt.expected), prettyJSON(got))
}
})
}
}
func TestRenderSlackMrkdwn(t *testing.T) {
markdown := `# Alert Triggered
- Service: **checkout-api**
- Status: _critical_
- Dashboard: [View Dashboard](https://example.com/dashboard)
| Metric | Value | Threshold |
| --- | --- | --- |
| Latency | 250ms | 100ms |
| Error Rate | 5.2% | 1% |
` + "```" + `
error: connection timeout after 30s
` + "```"
expected := "*Alert Triggered*\n\n" +
"• Service: *checkout-api*\n" +
"• Status: _critical_\n" +
"• Dashboard: <https://example.com/dashboard|View Dashboard>\n\n" +
"```\nMetric | Value | Threshold\n-----------|-------|----------\nLatency | 250ms | 100ms \nError Rate | 5.2% | 1% \n```\n\n" +
"```\nerror: connection timeout after 30s\n```\n\n"
got, err := RenderSlackMrkdwn(markdown)
if err != nil {
t.Fatalf("Render error: %v", err)
}
if got != expected {
t.Errorf("mrkdwn mismatch\n\nExpected:\n%q\n\nGot:\n%q", expected, got)
}
}

View File

@@ -0,0 +1,83 @@
// Package templatingtypes holds the data types produced by the markdown
// renderers under pkg/templating (e.g. Slack Block Kit JSON DTOs).
package templatingtypes
// SectionBlock represents a Slack section block with mrkdwn text.
type SectionBlock struct {
Type string `json:"type"`
Text *TextObject `json:"text"`
}
// DividerBlock represents a Slack divider block.
type DividerBlock struct {
Type string `json:"type"`
}
// RichTextBlock is a container for rich text elements (lists, code blocks,
// table and cell blocks).
type RichTextBlock struct {
Type string `json:"type"`
Elements []any `json:"elements"`
}
// TableBlock represents a Slack table rendered as a rich_text block with
// preformatted text.
type TableBlock struct {
Type string `json:"type"`
Rows [][]TableCell `json:"rows"`
}
// TableCell is a cell in a TableBlock.
type TableCell struct {
Type string `json:"type"`
Elements []any `json:"elements"`
}
// TextObject is the text field inside a SectionBlock.
type TextObject struct {
Type string `json:"type"`
Text string `json:"text"`
}
// RichTextList represents an ordered or unordered list.
type RichTextList struct {
Type string `json:"type"`
Style string `json:"style"`
Indent int `json:"indent"`
Border int `json:"border"`
Offset int `json:"offset,omitempty"`
Elements []any `json:"elements"`
}
// RichTextPreformatted represents a code block.
type RichTextPreformatted struct {
Type string `json:"type"`
Elements []any `json:"elements"`
Border int `json:"border"`
Language string `json:"language,omitempty"`
}
// RichTextInline represents inline text with optional styling, e.g. text
// inside a list or a table cell.
type RichTextInline struct {
Type string `json:"type"`
Text string `json:"text"`
Style *RichTextStyle `json:"style,omitempty"`
}
// RichTextLink represents a link inside rich text, e.g. a link inside a list
// or a table cell.
type RichTextLink struct {
Type string `json:"type"`
URL string `json:"url"`
Text string `json:"text,omitempty"`
Style *RichTextStyle `json:"style,omitempty"`
}
// RichTextStyle toggles inline styling on a rich-text element.
type RichTextStyle struct {
Bold bool `json:"bold,omitempty"`
Italic bool `json:"italic,omitempty"`
Strike bool `json:"strike,omitempty"`
Code bool `json:"code,omitempty"`
}