From 48c6395bee9dd419ad8268eefcb8c110db545ab1 Mon Sep 17 00:00:00 2001 From: Peter Zenger Date: Fri, 28 Aug 2026 14:19:15 -0700 Subject: [PATCH 1/6] RPCN description improements --- .../connect-command-palette-utils.test.ts | 25 ---- .../connect-command-palette-utils.ts | 31 ----- .../onboarding/connect-command-palette.tsx | 3 +- .../pipeline/field-description.test.tsx | 111 ++++++++++++++++ .../rp-connect/pipeline/field-description.tsx | 118 +++++++++++++++++ .../rp-connect/pipeline/node-config-form.tsx | 4 +- .../template-gallery/template-schema.ts | 2 +- .../pages/rp-connect/utils/asciidoc.test.ts | 118 +++++++++++++++++ .../pages/rp-connect/utils/asciidoc.ts | 119 ++++++++++++++++++ 9 files changed, 469 insertions(+), 62 deletions(-) create mode 100644 frontend/src/components/pages/rp-connect/pipeline/field-description.test.tsx create mode 100644 frontend/src/components/pages/rp-connect/pipeline/field-description.tsx create mode 100644 frontend/src/components/pages/rp-connect/utils/asciidoc.test.ts create mode 100644 frontend/src/components/pages/rp-connect/utils/asciidoc.ts diff --git a/frontend/src/components/pages/rp-connect/onboarding/connect-command-palette-utils.test.ts b/frontend/src/components/pages/rp-connect/onboarding/connect-command-palette-utils.test.ts index 9d2c20b934..64a3d8c7c8 100644 --- a/frontend/src/components/pages/rp-connect/onboarding/connect-command-palette-utils.test.ts +++ b/frontend/src/components/pages/rp-connect/onboarding/connect-command-palette-utils.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest'; import { aliasTermsForName, - asciidocToMarkdown, buildEmptyMessage, byProminence, COMPONENT_ALIASES, @@ -117,30 +116,6 @@ describe('searchableText', () => { }); }); -describe('asciidocToMarkdown', () => { - it('turns AsciiDoc section titles into Markdown headings instead of leaking "=="', () => { - const out = asciidocToMarkdown('== Performance\nThis output benefits from batching.'); - expect(out).toBe('#### Performance\nThis output benefits from batching.'); - expect(out).not.toContain('== '); - }); - - it('handles multiple heading levels and keeps paragraphs separated', () => { - const out = asciidocToMarkdown('Intro paragraph.\n\n=== Delivery Guarantees\nAt least once.'); - expect(out).toBe('Intro paragraph.\n\n#### Delivery Guarantees\nAt least once.'); - }); - - it('converts link/xref macros to label text and bare URL macros to Markdown links', () => { - expect(asciidocToMarkdown('See xref:guides:about.adoc[the guide] for details.')).toBe('See the guide for details.'); - expect(asciidocToMarkdown('Uses https://github.com/twmb/franz-go[franz-go] under the hood.')).toBe( - 'Uses [franz-go](https://github.com/twmb/franz-go) under the hood.' - ); - }); - - it('converts AsciiDoc bullets to Markdown list items', () => { - expect(asciidocToMarkdown('* first\n* second')).toBe('- first\n- second'); - }); -}); - describe('buildEmptyMessage', () => { it('reports an empty catalog when there is no query', () => { expect(buildEmptyMessage('')).toBe('No components available.'); diff --git a/frontend/src/components/pages/rp-connect/onboarding/connect-command-palette-utils.ts b/frontend/src/components/pages/rp-connect/onboarding/connect-command-palette-utils.ts index 66bd10c730..6d7505c694 100644 --- a/frontend/src/components/pages/rp-connect/onboarding/connect-command-palette-utils.ts +++ b/frontend/src/components/pages/rp-connect/onboarding/connect-command-palette-utils.ts @@ -174,37 +174,6 @@ export function matchRank(component: ConnectComponentSpec, query: string, text: return text.includes(query) ? 3 : -1; } -// Reduce one-line AsciiDoc summaries (link macros, code spans) to plain label text on a single line. -export function cleanText(text: string): string { - return text - .replace(/(?:xref|link):[^\s[]*\[([^\]]*)\]/g, '$1') - .replace(/https?:\/\/[^\s[]+\[([^\]]*)\]/g, '$1') - .replace(/`([^`]+)`/g, '$1') - .replace(/\s+/g, ' ') - .trim(); -} - -// Convert the AsciiDoc constructs Connect uses (titles, link macros, bullets) to Markdown for react-markdown. -// Unlike cleanText, newlines are preserved so titles/paragraphs stay distinct. -export function asciidocToMarkdown(raw: string): string { - return ( - raw - .replace(/\r\n/g, '\n') - // Link macros → label text. - .replace(/(?:xref|link):[^\s[\]]*\[([^\]]*)\]/g, '$1') - // Bare URL macro → Markdown link. - .replace(/(https?:\/\/[^\s[\]]+)\[([^\]]*)\]/g, '[$2]($1)') - // Section titles (`==`/`===`/… Title) → small heading. - .replace(/^=+\s+(.{1,60})$/gm, '#### $1') - // Strip leftover markers from over-long titles. - .replace(/^=+\s+/gm, '') - // List markers → bullets. - .replace(/^\*\s+/gm, '- ') - .replace(/\n{3,}/g, '\n\n') - .trim() - ); -} - const STARTS_WITH_VOWEL_REGEX = /^[aeiou]/; // Humanize + pluralize snake_case labels, joined with "or": ['cache', 'rate_limit'] → 'caches or rate limits'. diff --git a/frontend/src/components/pages/rp-connect/onboarding/connect-command-palette.tsx b/frontend/src/components/pages/rp-connect/onboarding/connect-command-palette.tsx index 9061da07be..fcb8ef5b68 100644 --- a/frontend/src/components/pages/rp-connect/onboarding/connect-command-palette.tsx +++ b/frontend/src/components/pages/rp-connect/onboarding/connect-command-palette.tsx @@ -21,10 +21,8 @@ import ReactMarkdown, { type Components } from 'react-markdown'; import { pluralizeWithNumber } from 'utils/string'; import { - asciidocToMarkdown, buildEmptyMessage, byProminence, - cleanText, computeSuggested, matchRank, pushRecent, @@ -33,6 +31,7 @@ import { } from './connect-command-palette-utils'; import { ConnectorLogo } from './connector-logo'; import type { ConnectComponentSpec, ConnectComponentType, ExtendedConnectComponentSpec } from '../types/schema'; +import { asciidocToMarkdown, cleanText } from '../utils/asciidoc'; import { getCategoryDisplayName } from '../utils/categories'; import { getConnectorDocsUrl } from '../utils/connector-docs'; import { componentStatusToString, parseSchema } from '../utils/schema'; diff --git a/frontend/src/components/pages/rp-connect/pipeline/field-description.test.tsx b/frontend/src/components/pages/rp-connect/pipeline/field-description.test.tsx new file mode 100644 index 0000000000..aa57b7bb8c --- /dev/null +++ b/frontend/src/components/pages/rp-connect/pipeline/field-description.test.tsx @@ -0,0 +1,111 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import userEvent from '@testing-library/user-event'; +import { render, screen } from 'test-utils'; +import { describe, expect, test } from 'vitest'; + +import { FieldDescription } from './field-description'; +import type { RawFieldSpec } from '../types/schema'; + +const field = (overrides: Partial): RawFieldSpec => + ({ name: 'topics', type: 'string', kind: 'scalar', ...overrides }) as RawFieldSpec; + +// The `input: redpanda` topics field: a one-line short description over a multi-paragraph AsciiDoc one. +const LONG_TOPICS_DESCRIPTION = + '\nA list of topics to consume from. Multiple comma separated topics can be listed in a single element. ' + + 'When a `consumer_group` is specified partitions are automatically distributed across consumers of a topic, ' + + 'otherwise all partitions are consumed.\n\nAlternatively, it is possible to specify explicit partitions.'; + +const SHORT_TOPICS_DESCRIPTION = + 'A list of topics to consume from. Multiple comma-separated topics may share one element.'; + +const SHOW_MORE_RE = /show more/i; +const ALTERNATIVELY_RE = /Alternatively, it is possible/; +const TOPICS_LEAD_RE = /A list of topics to consume from/; +const SHOW_LESS_RE = /show less/i; + +describe('FieldDescription', () => { + test('prefers the short description over the long one', () => { + render( + + ); + + expect(screen.getByText(SHORT_TOPICS_DESCRIPTION)).toBeInTheDocument(); + expect(screen.queryByText(ALTERNATIVELY_RE)).not.toBeInTheDocument(); + // A one-liner needs no expander. + expect(screen.queryByRole('button', { name: SHOW_MORE_RE })).not.toBeInTheDocument(); + }); + + test('falls back to the long description when no short one is served', () => { + render(); + + expect(screen.getByText(TOPICS_LEAD_RE)).toBeInTheDocument(); + }); + + test('treats a blank short description as absent', () => { + render( + + ); + + expect(screen.getByText('An identifier for the client.')).toBeInTheDocument(); + }); + + test('renders nothing when the field carries no prose', () => { + const { container } = render(); + + // The render wrapper contributes a hidden Chakra env node, so assert on visible text. + expect(container.textContent).toBe(''); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + test('collapses a long fallback description behind an expander', async () => { + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole('button', { name: SHOW_MORE_RE }); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + // Collapsed is the flattened single-line rendering, so the trailing paragraph is clipped by CSS + // but the code span markup is already gone. + expect(screen.queryByText('consumer_group', { selector: 'code' })).not.toBeInTheDocument(); + + await user.click(toggle); + + expect(screen.getByRole('button', { name: SHOW_LESS_RE })).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByText('consumer_group', { selector: 'code' })).toBeInTheDocument(); + expect(screen.getByText(ALTERNATIVELY_RE)).toBeInTheDocument(); + }); + + test('renders a short fallback description as Markdown with no expander', () => { + render(); + + expect(screen.getByText('topic', { selector: 'code' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: SHOW_MORE_RE })).not.toBeInTheDocument(); + }); + + test('links a bare URL macro out of the AsciiDoc source', () => { + render( + + ); + + const link = screen.getByRole('link', { name: 'franz-go' }); + expect(link).toHaveAttribute('href', 'https://github.com/twmb/franz-go'); + expect(link).toHaveAttribute('target', '_blank'); + }); + + test('keeps angle-bracket placeholders that Markdown would otherwise swallow', () => { + render(`.' })} />); + + expect(screen.getByText('cdc_metadata_', { selector: 'code' })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/pages/rp-connect/pipeline/field-description.tsx b/frontend/src/components/pages/rp-connect/pipeline/field-description.tsx new file mode 100644 index 0000000000..8ddda31e98 --- /dev/null +++ b/frontend/src/components/pages/rp-connect/pipeline/field-description.tsx @@ -0,0 +1,118 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { Link } from 'components/redpanda-ui/components/typography'; +import { useId, useMemo, useState } from 'react'; +import ReactMarkdown, { type Components } from 'react-markdown'; + +import type { RawFieldSpec } from '../types/schema'; +import { asciidocToMarkdown, asciidocToPlainText } from '../utils/asciidoc'; + +/** + * Longer than this (or spanning paragraphs) and the description is collapsed behind "Show more": + * schema descriptions run to a few thousand characters, which buries the control it belongs to. + * Short descriptions cap at ~140 and are never clamped. + */ +const CLAMP_OVER_CHARS = 200; + +// Field prose sits under its control, so headings collapse to the same compact label as the body. +const MarkdownHeading = ({ children }: { children?: React.ReactNode }) => ( +
{children}
+); + +const MARKDOWN_COMPONENTS: Components = { + h1: MarkdownHeading, + h2: MarkdownHeading, + h3: MarkdownHeading, + h4: MarkdownHeading, + h5: MarkdownHeading, + h6: MarkdownHeading, + p: ({ children }) =>
{children}
, + a: ({ href, children }) => ( + + {children} + + ), + code: ({ children }) => ( + // break-words, not break-all: only unbreakable strings (DSNs, URLs) wrap, short tokens stay whole. + {children} + ), + ul: ({ children }) =>
    {children}
, + ol: ({ children }) => ( +
    {children}
+ ), + li: ({ children }) =>
  • {children}
  • , + strong: ({ children }) => {children}, +}; + +const MarkdownBody = ({ markdown }: { markdown: string }) => ( +
    + {markdown} +
    +); + +/** AsciiDoc `description`, rendered as Markdown and collapsed to two lines when it runs long. */ +const LongDescription = ({ source }: { source: string }) => { + const [expanded, setExpanded] = useState(false); + const bodyId = useId(); + const { markdown, preview, clampable } = useMemo(() => { + const converted = asciidocToMarkdown(source); + const plain = asciidocToPlainText(source); + return { + markdown: converted, + preview: plain, + clampable: plain.length > CLAMP_OVER_CHARS || converted.includes('\n'), + }; + }, [source]); + + if (!clampable) { + return ; + } + + return ( +
    +
    + {expanded ? ( + + ) : ( + // Collapsed shows the plain-text rendering: one text node, so line-clamp applies cleanly. +
    {preview}
    + )} +
    + +
    + ); +}; + +/** + * Help text under a config control. Prefers the schema's `short_description` — a markup-free + * one-liner written for inline display — and falls back to the AsciiDoc `description`, which most + * fields are still limited to. + */ +export const FieldDescription = ({ spec }: { spec: RawFieldSpec }) => { + const short = spec.shortDescription?.trim(); + if (short) { + return
    {short}
    ; + } + const description = spec.description?.trim(); + if (!description) { + return null; + } + return ; +}; diff --git a/frontend/src/components/pages/rp-connect/pipeline/node-config-form.tsx b/frontend/src/components/pages/rp-connect/pipeline/node-config-form.tsx index e378068fa6..2876a0fe06 100644 --- a/frontend/src/components/pages/rp-connect/pipeline/node-config-form.tsx +++ b/frontend/src/components/pages/rp-connect/pipeline/node-config-form.tsx @@ -32,6 +32,7 @@ import { type Control, Controller, type FieldPath, useForm, useWatch } from 'rea import { useListTopicsQuery } from 'react-query/api/topic'; import { parse as parseYaml, stringify as yamlStringify } from 'yaml'; +import { FieldDescription } from './field-description'; import type { FieldLintErrors } from './lint-field-mapping'; import { ScrollShadow } from './scroll-shadow'; import { getSecretSyntax, REDPANDA_TOPIC_AND_USER_COMPONENTS } from '../types/constants'; @@ -551,9 +552,6 @@ const FieldLabel = ({ spec, htmlFor }: { spec: RawFieldSpec; htmlFor?: string }) ); -const FieldDescription = ({ spec }: { spec: RawFieldSpec }) => - spec.description ?
    {spec.description}
    : null; - // Mask fields the schema flags as secret (stamped from the raw config schema; the proto has no // secret field), plus a name heuristic as the union — the flag misses plausibly-sensitive fields // like AWS session tokens and SAS tokens, and the heuristic is all we have on older dataplanes. diff --git a/frontend/src/components/pages/rp-connect/template-gallery/template-schema.ts b/frontend/src/components/pages/rp-connect/template-gallery/template-schema.ts index 41e7fc6da8..e170bf0dcb 100644 --- a/frontend/src/components/pages/rp-connect/template-gallery/template-schema.ts +++ b/frontend/src/components/pages/rp-connect/template-gallery/template-schema.ts @@ -47,7 +47,7 @@ export function applySchemaToSlots(template: PipelineTemplate, components?: Conn const merged: TemplateSlot = { ...slot, - description: slot.description ?? (field.description || undefined), + description: slot.description ?? (field.shortDescription || field.description || undefined), required: slot.required ?? checkRequired(field), }; diff --git a/frontend/src/components/pages/rp-connect/utils/asciidoc.test.ts b/frontend/src/components/pages/rp-connect/utils/asciidoc.test.ts new file mode 100644 index 0000000000..3a6c831675 --- /dev/null +++ b/frontend/src/components/pages/rp-connect/utils/asciidoc.test.ts @@ -0,0 +1,118 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { describe, expect, it } from 'vitest'; + +import { asciidocToMarkdown, asciidocToPlainText, cleanText } from './asciidoc'; + +describe('asciidocToMarkdown', () => { + it('turns AsciiDoc section titles into Markdown headings instead of leaking "=="', () => { + const out = asciidocToMarkdown('== Performance\nThis output benefits from batching.'); + expect(out).toBe('#### Performance\nThis output benefits from batching.'); + expect(out).not.toContain('== '); + }); + + it('handles multiple heading levels and keeps paragraphs separated', () => { + const out = asciidocToMarkdown('Intro paragraph.\n\n=== Delivery Guarantees\nAt least once.'); + expect(out).toBe('Intro paragraph.\n\n#### Delivery Guarantees\nAt least once.'); + }); + + it('converts link/xref macros to label text and bare URL macros to Markdown links', () => { + expect(asciidocToMarkdown('See xref:guides:about.adoc[the guide] for details.')).toBe('See the guide for details.'); + expect(asciidocToMarkdown('Uses https://github.com/twmb/franz-go[franz-go] under the hood.')).toBe( + 'Uses [franz-go](https://github.com/twmb/franz-go) under the hood.' + ); + }); + + it('converts AsciiDoc bullets to Markdown list items', () => { + expect(asciidocToMarkdown('* first\n* second')).toBe('- first\n- second'); + }); + + // Every AWS `credentials` field ends "…can be found in xref:guides:cloud/aws.adoc[]."; dropping + // the macro outright left the sentence as "…can be found in .". + it('names a target for an empty-label xref rather than leaving dangling punctuation', () => { + expect(asciidocToMarkdown('More information can be found in xref:guides:cloud/aws.adoc[].')).toBe( + 'More information can be found in the documentation.' + ); + }); + + it('keeps a macro label that contains escaped brackets, dropping the new-window flag', () => { + // AsciiDoc escapes `]` inside a label, and a trailing `^` means "open in a new window". + const source = 'See https://example.com/dsn[`http[s\\]://user[:pass\\]`^] here.'; + expect(asciidocToMarkdown(source)).toBe('See [`http[s]://user[:pass]`](https://example.com/dsn) here.'); + }); + + it('escapes angle-bracket placeholders so Markdown does not swallow them as HTML', () => { + const out = asciidocToMarkdown("Requests must include 'authorization: Bearer ' metadata."); + expect(out).toBe(String.raw`Requests must include 'authorization: Bearer \' metadata.`); + }); + + it('leaves placeholders inside code spans untouched', () => { + expect(asciidocToMarkdown('Defaults to `redpanda_connect_jira_input_`.')).toBe( + 'Defaults to `redpanda_connect_jira_input_`.' + ); + }); + + it('flattens AsciiDoc tables to bullets and drops docs attribute lines', () => { + const source = [ + 'A Data Source Name.', + '', + ':driver-support: mysql=certified, postgres=certified', + '', + '|===', + '| Driver | Data Source Name Format', + '', + '| `mysql`', + '| `[username[:password]@]/dbname`', + '|===', + ].join('\n'); + expect(asciidocToMarkdown(source)).toBe( + [ + 'A Data Source Name.', + '', + '- Driver — Data Source Name Format', + '', + '- `mysql`', + '- `[username[:password]@]/dbname`', + ].join('\n') + ); + }); + + it('trims the leading newline that many field descriptions start with', () => { + expect(asciidocToMarkdown('\nA list of topics to consume from.')).toBe('A list of topics to consume from.'); + }); +}); + +describe('asciidocToPlainText', () => { + it('reduces converted Markdown to a single line without syntax', () => { + expect(asciidocToPlainText('\nUse `consumer_group` to share load.\n\n== Notes\n* first')).toBe( + 'Use consumer_group to share load. Notes first' + ); + }); + + it('keeps link labels and unescapes placeholders', () => { + expect(asciidocToPlainText('See https://example.com[the docs] for usage.')).toBe( + 'See the docs for usage.' + ); + }); +}); + +describe('cleanText', () => { + it('strips code spans and macros down to one line', () => { + expect(cleanText('Sends to `redpanda`\nvia xref:guides:about.adoc[the guide].')).toBe( + 'Sends to redpanda via the guide.' + ); + }); + + it('substitutes a label for an empty-label xref', () => { + expect(cleanText('Found in xref:guides:cloud/aws.adoc[].')).toBe('Found in the documentation.'); + }); +}); diff --git a/frontend/src/components/pages/rp-connect/utils/asciidoc.ts b/frontend/src/components/pages/rp-connect/utils/asciidoc.ts new file mode 100644 index 0000000000..6da9f9da73 --- /dev/null +++ b/frontend/src/components/pages/rp-connect/utils/asciidoc.ts @@ -0,0 +1,119 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +/** + * Redpanda Connect schema prose is AsciiDoc, not Markdown: `xref:`/`link:` macros, `url[label]` + * macros, `==` section titles, `|===` tables, `:attr:` lines, and backticks for monospace. Applies + * to component `summary`/`description` and per-field `description`. Field `short_description`s carry + * no markup by contract and must not be run through any of this. + */ + +// A macro label, allowing AsciiDoc's `\]` escape: either an escaped pair or a plain char. +const MACRO_LABEL = String.raw`((?:\\.|[^\]\\])*)`; +const XREF_MACRO = new RegExp(String.raw`(?:xref|link):[^\s[\]]*\[${MACRO_LABEL}\]`, 'g'); +const URL_MACRO = new RegExp(String.raw`(https?://[^\s[\]]+)\[${MACRO_LABEL}\]`, 'g'); + +// Every empty-label xref in the schema reads "More information can be found in xref:…[].", so a +// generic noun keeps the sentence intact where dropping the macro would leave a dangling "in .". +const EMPTY_XREF_LABEL = 'the documentation'; + +const NEW_WINDOW_FLAG = /\^$/; +const TABLE_CELL_LINE = /^\|\s*(.*)$/; +const TABLE_CELL_SEPARATOR = /\s+\|\s+/; + +// `\]` escapes the bracket; a trailing `^` is AsciiDoc's "open in a new window" flag, not text. +function macroLabel(label: string): string { + return label.replace(/\\(.)/g, '$1').replace(NEW_WINDOW_FLAG, '').trim(); +} + +/** + * Flattens `|===` tables to bullets. The schema's tables are one `| cell` per line with blank lines + * between rows, so the row grouping survives; a real Markdown table isn't worth the conversion for + * the handful of fields (sql_* DSN formats) that use one. + */ +function flattenTables(text: string): string { + if (!text.includes('|===')) { + return text; + } + return text + .split('\n') + .filter((line) => line.trim() !== '|===') + .map((line) => { + const cells = TABLE_CELL_LINE.exec(line.trimEnd()); + if (!cells) { + return line; + } + const joined = cells[1].split(TABLE_CELL_SEPARATOR).join(' — ').trim(); + return joined ? `- ${joined}` : ''; + }) + .join('\n'); +} + +/** + * Escapes `` spans outside code so Markdown keeps them: remark parses `` as inline + * HTML and, with raw HTML disabled, drops it — silently corrupting the DSN and header examples that + * use angle-bracket placeholders. Autolinks (``) are left alone. + */ +function escapePlaceholders(text: string): string { + return text + .split(/(```[\s\S]*?```|`[^`\n]*`)/g) + .map((part, index) => (index % 2 === 1 ? part : part.replace(/<(?!https?:\/\/)(?=[a-zA-Z/])/g, String.raw`\<`))) + .join(''); +} + +/** Reduces one-line AsciiDoc prose (link macros, code spans) to plain label text on a single line. */ +export function cleanText(text: string): string { + return text + .replace(XREF_MACRO, (_match, label: string) => macroLabel(label) || EMPTY_XREF_LABEL) + .replace(URL_MACRO, (_match, _url: string, label: string) => macroLabel(label)) + .replace(/`([^`]+)`/g, '$1') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Converts the AsciiDoc constructs Connect uses to Markdown for react-markdown. Unlike + * {@link cleanText}, newlines are preserved so titles and paragraphs stay distinct. + */ +export function asciidocToMarkdown(raw: string): string { + return escapePlaceholders( + flattenTables(raw.replace(/\r\n/g, '\n')) + // Attribute definitions configure the docs build; they render as noise. + .replace(/^:[a-zA-Z][\w-]*:.*$/gm, '') + // Link macros → label text. + .replace(XREF_MACRO, (_match, label: string) => macroLabel(label) || EMPTY_XREF_LABEL) + // Bare URL macro → Markdown link. + .replace(URL_MACRO, (_match, url: string, label: string) => { + const text = macroLabel(label); + return text ? `[${text}](${url})` : url; + }) + // Section titles (`==`/`===`/… Title) → small heading. + .replace(/^=+\s+(.{1,60})$/gm, '#### $1') + // Strip leftover markers from over-long titles. + .replace(/^=+\s+/gm, '') + // List markers → bullets. + .replace(/^\*\s+/gm, '- ') + .replace(/\n{3,}/g, '\n\n') + .trim() + ); +} + +/** Single-line plain text for collapsed previews: the converted Markdown with its syntax removed. */ +export function asciidocToPlainText(raw: string): string { + return asciidocToMarkdown(raw) + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/^#+\s*/gm, '') + .replace(/^[-*]\s+/gm, '') + .replace(/\\([<>])/g, '$1') + .replace(/[`*]/g, '') + .replace(/\s+/g, ' ') + .trim(); +} From 381bd88a8c378e71803880f7da83b349f58e4a50 Mon Sep 17 00:00:00 2001 From: Peter Zenger Date: Fri, 28 Aug 2026 15:05:13 -0700 Subject: [PATCH 2/6] Improving description rendering --- .../pipeline/field-description.test.tsx | 5 +- .../rp-connect/pipeline/field-description.tsx | 18 ++---- .../template-gallery/template-schema.ts | 4 +- .../pages/rp-connect/utils/asciidoc.test.ts | 38 +++++++++-- .../pages/rp-connect/utils/asciidoc.ts | 64 ++++++++++++------- 5 files changed, 84 insertions(+), 45 deletions(-) diff --git a/frontend/src/components/pages/rp-connect/pipeline/field-description.test.tsx b/frontend/src/components/pages/rp-connect/pipeline/field-description.test.tsx index aa57b7bb8c..0024d29d0d 100644 --- a/frontend/src/components/pages/rp-connect/pipeline/field-description.test.tsx +++ b/frontend/src/components/pages/rp-connect/pipeline/field-description.test.tsx @@ -64,7 +64,7 @@ describe('FieldDescription', () => { test('renders nothing when the field carries no prose', () => { const { container } = render(); - // The render wrapper contributes a hidden Chakra env node, so assert on visible text. + // The render wrapper adds a hidden Chakra node, so assert on text rather than an empty DOM. expect(container.textContent).toBe(''); expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); @@ -75,8 +75,7 @@ describe('FieldDescription', () => { const toggle = screen.getByRole('button', { name: SHOW_MORE_RE }); expect(toggle).toHaveAttribute('aria-expanded', 'false'); - // Collapsed is the flattened single-line rendering, so the trailing paragraph is clipped by CSS - // but the code span markup is already gone. + // Collapsed is the flattened single-line rendering: no code spans, trailing paragraph clipped by CSS. expect(screen.queryByText('consumer_group', { selector: 'code' })).not.toBeInTheDocument(); await user.click(toggle); diff --git a/frontend/src/components/pages/rp-connect/pipeline/field-description.tsx b/frontend/src/components/pages/rp-connect/pipeline/field-description.tsx index 8ddda31e98..d8cbad215f 100644 --- a/frontend/src/components/pages/rp-connect/pipeline/field-description.tsx +++ b/frontend/src/components/pages/rp-connect/pipeline/field-description.tsx @@ -14,13 +14,10 @@ import { useId, useMemo, useState } from 'react'; import ReactMarkdown, { type Components } from 'react-markdown'; import type { RawFieldSpec } from '../types/schema'; -import { asciidocToMarkdown, asciidocToPlainText } from '../utils/asciidoc'; +import { asciidocToMarkdown, markdownToPlainText } from '../utils/asciidoc'; -/** - * Longer than this (or spanning paragraphs) and the description is collapsed behind "Show more": - * schema descriptions run to a few thousand characters, which buries the control it belongs to. - * Short descriptions cap at ~140 and are never clamped. - */ +// Descriptions longer than this (or spanning paragraphs) collapse behind "Show more"; schema prose +// runs to a few thousand characters and buries the control it belongs to. const CLAMP_OVER_CHARS = 200; // Field prose sits under its control, so headings collapse to the same compact label as the body. @@ -42,7 +39,7 @@ const MARKDOWN_COMPONENTS: Components = { ), code: ({ children }) => ( - // break-words, not break-all: only unbreakable strings (DSNs, URLs) wrap, short tokens stay whole. + // break-words, not break-all: only unbreakable strings (DSNs, URLs) wrap mid-token. {children} ), ul: ({ children }) =>
      {children}
    , @@ -65,7 +62,7 @@ const LongDescription = ({ source }: { source: string }) => { const bodyId = useId(); const { markdown, preview, clampable } = useMemo(() => { const converted = asciidocToMarkdown(source); - const plain = asciidocToPlainText(source); + const plain = markdownToPlainText(converted); return { markdown: converted, preview: plain, @@ -83,7 +80,7 @@ const LongDescription = ({ source }: { source: string }) => { {expanded ? ( ) : ( - // Collapsed shows the plain-text rendering: one text node, so line-clamp applies cleanly. + // Plain text collapsed: one text node, so line-clamp applies cleanly.
    {preview}
    )} @@ -102,8 +99,7 @@ const LongDescription = ({ source }: { source: string }) => { /** * Help text under a config control. Prefers the schema's `short_description` — a markup-free - * one-liner written for inline display — and falls back to the AsciiDoc `description`, which most - * fields are still limited to. + * one-liner — and falls back to the AsciiDoc `description` that most fields are still limited to. */ export const FieldDescription = ({ spec }: { spec: RawFieldSpec }) => { const short = spec.shortDescription?.trim(); diff --git a/frontend/src/components/pages/rp-connect/template-gallery/template-schema.ts b/frontend/src/components/pages/rp-connect/template-gallery/template-schema.ts index e170bf0dcb..69cbe21704 100644 --- a/frontend/src/components/pages/rp-connect/template-gallery/template-schema.ts +++ b/frontend/src/components/pages/rp-connect/template-gallery/template-schema.ts @@ -11,6 +11,7 @@ import type { PipelineTemplate, TemplateSlot } from './pipeline-template-types'; import type { ConnectComponentSpec } from '../types/schema'; +import { cleanText } from '../utils/asciidoc'; import { checkRequired, findConnectComponent, resolveFieldByPath } from '../utils/schema'; // Slot-level values win; schema only fills unset `description` / `required` / @@ -47,7 +48,8 @@ export function applySchemaToSlots(template: PipelineTemplate, components?: Conn const merged: TemplateSlot = { ...slot, - description: slot.description ?? (field.shortDescription || field.description || undefined), + // Slot descriptions render as plain text, so the AsciiDoc fallback has to be flattened. + description: slot.description ?? (field.shortDescription || cleanText(field.description ?? '') || undefined), required: slot.required ?? checkRequired(field), }; diff --git a/frontend/src/components/pages/rp-connect/utils/asciidoc.test.ts b/frontend/src/components/pages/rp-connect/utils/asciidoc.test.ts index 3a6c831675..f88220055c 100644 --- a/frontend/src/components/pages/rp-connect/utils/asciidoc.test.ts +++ b/frontend/src/components/pages/rp-connect/utils/asciidoc.test.ts @@ -11,7 +11,7 @@ import { describe, expect, it } from 'vitest'; -import { asciidocToMarkdown, asciidocToPlainText, cleanText } from './asciidoc'; +import { asciidocToMarkdown, cleanText, markdownToPlainText } from './asciidoc'; describe('asciidocToMarkdown', () => { it('turns AsciiDoc section titles into Markdown headings instead of leaking "=="', () => { @@ -36,8 +36,7 @@ describe('asciidocToMarkdown', () => { expect(asciidocToMarkdown('* first\n* second')).toBe('- first\n- second'); }); - // Every AWS `credentials` field ends "…can be found in xref:guides:cloud/aws.adoc[]."; dropping - // the macro outright left the sentence as "…can be found in .". + // Every AWS `credentials` field ends "…can be found in xref:guides:cloud/aws.adoc[].". it('names a target for an empty-label xref rather than leaving dangling punctuation', () => { expect(asciidocToMarkdown('More information can be found in xref:guides:cloud/aws.adoc[].')).toBe( 'More information can be found in the documentation.' @@ -86,20 +85,47 @@ describe('asciidocToMarkdown', () => { ); }); + // The Debezium type table writes rows as `|Type Name |Bloblang Type`, with no space after the + // cell marker. + it('splits table cells that are not padded around the marker', () => { + const source = ['.Debezium Custom Temporal Types', '|===', '|Type Name |Bloblang Type', '|==='].join('\n'); + expect(asciidocToMarkdown(source)).toBe( + ['#### Debezium Custom Temporal Types', '- Type Name — Bloblang Type'].join('\n') + ); + }); + + it('leaves a pipe in prose alone even when the description also has a table', () => { + const source = ['Splits on | characters.', '', '|===', '|a |b', '|==='].join('\n'); + expect(asciidocToMarkdown(source)).toBe(['Splits on | characters.', '', '- a — b'].join('\n')); + }); + + it('drops block delimiters that would promote the prose around them to a heading', () => { + const source = [ + '[CAUTION]', + '.Endpoint caveats', + '====', + 'Endpoints register in a non-deterministic order.', + '====', + ].join('\n'); + expect(asciidocToMarkdown(source)).toBe( + ['**CAUTION**', '#### Endpoint caveats', '', 'Endpoints register in a non-deterministic order.'].join('\n') + ); + }); + it('trims the leading newline that many field descriptions start with', () => { expect(asciidocToMarkdown('\nA list of topics to consume from.')).toBe('A list of topics to consume from.'); }); }); -describe('asciidocToPlainText', () => { +describe('markdownToPlainText', () => { it('reduces converted Markdown to a single line without syntax', () => { - expect(asciidocToPlainText('\nUse `consumer_group` to share load.\n\n== Notes\n* first')).toBe( + expect(markdownToPlainText(asciidocToMarkdown('\nUse `consumer_group` to share load.\n\n== Notes\n* first'))).toBe( 'Use consumer_group to share load. Notes first' ); }); it('keeps link labels and unescapes placeholders', () => { - expect(asciidocToPlainText('See https://example.com[the docs] for usage.')).toBe( + expect(markdownToPlainText(asciidocToMarkdown('See https://example.com[the docs] for usage.'))).toBe( 'See the docs for usage.' ); }); diff --git a/frontend/src/components/pages/rp-connect/utils/asciidoc.ts b/frontend/src/components/pages/rp-connect/utils/asciidoc.ts index 6da9f9da73..1edf35165d 100644 --- a/frontend/src/components/pages/rp-connect/utils/asciidoc.ts +++ b/frontend/src/components/pages/rp-connect/utils/asciidoc.ts @@ -21,13 +21,20 @@ const MACRO_LABEL = String.raw`((?:\\.|[^\]\\])*)`; const XREF_MACRO = new RegExp(String.raw`(?:xref|link):[^\s[\]]*\[${MACRO_LABEL}\]`, 'g'); const URL_MACRO = new RegExp(String.raw`(https?://[^\s[\]]+)\[${MACRO_LABEL}\]`, 'g'); -// Every empty-label xref in the schema reads "More information can be found in xref:…[].", so a -// generic noun keeps the sentence intact where dropping the macro would leave a dangling "in .". +// Keeps "More information can be found in xref:…[]." from rendering as "…found in .". const EMPTY_XREF_LABEL = 'the documentation'; const NEW_WINDOW_FLAG = /\^$/; -const TABLE_CELL_LINE = /^\|\s*(.*)$/; -const TABLE_CELL_SEPARATOR = /\s+\|\s+/; +const ATTRIBUTE_LINE = /^:[a-zA-Z][\w-]*:.*$/gm; +const TABLE_DELIMITER = /^\s*\|===\s*$/; +const LEADING_CELL_MARKER = /^\|\s*/; +const TABLE_CELL_SEPARATOR = /\s*\|\s*/; +// Admonition markers keep their label; every other block attribute (`[source,yaml]`) is docs noise. +const ADMONITION_LINE = /^\[(NOTE|TIP|WARNING|IMPORTANT|CAUTION)\]$/gm; +const BLOCK_ATTRIBUTE_LINE = /^\[[^\]]*\]$/gm; +// Block delimiters (`====` example, `----` listing). `-{4,}` leaves a Markdown `---` alone. +const BLOCK_DELIMITER = /^(?:={2,}|-{4,}|\*{4,}|_{4,}|\+{4,})$/gm; +const BLOCK_TITLE_LINE = /^\.([A-Z][^\n]*)$/gm; // `\]` escapes the bracket; a trailing `^` is AsciiDoc's "open in a new window" flag, not text. function macroLabel(label: string): string { @@ -35,26 +42,29 @@ function macroLabel(label: string): string { } /** - * Flattens `|===` tables to bullets. The schema's tables are one `| cell` per line with blank lines - * between rows, so the row grouping survives; a real Markdown table isn't worth the conversion for - * the handful of fields (sql_* DSN formats) that use one. + * Flattens `|===` tables to one bullet per source line, cells joined with an em dash. A Markdown + * table isn't worth the conversion for the handful of fields (sql DSN formats, Debezium type maps) + * that use one. Rows are only recognized between delimiters, so a `|` in prose is left alone. */ function flattenTables(text: string): string { if (!text.includes('|===')) { return text; } - return text - .split('\n') - .filter((line) => line.trim() !== '|===') - .map((line) => { - const cells = TABLE_CELL_LINE.exec(line.trimEnd()); - if (!cells) { - return line; - } - const joined = cells[1].split(TABLE_CELL_SEPARATOR).join(' — ').trim(); - return joined ? `- ${joined}` : ''; - }) - .join('\n'); + let inTable = false; + const lines: string[] = []; + for (const line of text.split('\n')) { + if (TABLE_DELIMITER.test(line)) { + inTable = !inTable; + continue; + } + if (!inTable) { + lines.push(line); + continue; + } + const row = line.trim().replace(LEADING_CELL_MARKER, ''); + lines.push(row ? `- ${row.split(TABLE_CELL_SEPARATOR).join(' — ')}` : ''); + } + return lines.join('\n'); } /** @@ -73,7 +83,7 @@ function escapePlaceholders(text: string): string { export function cleanText(text: string): string { return text .replace(XREF_MACRO, (_match, label: string) => macroLabel(label) || EMPTY_XREF_LABEL) - .replace(URL_MACRO, (_match, _url: string, label: string) => macroLabel(label)) + .replace(URL_MACRO, (_match, url: string, label: string) => macroLabel(label) || url) .replace(/`([^`]+)`/g, '$1') .replace(/\s+/g, ' ') .trim(); @@ -87,7 +97,13 @@ export function asciidocToMarkdown(raw: string): string { return escapePlaceholders( flattenTables(raw.replace(/\r\n/g, '\n')) // Attribute definitions configure the docs build; they render as noise. - .replace(/^:[a-zA-Z][\w-]*:.*$/gm, '') + .replace(ATTRIBUTE_LINE, '') + .replace(ADMONITION_LINE, '**$1**') + .replace(BLOCK_ATTRIBUTE_LINE, '') + // Left in place, a delimiter turns the prose around it into a setext heading. + .replace(BLOCK_DELIMITER, '') + // Block titles (`.Endpoint caveats`) → small heading. + .replace(BLOCK_TITLE_LINE, '#### $1') // Link macros → label text. .replace(XREF_MACRO, (_match, label: string) => macroLabel(label) || EMPTY_XREF_LABEL) // Bare URL macro → Markdown link. @@ -106,9 +122,9 @@ export function asciidocToMarkdown(raw: string): string { ); } -/** Single-line plain text for collapsed previews: the converted Markdown with its syntax removed. */ -export function asciidocToPlainText(raw: string): string { - return asciidocToMarkdown(raw) +/** Strips Markdown syntax to a single line, for collapsed previews. */ +export function markdownToPlainText(markdown: string): string { + return markdown .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') .replace(/^#+\s*/gm, '') .replace(/^[-*]\s+/gm, '') From 591d51f302bb8b0019e1c4eb7713590ac6075970 Mon Sep 17 00:00:00 2001 From: Peter Zenger Date: Mon, 31 Aug 2026 09:48:43 -0700 Subject: [PATCH 3/6] field docs --- .../pipeline/field-description.test.tsx | 34 ++ .../rp-connect/pipeline/field-description.tsx | 71 ++++- .../pipeline/node-config-form.test.tsx | 17 + .../rp-connect/pipeline/node-config-form.tsx | 294 +++++++++--------- .../rp-connect/utils/connector-docs.test.ts | 31 +- .../pages/rp-connect/utils/connector-docs.ts | 20 ++ 6 files changed, 312 insertions(+), 155 deletions(-) diff --git a/frontend/src/components/pages/rp-connect/pipeline/field-description.test.tsx b/frontend/src/components/pages/rp-connect/pipeline/field-description.test.tsx index 0024d29d0d..603a9ce202 100644 --- a/frontend/src/components/pages/rp-connect/pipeline/field-description.test.tsx +++ b/frontend/src/components/pages/rp-connect/pipeline/field-description.test.tsx @@ -32,6 +32,9 @@ const SHOW_MORE_RE = /show more/i; const ALTERNATIVELY_RE = /Alternatively, it is possible/; const TOPICS_LEAD_RE = /A list of topics to consume from/; const SHOW_LESS_RE = /show less/i; +const TOPICS_DOCS_RE = /topics documentation/i; +const TOPICS_DOCS_URL = + 'https://docs.redpanda.com/cloud-data-platform/develop/connect/components/inputs/redpanda/#topics'; describe('FieldDescription', () => { test('prefers the short description over the long one', () => { @@ -107,4 +110,35 @@ describe('FieldDescription', () => { expect(screen.getByText('cdc_metadata_', { selector: 'code' })).toBeInTheDocument(); }); + + test('deep-links the field on the connector docs page, named for the field', () => { + render(); + + const link = screen.getByRole('link', { name: TOPICS_DOCS_RE }); + expect(link).toHaveAttribute('href', TOPICS_DOCS_URL); + expect(link).toHaveAttribute('target', '_blank'); + }); + + test('keeps the docs link reachable while a long description is collapsed', async () => { + const user = userEvent.setup(); + render(); + + expect(screen.getByRole('link', { name: TOPICS_DOCS_RE })).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: SHOW_MORE_RE })); + + expect(screen.getByRole('link', { name: TOPICS_DOCS_RE })).toBeInTheDocument(); + }); + + test('offers the docs link even for a field the schema documents nowhere else', () => { + render(); + + expect(screen.getByRole('link', { name: TOPICS_DOCS_RE })).toBeInTheDocument(); + }); + + test('renders no docs link for a component without a docs page', () => { + render(); + + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/pages/rp-connect/pipeline/field-description.tsx b/frontend/src/components/pages/rp-connect/pipeline/field-description.tsx index d8cbad215f..5294b29e8f 100644 --- a/frontend/src/components/pages/rp-connect/pipeline/field-description.tsx +++ b/frontend/src/components/pages/rp-connect/pipeline/field-description.tsx @@ -10,6 +10,7 @@ */ import { Link } from 'components/redpanda-ui/components/typography'; +import { BookOpenIcon } from 'lucide-react'; import { useId, useMemo, useState } from 'react'; import ReactMarkdown, { type Components } from 'react-markdown'; @@ -56,8 +57,32 @@ const MarkdownBody = ({ markdown }: { markdown: string }) => ( ); +/** + * Trailing link to the field's own heading on the connector's docs page. Muted and named after the + * field, so a form of twenty of these reads as help text rather than twenty calls to action. + * + * Laid out inline, not inline-flex: a flex box takes its baseline from the icon's bottom edge, which + * drops the word below the baseline of the sentence it follows. Inline keeps one shared line box, so + * `Docs` sits on the prose baseline and the icon is placed against it by `vertical-align`. The + * underline is on the word alone — an ancestor's decoration would rule through the icon too. + */ +const FieldDocsLink = ({ href, fieldName }: { href: string; fieldName?: string }) => ( + + {/* size-3 matches the 12px text; -0.15em centres the icon on the word's cap height. */} + + Docs + +); + /** AsciiDoc `description`, rendered as Markdown and collapsed to two lines when it runs long. */ -const LongDescription = ({ source }: { source: string }) => { +const LongDescription = ({ source, docsLink }: { source: string; docsLink: React.ReactNode }) => { const [expanded, setExpanded] = useState(false); const bodyId = useId(); const { markdown, preview, clampable } = useMemo(() => { @@ -71,7 +96,12 @@ const LongDescription = ({ source }: { source: string }) => { }, [source]); if (!clampable) { - return ; + return ( +
    + + {docsLink} +
    + ); } return ( @@ -84,15 +114,19 @@ const LongDescription = ({ source }: { source: string }) => {
    {preview}
    )} - + {/* The expander already earns a row here, so the docs link joins it instead of adding one. */} +
    + + {docsLink} +
    ); }; @@ -100,15 +134,22 @@ const LongDescription = ({ source }: { source: string }) => { /** * Help text under a config control. Prefers the schema's `short_description` — a markup-free * one-liner — and falls back to the AsciiDoc `description` that most fields are still limited to. + * `docsUrl` deep-links the field's own heading on the connector's reference page. */ -export const FieldDescription = ({ spec }: { spec: RawFieldSpec }) => { +export const FieldDescription = ({ spec, docsUrl }: { spec: RawFieldSpec; docsUrl?: string }) => { + const docsLink = docsUrl ? : null; const short = spec.shortDescription?.trim(); if (short) { - return
    {short}
    ; + return ( + // A one-liner and the link share a row rather than the link claiming its own. +
    + {short} {docsLink} +
    + ); } const description = spec.description?.trim(); if (!description) { - return null; + return docsLink; } - return ; + return ; }; diff --git a/frontend/src/components/pages/rp-connect/pipeline/node-config-form.test.tsx b/frontend/src/components/pages/rp-connect/pipeline/node-config-form.test.tsx index 7266f10a56..80a1bcf021 100644 --- a/frontend/src/components/pages/rp-connect/pipeline/node-config-form.test.tsx +++ b/frontend/src/components/pages/rp-connect/pipeline/node-config-form.test.tsx @@ -33,6 +33,9 @@ function renderForm(value: Record, onConfigChange = vi.fn()) { } const CREATE_NEW_TOPIC_RE = /create new topic/i; +const KAFKA_OUTPUT_DOCS = 'https://docs.redpanda.com/cloud-data-platform/develop/connect/components/outputs/kafka/'; +const TOPIC_DOCS_RE = /topic documentation/i; +const BATCHING_COUNT_DOCS_RE = /count documentation/i; // The most recent config reported by the form (undefined if never called, null when clean). function lastReported(onConfigChange: ReturnType): unknown { @@ -75,6 +78,20 @@ describe('NodeConfigForm — full schema', () => { expect(mechRow?.querySelector('[title="Required"]')).toBeNull(); }); + test('deep-links every field to its own heading on the connector docs page', async () => { + const user = userEvent.setup(); + renderForm({ kafka: { topic: 't', addresses: ['a:9092'] } }); + + expect(screen.getByRole('link', { name: TOPIC_DOCS_RE })).toHaveAttribute('href', `${KAFKA_OUTPUT_DOCS}#topic`); + + // A nested field is anchored by its whole path, as the docs generator ids it. + await user.click(screen.getByText('batching')); + expect(screen.getByRole('link', { name: BATCHING_COUNT_DOCS_RE })).toHaveAttribute( + 'href', + `${KAFKA_OUTPUT_DOCS}#batching-count` + ); + }); + test('shows the schema default as a hint for optional fields', () => { renderForm({ kafka: { topic: 't', addresses: ['a:9092'] } }); // partitioner defaults to fnv1a_hash. diff --git a/frontend/src/components/pages/rp-connect/pipeline/node-config-form.tsx b/frontend/src/components/pages/rp-connect/pipeline/node-config-form.tsx index 2876a0fe06..062d51b22f 100644 --- a/frontend/src/components/pages/rp-connect/pipeline/node-config-form.tsx +++ b/frontend/src/components/pages/rp-connect/pipeline/node-config-form.tsx @@ -37,6 +37,7 @@ import type { FieldLintErrors } from './lint-field-mapping'; import { ScrollShadow } from './scroll-shadow'; import { getSecretSyntax, REDPANDA_TOPIC_AND_USER_COMPONENTS } from '../types/constants'; import type { ConnectComponentSpec, RawFieldSpec } from '../types/schema'; +import { getFieldDocsUrl } from '../utils/connector-docs'; import { checkRequired, fieldHasOptions, @@ -530,6 +531,17 @@ function buildComponentEntry({ return next; } +// Docs identity of the edited component, so a field can deep-link its own heading on that +// component's reference page. Context for the same reason as ResourceFieldContext: the consumers are +// leaf controls several generic layers down. +type ComponentDocsIdentity = { section: string; componentName: string }; +const ComponentDocsContext = createContext(undefined); + +const useFieldDocsUrl = (path: string[]): string | undefined => { + const component = useContext(ComponentDocsContext); + return component ? getFieldDocsUrl(component.section, component.componentName, path) : undefined; +}; + const FieldLabel = ({ spec, htmlFor }: { spec: RawFieldSpec; htmlFor?: string }) => (
    ); }} @@ -830,6 +843,7 @@ const ArrayField = ({ leaf, control }: { leaf: Leaf; control: Control field.onChange([...lines, t].join('\n'))} /> ) : null} - + ); }} @@ -1146,150 +1160,152 @@ export function NodeConfigForm({ .join('\n'); return ( - - - {/* Committing per FIELD (not per node): the container listens for focus leaving any field + + + + {/* Committing per FIELD (not per node): the container listens for focus leaving any field (bubbled focusout) and ⌘⏎, flushing the draft so the canvas card and lint react while the node is still selected. It is not itself interactive — the fields inside are. */} - {/* biome-ignore lint/a11y/noStaticElementInteractions: passive listener for events bubbling from the form controls. */} - {/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: passive listener for events bubbling from the form controls. */} -
    { - if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { - e.preventDefault(); - commitNow(); + {/* biome-ignore lint/a11y/noStaticElementInteractions: passive listener for events bubbling from the form controls. */} + {/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: passive listener for events bubbling from the form controls. */} +
    { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault(); + commitNow(); + } } - } - : undefined - } - > - - {/* Full-bleed to the scroll edges; padded fields follow. */} - {headerSlot ?
    {headerSlot}
    : null} -
    - - ( - <> - - {requireLabel && !field.value.trim() ? ( -
    - A resource needs a label — nodes reference it by name. The saved label is kept. -
    - ) : null} - - - )} - /> -
    - - {isListValued && hasChildList ? ( - void} - /> - ) : null} - {isListValued && !hasChildList ? ( -
    -
    - This component's items (cases / processors) are edited on the canvas — select one to edit it. -
    -
    - ) : null} - - {isListValued - ? null - : required.map((f) => )} - - {isListValued ? null : ( - 0} /> - )} - - {!isListValued && advanced.length > 0 ? ( - - {advanced.map((f) => ( - - ))} - - ) : null} - - {!isListValued && componentFields.length > 0 && hasChildList ? ( - void} - /> - ) : null} - - {!isListValued && showRaw ? ( - + : undefined + } + > + + {/* Full-bleed to the scroll edges; padded fields follow. */} + {headerSlot ?
    {headerSlot}
    : null} +
    + { - const invalid = field.value.trim() !== '' && parseRawSection(true, field.value) === null; - return ( -
    -
    - field.onChange(v || '')} - options={{ minimap: { enabled: false } }} - transparentBackground - value={field.value} - /> + name="label" + render={({ field, fieldState }) => ( + <> + + {requireLabel && !field.value.trim() ? ( +
    + A resource needs a label — nodes reference it by name. The saved label is kept.
    - {invalid ? ( -
    - Invalid YAML — these settings won't be saved until fixed. -
    - ) : null} -
    - ); - }} + ) : null} + + + )} /> - - ) : null} - - {/* Edits normally apply on field blur; this is the visible pending state plus a manual +
    + + {isListValued && hasChildList ? ( + void} + /> + ) : null} + {isListValued && !hasChildList ? ( +
    +
    + This component's items (cases / processors) are edited on the canvas — select one to edit it. +
    +
    + ) : null} + + {isListValued + ? null + : required.map((f) => )} + + {isListValued ? null : ( + 0} /> + )} + + {!isListValued && advanced.length > 0 ? ( + + {advanced.map((f) => ( + + ))} + + ) : null} + + {!isListValued && componentFields.length > 0 && hasChildList ? ( + void} + /> + ) : null} + + {!isListValued && showRaw ? ( + + { + const invalid = field.value.trim() !== '' && parseRawSection(true, field.value) === null; + return ( +
    +
    + field.onChange(v || '')} + options={{ minimap: { enabled: false } }} + transparentBackground + value={field.value} + /> +
    + {invalid ? ( +
    + Invalid YAML — these settings won't be saved until fixed. +
    + ) : null} +
    + ); + }} + /> +
    + ) : null} + + {/* Edits normally apply on field blur; this is the visible pending state plus a manual trigger (and ⌘⏎) for applying without moving focus. */} - {onCommitField && isDirty ? ( -
    - - - Unsaved edits - - -
    - ) : null} -
    - - + {onCommitField && isDirty ? ( +
    + + + Unsaved edits + + +
    + ) : null} +
    + + + ); } diff --git a/frontend/src/components/pages/rp-connect/utils/connector-docs.test.ts b/frontend/src/components/pages/rp-connect/utils/connector-docs.test.ts index d5c8170582..c91c3e4999 100644 --- a/frontend/src/components/pages/rp-connect/utils/connector-docs.test.ts +++ b/frontend/src/components/pages/rp-connect/utils/connector-docs.test.ts @@ -11,7 +11,7 @@ import { describe, expect, it } from 'vitest'; -import { getConnectorDocsUrl, getNodeDocsUrl } from './connector-docs'; +import { getConnectorDocsUrl, getFieldDocsUrl, getNodeDocsUrl } from './connector-docs'; describe('getConnectorDocsUrl', () => { it('builds correct URL for input connectors', () => { @@ -58,6 +58,35 @@ describe('getConnectorDocsUrl', () => { }); }); +describe('getFieldDocsUrl', () => { + const INPUT_REDPANDA = 'https://docs.redpanda.com/cloud-data-platform/develop/connect/components/inputs/redpanda/'; + + it('anchors a top-level field by its name', () => { + expect(getFieldDocsUrl('input', 'redpanda', ['consumer_group'])).toBe(`${INPUT_REDPANDA}#consumer_group`); + }); + + it('joins a nested field path with hyphens, as the docs generator ids it', () => { + expect(getFieldDocsUrl('output', 'sql_insert', ['batching', 'byte_size'])).toBe( + 'https://docs.redpanda.com/cloud-data-platform/develop/connect/components/outputs/sql_insert/#batching-byte_size' + ); + }); + + it('ignores list nesting, which the docs anchors drop', () => { + // Documented as `sasl[].aws.credentials.role`, anchored `#sasl-aws-credentials-role`. + expect(getFieldDocsUrl('input', 'redpanda', ['sasl', 'aws', 'credentials', 'role'])).toBe( + `${INPUT_REDPANDA}#sasl-aws-credentials-role` + ); + }); + + it('falls back to the component page when there is no field path', () => { + expect(getFieldDocsUrl('input', 'redpanda', [])).toBe(INPUT_REDPANDA); + }); + + it('returns undefined when the component itself has no docs page', () => { + expect(getFieldDocsUrl('metrics', 'prometheus', ['use_histogram_timing'])).toBeUndefined(); + }); +}); + describe('getNodeDocsUrl', () => { it('links a component node through its section', () => { expect(getNodeDocsUrl({ kind: 'leaf', label: 'kafka_franz', section: 'input' })).toBe( diff --git a/frontend/src/components/pages/rp-connect/utils/connector-docs.ts b/frontend/src/components/pages/rp-connect/utils/connector-docs.ts index a801cf0d3c..d59835a192 100644 --- a/frontend/src/components/pages/rp-connect/utils/connector-docs.ts +++ b/frontend/src/components/pages/rp-connect/utils/connector-docs.ts @@ -27,6 +27,26 @@ export function getConnectorDocsUrl(section: string, connectorName: string): str return `${DOCS_BASE}/${section}s/${connectorName}/`; } +/** + * Docs URL for one field on a connector's reference page. Each field heading on those pages is + * anchored by its dotted path with list markers dropped (`batching.byte_size` → + * `#batching-byte_size`), which is exactly the form's field path. A field whose name collides with + * a prose section of the same page is anchored `-2` (~0.2% of fields, e.g. `snowflake_put`'s + * `snowpipe`); there the plain anchor lands on that same-named section, and an anchor that misses + * outright leaves the reader at the top of the right page. + */ +export function getFieldDocsUrl( + section: string, + connectorName: string, + fieldPath: readonly string[] +): string | undefined { + const base = getConnectorDocsUrl(section, connectorName); + if (!base || fieldPath.length === 0) { + return base; + } + return `${base}#${fieldPath.join('-')}`; +} + type DocsNode = Pick; /** Docs URL for a parsed pipeline node, or undefined when the node names no documented component. */ From 033a8a6e8f7d905bcbeadc377e7db9bb8a335c00 Mon Sep 17 00:00:00 2001 From: Peter Zenger Date: Mon, 31 Aug 2026 09:57:31 -0700 Subject: [PATCH 4/6] Field doc links --- .../pipeline/field-description.test.tsx | 11 +++++++++++ .../rp-connect/pipeline/field-description.tsx | 18 +++++++++++++----- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/pages/rp-connect/pipeline/field-description.test.tsx b/frontend/src/components/pages/rp-connect/pipeline/field-description.test.tsx index 603a9ce202..3464190216 100644 --- a/frontend/src/components/pages/rp-connect/pipeline/field-description.test.tsx +++ b/frontend/src/components/pages/rp-connect/pipeline/field-description.test.tsx @@ -119,6 +119,17 @@ describe('FieldDescription', () => { expect(link).toHaveAttribute('target', '_blank'); }); + test('trails the link on the sentence when the description is a single paragraph', () => { + render( + + ); + + // One block, so the link reads as part of the help text instead of claiming a row of its own. + const link = screen.getByRole('link', { name: TOPICS_DOCS_RE }); + expect(link.parentElement?.textContent).toBe('Set the topic to publish to. Docs'); + expect(screen.queryByRole('button', { name: SHOW_MORE_RE })).not.toBeInTheDocument(); + }); + test('keeps the docs link reachable while a long description is collapsed', async () => { const user = userEvent.setup(); render(); diff --git a/frontend/src/components/pages/rp-connect/pipeline/field-description.tsx b/frontend/src/components/pages/rp-connect/pipeline/field-description.tsx index 5294b29e8f..b96c59e7d0 100644 --- a/frontend/src/components/pages/rp-connect/pipeline/field-description.tsx +++ b/frontend/src/components/pages/rp-connect/pipeline/field-description.tsx @@ -51,6 +51,13 @@ const MARKDOWN_COMPONENTS: Components = { strong: ({ children }) => {children}, }; +// Paragraphs unwrapped, so a single-paragraph description can flow inline with the docs link that +// trails it. Everything else (code spans, links, emphasis) renders the same as in a block. +const INLINE_MARKDOWN_COMPONENTS: Components = { + ...MARKDOWN_COMPONENTS, + p: ({ children }) => <>{children}, +}; + const MarkdownBody = ({ markdown }: { markdown: string }) => (
    {markdown} @@ -75,7 +82,8 @@ const FieldDocsLink = ({ href, fieldName }: { href: string; fieldName?: string } target="_blank" tone="current" > - {/* size-3 matches the 12px text; -0.15em centres the icon on the word's cap height. */} + {/* size-3 is the same rung as text-body-sm, so the icon never outweighs the word it labels; + -0.15em centres it on the cap height. */} Docs @@ -95,11 +103,11 @@ const LongDescription = ({ source, docsLink }: { source: string; docsLink: React }; }, [source]); + // Not clampable means one paragraph and no block content, so it can carry the link on its line. if (!clampable) { return ( -
    - - {docsLink} +
    + {markdown} {docsLink}
    ); } @@ -115,7 +123,7 @@ const LongDescription = ({ source, docsLink }: { source: string; docsLink: React )}
    {/* The expander already earns a row here, so the docs link joins it instead of adding one. */} -
    +
    -
    + ) : null} +
    + ); + }} + /> + ) : null} -
    - - - + + {/* Edits normally apply on field blur; this is the visible pending state plus a manual + trigger (and ⌘⏎) for applying without moving focus. */} + {onCommitField && isDirty ? ( +
    + + + Unsaved edits + + +
    + ) : null} +
    +
    +
    ); } diff --git a/frontend/src/components/pages/rp-connect/utils/asciidoc.test.ts b/frontend/src/components/pages/rp-connect/utils/asciidoc.test.ts index 167d41d0b0..58610b0be3 100644 --- a/frontend/src/components/pages/rp-connect/utils/asciidoc.test.ts +++ b/frontend/src/components/pages/rp-connect/utils/asciidoc.test.ts @@ -11,162 +11,129 @@ import { describe, expect, it } from 'vitest'; -import { asciidocToMarkdown, cleanText, markdownToPlainText } from './asciidoc'; +import { asciidocToMarkdown, markdownToPlainText } from './asciidoc'; -describe('asciidocToMarkdown', () => { - it('turns AsciiDoc section titles into Markdown headings instead of leaking "=="', () => { - const out = asciidocToMarkdown('== Performance\nThis output benefits from batching.'); - expect(out).toBe('#### Performance\nThis output benefits from batching.'); - expect(out).not.toContain('== '); - }); - - it('handles multiple heading levels and keeps paragraphs separated', () => { - const out = asciidocToMarkdown('Intro paragraph.\n\n=== Delivery Guarantees\nAt least once.'); - expect(out).toBe('Intro paragraph.\n\n#### Delivery Guarantees\nAt least once.'); - }); - - it('converts link/xref macros to label text and bare URL macros to Markdown links', () => { - expect(asciidocToMarkdown('See xref:guides:about.adoc[the guide] for details.')).toBe('See the guide for details.'); - expect(asciidocToMarkdown('Uses https://github.com/twmb/franz-go[franz-go] under the hood.')).toBe( - 'Uses [franz-go](https://github.com/twmb/franz-go) under the hood.' - ); - }); - - it('converts AsciiDoc bullets to Markdown list items', () => { - expect(asciidocToMarkdown('* first\n* second')).toBe('- first\n- second'); - }); - - // Every AWS `credentials` field ends "…can be found in xref:guides:cloud/aws.adoc[].". - it('names a target for an empty-label xref rather than leaving dangling punctuation', () => { - expect(asciidocToMarkdown('More information can be found in xref:guides:cloud/aws.adoc[].')).toBe( - 'More information can be found in the documentation.' - ); - }); - - it('keeps a macro label that contains escaped brackets, dropping the new-window flag', () => { - // AsciiDoc escapes `]` inside a label, and a trailing `^` means "open in a new window". - const source = 'See https://example.com/dsn[`http[s\\]://user[:pass\\]`^] here.'; - expect(asciidocToMarkdown(source)).toBe('See [`http[s]://user[:pass]`](https://example.com/dsn) here.'); - }); - - it('reduces internal cross-references to their wording', () => { - expect(asciidocToMarkdown('Set the field <> to `false`.')).toBe( - 'Set the field `batch_as_multipart` to `false`.' - ); - expect(asciidocToMarkdown('Brokering <> are supported.')).toBe('Brokering patterns are supported.'); - }); +const lines = (...rows: string[]) => rows.join('\n'); - it('flattens a Markdown pipe table, dropping its header rule', () => { - const source = 'Placeholders:\n\n| Driver | Style |\n|---|---|\n| `mysql` | Question mark |'; - expect(asciidocToMarkdown(source)).toBe('Placeholders:\n\n- Driver — Style\n- `mysql` — Question mark'); - }); - - it('escapes angle-bracket placeholders so Markdown does not swallow them as HTML', () => { - const out = asciidocToMarkdown("Requests must include 'authorization: Bearer ' metadata."); - expect(out).toBe(String.raw`Requests must include 'authorization: Bearer \' metadata.`); - }); - - it('leaves placeholders inside code spans untouched', () => { - expect(asciidocToMarkdown('Defaults to `redpanda_connect_jira_input_`.')).toBe( - 'Defaults to `redpanda_connect_jira_input_`.' - ); - }); - - it('flattens AsciiDoc tables to bullets and drops docs attribute lines', () => { - const source = [ - 'A Data Source Name.', - '', - ':driver-support: mysql=certified, postgres=certified', - '', - '|===', - '| Driver | Data Source Name Format', - '', - '| `mysql`', - '| `[username[:password]@]/dbname`', - '|===', - ].join('\n'); - expect(asciidocToMarkdown(source)).toBe( - [ - 'A Data Source Name.', +describe('asciidocToMarkdown', () => { + it.each([ + [ + 'section titles, so no "==" leaks', + '== Performance\nBenefits from batching.', + '#### Performance\nBenefits from batching.', + ], + [ + 'deeper titles, paragraphs kept apart', + 'Intro.\n\n=== Guarantees\nAt least once.', + 'Intro.\n\n#### Guarantees\nAt least once.', + ], + ['an over-long title, marker still stripped', `= ${'x'.repeat(70)}`, 'x'.repeat(70)], + ['xref macros, down to their label', 'See xref:guides:about.adoc[the guide].', 'See the guide.'], + [ + 'URL macros, as Markdown links', + 'Uses https://example.com/go[franz-go].', + 'Uses [franz-go](https://example.com/go).', + ], + // Every AWS `credentials` field ends "…can be found in xref:guides:cloud/aws.adoc[].". + [ + 'an empty-label xref, leaving no dangling punctuation', + 'Found in xref:guides:cloud/aws.adoc[].', + 'Found in the documentation.', + ], + // AsciiDoc escapes `]` inside a label; a trailing `^` means "open in a new window". + [ + 'bracketed labels and the new-window flag', + 'See https://x.com/dsn[`http[s\\]://u[:p\\]`^] here.', + 'See [`http[s]://u[:p]`](https://x.com/dsn) here.', + ], + [ + 'internal cross-references with a label', + 'Set <> to `false`.', + 'Set `batch_as_multipart` to `false`.', + ], + [ + 'internal cross-references without one', + 'Brokering <> are supported.', + 'Brokering patterns are supported.', + ], + ['AsciiDoc bullets', '* first\n* second', '- first\n- second'], + [ + 'admonitions, block titles and block delimiters', + lines('[CAUTION]', '.Endpoint caveats', '====', 'Order is not deterministic.', '===='), + lines('**CAUTION**', '#### Endpoint caveats', '', 'Order is not deterministic.'), + ], + ['the leading newline most descriptions start with', '\nA list of topics.', 'A list of topics.'], + [ + 'angle-bracket placeholders, escaped past remark', + "Send 'authorization: Bearer '.", + String.raw`Send 'authorization: Bearer \'.`, + ], + [ + 'placeholders inside a code span, untouched', + 'Defaults to `jira_input_`.', + 'Defaults to `jira_input_`.', + ], + [ + 'Markdown pipe tables, header rule dropped', + lines('Placeholders:', '', '| Driver | Style |', '|---|---|', '| `mysql` | Question mark |'), + lines('Placeholders:', '', '- Driver — Style', '- `mysql` — Question mark'), + ], + [ + '`|===` tables and the `:attr:` lines around them', + lines( + 'A DSN.', '', - '- Driver — Data Source Name Format', + ':driver-support: mysql=certified', '', - '- `mysql`', - '- `[username[:password]@]/dbname`', - ].join('\n') - ); - }); - - // The Debezium type table writes rows as `|Type Name |Bloblang Type`, with no space after the - // cell marker. - it('splits a dsv table on colons, the separator its attribute line declares', () => { - const source = '[%header,format=dsv]\n|===\nSnowflake type:Connect format\nCHAR, VARCHAR:string\n|==='; - expect(asciidocToMarkdown(source)).toBe('- Snowflake type — Connect format\n- CHAR, VARCHAR — string'); - }); - - it('splits table cells that are not padded around the marker', () => { - const source = ['.Debezium Custom Temporal Types', '|===', '|Type Name |Bloblang Type', '|==='].join('\n'); - expect(asciidocToMarkdown(source)).toBe( - ['#### Debezium Custom Temporal Types', '- Type Name — Bloblang Type'].join('\n') - ); - }); - - it('leaves a pipe in prose alone even when the description also has a table', () => { - const source = ['Splits on | characters.', '', '|===', '|a |b', '|==='].join('\n'); - expect(asciidocToMarkdown(source)).toBe(['Splits on | characters.', '', '- a — b'].join('\n')); - }); - - it('drops block delimiters that would promote the prose around them to a heading', () => { - const source = [ - '[CAUTION]', - '.Endpoint caveats', - '====', - 'Endpoints register in a non-deterministic order.', - '====', - ].join('\n'); - expect(asciidocToMarkdown(source)).toBe( - ['**CAUTION**', '#### Endpoint caveats', '', 'Endpoints register in a non-deterministic order.'].join('\n') - ); - }); - - it('trims the leading newline that many field descriptions start with', () => { - expect(asciidocToMarkdown('\nA list of topics to consume from.')).toBe('A list of topics to consume from.'); + '|===', + '| Driver | Format', + '', + '| `mysql`', + '| `[user[:pass]@]/db`', + '|===' + ), + lines('A DSN.', '', '- Driver — Format', '', '- `mysql`', '- `[user[:pass]@]/db`'), + ], + // The Debezium type table writes rows as `|Type Name |Bloblang Type`, unpadded. + [ + 'cells that are not padded around the marker', + lines('|===', '|Type Name |Bloblang Type', '|==='), + '- Type Name — Bloblang Type', + ], + [ + 'dsv tables, split on the separator their attribute line declares', + lines('[%header,format=dsv]', '|===', 'Snowflake type:Connect format', 'CHAR, VARCHAR:string', '|==='), + lines('- Snowflake type — Connect format', '- CHAR, VARCHAR — string'), + ], + [ + 'a pipe in prose, even alongside a table', + lines('Splits on | characters.', '', '|===', '|a |b', '|==='), + lines('Splits on | characters.', '', '- a — b'), + ], + ])('handles %s', (_case, source, expected) => { + expect(asciidocToMarkdown(source)).toBe(expected); }); }); describe('markdownToPlainText', () => { - it('reduces converted Markdown to a single line without syntax', () => { - expect(markdownToPlainText(asciidocToMarkdown('\nUse `consumer_group` to share load.\n\n== Notes\n* first'))).toBe( - 'Use consumer_group to share load. Notes first' - ); - }); - - it('strips a link whose label nests brackets, as the sql DSN examples do', () => { - const markdown = 'A DSN: [`clickhouse://[user[:pass]@][host]`](https://example.com/dsn) applies.'; - expect(markdownToPlainText(markdown)).toBe('A DSN: clickhouse://[user[:pass]@][host] applies.'); - }); - - it('keeps link labels and unescapes placeholders', () => { - expect(markdownToPlainText(asciidocToMarkdown('See https://example.com[the docs] for usage.'))).toBe( - 'See the docs for usage.' - ); - }); -}); - -describe('cleanText', () => { - it('strips code spans and macros down to one line', () => { - expect(cleanText('Sends to `redpanda`\nvia xref:guides:about.adoc[the guide].')).toBe( - 'Sends to redpanda via the guide.' - ); - }); - - it('reduces internal cross-references to their wording', () => { - expect(cleanText('Brokering <> with <>.')).toBe( - 'Brokering patterns with structured data.' - ); - }); - - it('substitutes a label for an empty-label xref', () => { - expect(cleanText('Found in xref:guides:cloud/aws.adoc[].')).toBe('Found in the documentation.'); + it.each([ + [ + 'converted Markdown to one line', + asciidocToMarkdown('\nUse `consumer_group`.\n\n== Notes\n* first'), + 'Use consumer_group. Notes first', + ], + [ + 'link labels, unescaping placeholders', + asciidocToMarkdown('See https://x.com[the docs] for usage.'), + 'See the docs for usage.', + ], + // The sql `dsn` fields document a bracket-heavy DSN inside the link label. + [ + 'a link whose label nests brackets', + 'A DSN: [`ch://[user[:pass]@][host]`](https://x.com/dsn) applies.', + 'A DSN: ch://[user[:pass]@][host] applies.', + ], + ])('reduces %s', (_case, markdown, expected) => { + expect(markdownToPlainText(markdown)).toBe(expected); }); }); diff --git a/frontend/src/components/pages/rp-connect/utils/asciidoc.ts b/frontend/src/components/pages/rp-connect/utils/asciidoc.ts index a7342b2fb2..9cfed7fb0a 100644 --- a/frontend/src/components/pages/rp-connect/utils/asciidoc.ts +++ b/frontend/src/components/pages/rp-connect/utils/asciidoc.ts @@ -48,7 +48,6 @@ const DSV_CELL_SEPARATOR = /\s*:\s*/; const CODE_BLOCK_OR_SPAN = /(```[\s\S]*?```|`[^`\n]*`)/g; const PLACEHOLDER_OPENER = /<(?!https?:\/\/)(?=[a-zA-Z/])/g; -const CODE_SPAN_TICKS = /`([^`]+)`/g; const MARKDOWN_MARKS = /[`*]/g; const MARKDOWN_HEADING = /^#+\s*/gm; const MARKDOWN_LIST_MARKER = /^[-*]\s+/gm; @@ -113,15 +112,6 @@ function escapePlaceholders(text: string): string { .join(''); } -/** One-line prose (component summaries) to plain text. Multi-paragraph prose needs the pair below. */ -export function cleanText(text: string): string { - return macrosToLabels(text) - .replace(URL_MACRO, (_match, url: string, label: string) => macroLabel(label) || url) - .replace(CODE_SPAN_TICKS, '$1') - .replace(WHITESPACE_RUN, ' ') - .trim(); -} - /** AsciiDoc to Markdown for react-markdown; newlines survive, so titles and paragraphs stay distinct. */ export function asciidocToMarkdown(raw: string): string { return escapePlaceholders( diff --git a/frontend/src/components/pages/rp-connect/utils/connector-docs.test.ts b/frontend/src/components/pages/rp-connect/utils/connector-docs.test.ts index c91c3e4999..667add3586 100644 --- a/frontend/src/components/pages/rp-connect/utils/connector-docs.test.ts +++ b/frontend/src/components/pages/rp-connect/utils/connector-docs.test.ts @@ -13,6 +13,8 @@ import { describe, expect, it } from 'vitest'; import { getConnectorDocsUrl, getFieldDocsUrl, getNodeDocsUrl } from './connector-docs'; +const DOCS = 'https://docs.redpanda.com/cloud-data-platform/develop/connect/components'; + describe('getConnectorDocsUrl', () => { it('builds correct URL for input connectors', () => { expect(getConnectorDocsUrl('input', 'aws_cloudwatch_logs')).toBe( @@ -59,27 +61,19 @@ describe('getConnectorDocsUrl', () => { }); describe('getFieldDocsUrl', () => { - const INPUT_REDPANDA = 'https://docs.redpanda.com/cloud-data-platform/develop/connect/components/inputs/redpanda/'; - - it('anchors a top-level field by its name', () => { - expect(getFieldDocsUrl('input', 'redpanda', ['consumer_group'])).toBe(`${INPUT_REDPANDA}#consumer_group`); - }); + const REDPANDA_INPUT = `${DOCS}/inputs/redpanda/`; - it('joins a nested field path with hyphens, as the docs generator ids it', () => { - expect(getFieldDocsUrl('output', 'sql_insert', ['batching', 'byte_size'])).toBe( - 'https://docs.redpanda.com/cloud-data-platform/develop/connect/components/outputs/sql_insert/#batching-byte_size' - ); - }); - - it('ignores list nesting, which the docs anchors drop', () => { + it.each([ + ['a top-level field by its name', ['consumer_group'], `${REDPANDA_INPUT}#consumer_group`], // Documented as `sasl[].aws.credentials.role`, anchored `#sasl-aws-credentials-role`. - expect(getFieldDocsUrl('input', 'redpanda', ['sasl', 'aws', 'credentials', 'role'])).toBe( - `${INPUT_REDPANDA}#sasl-aws-credentials-role` - ); - }); - - it('falls back to the component page when there is no field path', () => { - expect(getFieldDocsUrl('input', 'redpanda', [])).toBe(INPUT_REDPANDA); + [ + 'a nested path with hyphens, list nesting dropped', + ['sasl', 'aws', 'credentials', 'role'], + `${REDPANDA_INPUT}#sasl-aws-credentials-role`, + ], + ['the component page when there is no field path', [], REDPANDA_INPUT], + ])('anchors %s', (_case, path, expected) => { + expect(getFieldDocsUrl('input', 'redpanda', path)).toBe(expected); }); it('returns undefined when the component itself has no docs page', () => {