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..2a3c7a1c61 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, markdownToPlainText } from '../utils/asciidoc'; import { getCategoryDisplayName } from '../utils/categories'; import { getConnectorDocsUrl } from '../utils/connector-docs'; import { componentStatusToString, parseSchema } from '../utils/schema'; @@ -163,10 +162,10 @@ function DetailPane({ component }: { component?: ConnectComponentSpec }) { } const categories = (component.categories ?? []).map(getCategoryDisplayName).filter(Boolean); - const summary = cleanText(component.summary ?? ''); + const summary = markdownToPlainText(asciidocToMarkdown(component.summary ?? '')); const descriptionMd = component.description ? asciidocToMarkdown(component.description) : ''; // Skip the description when it just repeats the summary. - const showDescription = descriptionMd !== '' && cleanText(component.description ?? '') !== summary; + const showDescription = descriptionMd !== '' && markdownToPlainText(descriptionMd) !== summary; const docsUrl = getConnectorDocsUrl(component.type, component.name); return ( 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..e663d166d3 --- /dev/null +++ b/frontend/src/components/pages/rp-connect/pipeline/field-description.test.tsx @@ -0,0 +1,151 @@ +/** + * 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; +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', () => { + 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 adds a hidden Chakra node, so assert on text rather than an empty DOM. + 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: no code spans, trailing paragraph clipped by CSS. + 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('links URL macros out of the AsciiDoc source, bracketed labels included', () => { + const { unmount } = render( + + ); + + const link = screen.getByRole('link', { name: 'franz-go' }); + expect(link).toHaveAttribute('href', 'https://github.com/twmb/franz-go'); + expect(link).toHaveAttribute('target', '_blank'); + unmount(); + + // A code span binds tighter than the link, so a `]` inside the label doesn't end it. + render(); + expect(screen.getByRole('link', { name: 'user[:pass]@host' })).toHaveAttribute('href', 'https://x.com/dsn'); + }); + + test('keeps angle-bracket placeholders that Markdown would otherwise swallow', () => { + render(`.' })} />); + + 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('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.getByText('topic', { selector: 'code' })).toBeInTheDocument(); + 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(); + + 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 with no prose at all, and none when the component has no docs page', () => { + const { unmount } = render(); + expect(screen.getByRole('link', { name: TOPICS_DOCS_RE })).toBeInTheDocument(); + unmount(); + + 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 new file mode 100644 index 0000000000..629daa626a --- /dev/null +++ b/frontend/src/components/pages/rp-connect/pipeline/field-description.tsx @@ -0,0 +1,155 @@ +/** + * 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 { BookOpenIcon } from 'lucide-react'; +import { useId, useMemo, useState } from 'react'; +import ReactMarkdown, { type Components } from 'react-markdown'; + +import type { RawFieldSpec } from '../types/schema'; +import { asciidocToMarkdown, markdownToPlainText } from '../utils/asciidoc'; + +const CLAMP_OVER_CHARS = 200; + +// Field prose sits under its control: headings get no more weight than 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 mid-token. + {children} + ), + ul: ({ children }) =>
    {children}
, + ol: ({ children }) => ( +
    {children}
+ ), + li: ({ children }) =>
  • {children}
  • , + strong: ({ children }) => {children}, +}; + +// Paragraphs unwrapped, so a one-paragraph description can flow inline with its trailing docs link. +const INLINE_MARKDOWN_COMPONENTS: Components = { + ...MARKDOWN_COMPONENTS, + p: ({ children }) => <>{children}, +}; + +const MarkdownBody = ({ markdown }: { markdown: string }) => ( +
    + {markdown} +
    +); + +/** + * Link to the field's own heading on the connector's docs page. Inline, not inline-flex: a flex box + * baselines on the icon's bottom edge, which drops the word below the prose it follows. The underline + * is on the word alone so it doesn't rule through the icon. + */ +const FieldDocsLink = ({ href, fieldName }: { href: string; fieldName?: string }) => ( + + {/* size-3 is the text's own rung; -0.15em centres it on the cap height. */} + + Docs + +); + +// Help text and its trailing link share one line box, so the link reads as part of the sentence. +const InlineHelp = ({ children, docsLink }: { children: React.ReactNode; docsLink: React.ReactNode }) => ( +
    + {children} {docsLink} +
    +); + +/** AsciiDoc `description`, rendered as Markdown and collapsed to two lines when it runs long. */ +const LongDescription = ({ source, docsLink }: { source: string; docsLink: React.ReactNode }) => { + const [expanded, setExpanded] = useState(false); + const bodyId = useId(); + const { markdown, preview, clampable } = useMemo(() => { + const converted = asciidocToMarkdown(source); + const plain = markdownToPlainText(converted); + return { + markdown: converted, + preview: plain, + clampable: plain.length > CLAMP_OVER_CHARS || converted.includes('\n'), + }; + }, [source]); + + // One paragraph, no block content: it can carry the link on its own line. + if (!clampable) { + return ( + + {markdown} + + ); + } + + return ( +
    +
    + {expanded ? ( + + ) : ( + // Plain text collapsed: one text node, so line-clamp applies cleanly. +
    {preview}
    + )} +
    +
    + + {docsLink} +
    +
    + ); +}; + +/** + * Help text under a config control. Prefers the markup-free `short_description`, falling back to the + * AsciiDoc `description` that most fields are still limited to. + */ +export const FieldDescription = ({ spec, docsUrl }: { spec: RawFieldSpec; docsUrl?: string }) => { + const docsLink = docsUrl ? : null; + const short = spec.shortDescription?.trim(); + if (short) { + return {short}; + } + const description = spec.description?.trim(); + if (!description) { + return docsLink; + } + 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 e378068fa6..7acd033e9e 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,10 +32,12 @@ 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'; import type { ConnectComponentSpec, RawFieldSpec } from '../types/schema'; +import { getFieldDocsUrl } from '../utils/connector-docs'; import { checkRequired, fieldHasOptions, @@ -152,6 +154,8 @@ type ResourceFieldContextValue = { clusterTopicFields?: boolean; /** Opens the Add-topic dialog; the created topic is written into the component's topic field. */ onCreateTopic?: () => void; + /** Docs URL for one field of the edited component, by its path. */ + fieldDocsUrl?: (path: string[]) => string | undefined; }; const ResourceFieldContext = createContext({ labels: { cache: [], rate_limit: [] } }); @@ -551,9 +555,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. @@ -791,7 +792,8 @@ const SECRET_REF_EXAMPLE = getSecretSyntax('MY_SECRET'); const ScalarField = ({ leaf, control }: { leaf: Leaf; control: Control }) => { const inputId = useId(); const lintErrors = useContext(FieldLintErrorsContext); - const { clusterTopicFields } = useContext(ResourceFieldContext); + const { clusterTopicFields, fieldDocsUrl } = useContext(ResourceFieldContext); + const docsUrl = fieldDocsUrl?.(leaf.path); // The topic picker's combobox can't take an id — don't point the label at a nonexistent one. const labelFor = clusterTopicFields && isTopicField(leaf.spec.name ?? '') ? undefined : inputId; return ( @@ -820,7 +822,7 @@ const ScalarField = ({ leaf, control }: { leaf: Leaf; control: Control ) : null} - + ); }} @@ -831,7 +833,8 @@ const ScalarField = ({ leaf, control }: { leaf: Leaf; control: Control }) => { const inputId = useId(); const lintErrors = useContext(FieldLintErrorsContext); - const { clusterTopicFields } = useContext(ResourceFieldContext); + const { clusterTopicFields, fieldDocsUrl } = useContext(ResourceFieldContext); + const docsUrl = fieldDocsUrl?.(leaf.path); const isTopics = Boolean(clusterTopicFields) && isTopicField(leaf.spec.name ?? ''); return ( field.onChange([...lines, t].join('\n'))} /> ) : null} - + ); }} @@ -1140,6 +1143,7 @@ export function NodeConfigForm({ componentResourceKind: resourceKindForComponentName(componentName), clusterTopicFields, onCreateTopic: clusterTopicFields ? onCreateTopic : undefined, + fieldDocsUrl: (path) => getFieldDocsUrl(spec.type, componentName, path), }; const advancedLintSignature = advanced diff --git a/frontend/src/components/pages/rp-connect/template-gallery/__tests__/template-schema.test.tsx b/frontend/src/components/pages/rp-connect/template-gallery/__tests__/template-schema.test.tsx index 3a6f098b71..bac74faa0c 100644 --- a/frontend/src/components/pages/rp-connect/template-gallery/__tests__/template-schema.test.tsx +++ b/frontend/src/components/pages/rp-connect/template-gallery/__tests__/template-schema.test.tsx @@ -28,6 +28,19 @@ const components = [ { name: 'dsn', type: 'string', kind: 'scalar', description: 'Postgres DSN', defaultValue: 'pg-default' }, // No default → required by checkRequired. { name: 'snapshot', type: 'string', kind: 'scalar', description: 'Snapshot mode' }, + { + name: 'multiline', + type: 'string', + kind: 'scalar', + description: 'A Data Source Name.\n\n==== Drivers\n:driver-support: mysql=certified\n\nSee the driver list.', + }, + { + name: 'blankShort', + type: 'string', + kind: 'scalar', + description: 'The long one.', + shortDescription: ' ', + }, ], }, }, @@ -83,6 +96,24 @@ describe('applySchemaToSlots', () => { expect(result.description).toBe('Snapshot mode'); }); + test('flattens a multi-paragraph AsciiDoc description to plain slot help', () => { + const template = buildTemplate([ + slot({ id: 'dsn', kind: 'string', section: 'source', label: 'DSN', schemaField: 'multiline' }), + ]); + + const [result] = applySchemaToSlots(template, components); + expect(result.description).toBe('A Data Source Name. Drivers See the driver list.'); + }); + + test('treats a blank short description as absent, as the config form does', () => { + const template = buildTemplate([ + slot({ id: 'blank', kind: 'string', section: 'source', label: 'Blank', schemaField: 'blankShort' }), + ]); + + const [result] = applySchemaToSlots(template, components); + expect(result.description).toBe('The long one.'); + }); + test('keeps an explicit slot description over the schema description', () => { const template = buildTemplate([ slot({ 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..481ada7650 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 @@ -10,9 +10,14 @@ */ import type { PipelineTemplate, TemplateSlot } from './pipeline-template-types'; -import type { ConnectComponentSpec } from '../types/schema'; +import type { ConnectComponentSpec, RawFieldSpec } from '../types/schema'; +import { asciidocToMarkdown, markdownToPlainText } from '../utils/asciidoc'; import { checkRequired, findConnectComponent, resolveFieldByPath } from '../utils/schema'; +// Slot help renders as plain text, and the fallback is often multi-paragraph AsciiDoc. +const slotDescription = (field: RawFieldSpec): string | undefined => + field.shortDescription?.trim() || markdownToPlainText(asciidocToMarkdown(field.description ?? '')) || undefined; + // Slot-level values win; schema only fills unset `description` / `required` / // `default`. Slots without `schemaField` (or with unresolvable paths) pass through. // Pass enriched specs (enrichComponentsWithConfigSchema) so `required` uses the @@ -47,7 +52,7 @@ export function applySchemaToSlots(template: PipelineTemplate, components?: Conn const merged: TemplateSlot = { ...slot, - description: slot.description ?? (field.description || undefined), + description: slot.description ?? slotDescription(field), 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..58610b0be3 --- /dev/null +++ b/frontend/src/components/pages/rp-connect/utils/asciidoc.test.ts @@ -0,0 +1,139 @@ +/** + * 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, markdownToPlainText } from './asciidoc'; + +const lines = (...rows: string[]) => rows.join('\n'); + +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-support: mysql=certified', + '', + '|===', + '| 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.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 new file mode 100644 index 0000000000..9cfed7fb0a --- /dev/null +++ b/frontend/src/components/pages/rp-connect/utils/asciidoc.ts @@ -0,0 +1,146 @@ +/** + * 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 + */ + +/** + * Connect schema prose is AsciiDoc, not Markdown — component `summary`/`description` and per-field + * `description`. `short_description` carries no markup by contract and must not come through here. + */ + +// Macro label, allowing AsciiDoc's `\]` escape. +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'); +// Lazy up to the `](`, so brackets nested in the label (code-spanned DSN examples) stay part of it. +const MARKDOWN_LINK = /\[([^\n]*?)\]\([^)\n]*\)/g; +const INTERNAL_XREF = /<<([^>,\n]+)(?:,\s*([^>\n]+))?>>/g; +const NEW_WINDOW_FLAG = /\^$/; + +// So "found in xref:…[]." doesn't render as "found in .". +const EMPTY_XREF_LABEL = 'the documentation'; + +const ATTRIBUTE_LINE = /^:[a-zA-Z][\w-]*:.*$/gm; +// Admonitions 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; +// `-{4,}` leaves a Markdown `---` alone. +const BLOCK_DELIMITER = /^(?:={2,}|-{4,}|\*{4,}|_{4,}|\+{4,})$/gm; +const BLOCK_TITLE_LINE = /^\.([A-Z][^\n]*)$/gm; +const SECTION_TITLE = /^=+\s+(.{1,60})$/gm; +const OVERLONG_SECTION_MARKER = /^=+\s+/gm; +const LIST_MARKER = /^\*\s+/gm; +const BLANK_LINE_RUN = /\n{3,}/g; + +const TABLE_FENCE = /^\|===$/; +const TABLE_RULE_ROW = /^\|[\s:|-]+$/; +const TABLE_CELL_MARKERS = /^\|\s*|\s*\|$/g; +const TABLE_CELL_SEPARATOR = /\s*\|\s*/; +// `[%header,format=dsv]` tables separate cells with `:` instead. +const DSV_TABLE_ATTRIBUTE = /^\[.*format=dsv.*\]$/; +const DSV_CELL_SEPARATOR = /\s*:\s*/; + +const CODE_BLOCK_OR_SPAN = /(```[\s\S]*?```|`[^`\n]*`)/g; +const PLACEHOLDER_OPENER = /<(?!https?:\/\/)(?=[a-zA-Z/])/g; +const MARKDOWN_MARKS = /[`*]/g; +const MARKDOWN_HEADING = /^#+\s*/gm; +const MARKDOWN_LIST_MARKER = /^[-*]\s+/gm; +const BACKSLASH_ESCAPE = /\\(.)/g; +const WHITESPACE_RUN = /\s+/g; +const CRLF = /\r\n/g; + +function macroLabel(label: string): string { + return label.replace(BACKSLASH_ESCAPE, '$1').replace(NEW_WINDOW_FLAG, '').trim(); +} + +const macrosToLabels = (text: string): string => + text + .replace(XREF_MACRO, (_match, label: string) => macroLabel(label) || EMPTY_XREF_LABEL) + .replace(INTERNAL_XREF, (_match, anchor: string, label?: string) => (label ?? anchor).trim()); + +/** + * Flattens `|===` and Markdown pipe tables to one bullet per row — react-markdown is mounted without + * remark-gfm. Outside a fence a row must be pipe-delimited at both ends, so prose keeps its `|`. + */ +function flattenTables(text: string): string { + if (!text.includes('|')) { + return text; + } + let inFence = false; + let separator = TABLE_CELL_SEPARATOR; + const lines: string[] = []; + for (const line of text.split('\n')) { + const trimmed = line.trim(); + if (TABLE_FENCE.test(trimmed)) { + inFence = !inFence; + if (!inFence) { + separator = TABLE_CELL_SEPARATOR; + } + continue; + } + if (!inFence && DSV_TABLE_ATTRIBUTE.test(trimmed)) { + separator = DSV_CELL_SEPARATOR; + lines.push(line); + continue; + } + const isRow = inFence || (trimmed.startsWith('|') && trimmed.endsWith('|')); + if (!isRow) { + lines.push(line); + continue; + } + // A Markdown header rule (`|---|---|`) carries no content; inside a fence it could be a row. + if (!inFence && TABLE_RULE_ROW.test(trimmed)) { + continue; + } + const cells = trimmed.replace(TABLE_CELL_MARKERS, ''); + lines.push(cells ? `- ${cells.split(separator).join(' — ')}` : ''); + } + return lines.join('\n'); +} + +/** Escapes `` outside code: remark reads it as inline HTML and, with HTML off, drops it. */ +function escapePlaceholders(text: string): string { + return text + .split(CODE_BLOCK_OR_SPAN) + .map((part, index) => (index % 2 === 1 ? part : part.replace(PLACEHOLDER_OPENER, String.raw`\<`))) + .join(''); +} + +/** AsciiDoc to Markdown for react-markdown; newlines survive, so titles and paragraphs stay distinct. */ +export function asciidocToMarkdown(raw: string): string { + return escapePlaceholders( + macrosToLabels(flattenTables(raw.replace(CRLF, '\n'))) + .replace(ATTRIBUTE_LINE, '') + .replace(ADMONITION_LINE, '**$1**') + .replace(BLOCK_ATTRIBUTE_LINE, '') + .replace(BLOCK_DELIMITER, '') + .replace(BLOCK_TITLE_LINE, '#### $1') + .replace(URL_MACRO, (_match, url: string, label: string) => { + const text = macroLabel(label); + return text ? `[${text}](${url})` : url; + }) + .replace(SECTION_TITLE, '#### $1') + .replace(OVERLONG_SECTION_MARKER, '') + .replace(LIST_MARKER, '- ') + .replace(BLANK_LINE_RUN, '\n\n') + .trim() + ); +} + +/** Strips Markdown syntax to a single line, for collapsed previews. */ +export function markdownToPlainText(markdown: string): string { + return markdown + .replace(MARKDOWN_LINK, '$1') + .replace(MARKDOWN_HEADING, '') + .replace(MARKDOWN_LIST_MARKER, '') + .replace(BACKSLASH_ESCAPE, '$1') + .replace(MARKDOWN_MARKS, '') + .replace(WHITESPACE_RUN, ' ') + .trim(); +} 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..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 @@ -11,7 +11,9 @@ import { describe, expect, it } from 'vitest'; -import { getConnectorDocsUrl, getNodeDocsUrl } from './connector-docs'; +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', () => { @@ -58,6 +60,27 @@ describe('getConnectorDocsUrl', () => { }); }); +describe('getFieldDocsUrl', () => { + const REDPANDA_INPUT = `${DOCS}/inputs/redpanda/`; + + 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`. + [ + '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', () => { + 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..20cc901ad8 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,24 @@ export function getConnectorDocsUrl(section: string, connectorName: string): str return `${DOCS_BASE}/${section}s/${connectorName}/`; } +/** + * Docs URL for one field, anchored by its dotted path with list markers dropped + * (`batching.byte_size` → `#batching-byte_size`) — exactly the form's field path. A name that + * collides with a prose section on the page is anchored `-2` there instead (~0.2% of fields), and a + * missed anchor lands 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. */