From 19dc3892ce3656e0845c2627f0da94dc9ed9226d Mon Sep 17 00:00:00 2001 From: Marques Johansson Date: Thu, 5 Jun 2025 19:53:26 +0000 Subject: [PATCH 1/4] feat: use translate=no attribute in code sensitive renderings Signed-off-by: Marques Johansson --- src/components/ApiInfo/ApiInfo.tsx | 2 +- src/components/Endpoint/Endpoint.tsx | 6 +++--- src/components/Fields/Field.tsx | 12 ++++++------ src/components/Fields/FieldConstraints.tsx | 2 +- src/components/SecuritySchemes/SecuritySchemes.tsx | 2 +- src/utils/jsonToHtml.ts | 6 +++--- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/components/ApiInfo/ApiInfo.tsx b/src/components/ApiInfo/ApiInfo.tsx index 75e004387a..dc22feb21f 100644 --- a/src/components/ApiInfo/ApiInfo.tsx +++ b/src/components/ApiInfo/ApiInfo.tsx @@ -67,7 +67,7 @@ export class ApiInfo extends React.Component { )) || null; - const version = (info.version && ({info.version})) || null; + const version = (info.version && ({info.version})) || null; return (
diff --git a/src/components/Endpoint/Endpoint.tsx b/src/components/Endpoint/Endpoint.tsx index 96eca64648..232fb4b85c 100644 --- a/src/components/Endpoint/Endpoint.tsx +++ b/src/components/Endpoint/Endpoint.tsx @@ -50,10 +50,10 @@ export class Endpoint extends React.Component { {options => ( - + {operation.httpVerb} - {operation.path} + {operation.path} { - + {hideHostname || options.hideHostname ? basePath === '/' ? '' diff --git a/src/components/Fields/Field.tsx b/src/components/Fields/Field.tsx index 8ac4ef7cf2..6ed38eddac 100644 --- a/src/components/Fields/Field.tsx +++ b/src/components/Fields/Field.tsx @@ -80,13 +80,13 @@ export class Field extends React.Component { onKeyPress={this.handleKeyPress} aria-label={`expand ${name}`} > - {!hidePropertiesPrefix && - fieldParentsName.map( - name => name + '.\u200B', // zero-width space, a special character is used for correct line breaking - )} - {name} + {!hidePropertiesPrefix && + fieldParentsName.map( + name => name + '.\u200B', // zero-width space, a special character is used for correct line breaking + )} + {name} {labels} ) : ( @@ -96,7 +96,7 @@ export class Field extends React.Component { fieldParentsName.map( name => name + '.\u200B', // zero-width space, a special character is used for correct line breaking )} - {name} + {name} {labels} ); diff --git a/src/components/Fields/FieldConstraints.tsx b/src/components/Fields/FieldConstraints.tsx index c92e8f5c50..367a8ae2dd 100644 --- a/src/components/Fields/FieldConstraints.tsx +++ b/src/components/Fields/FieldConstraints.tsx @@ -14,7 +14,7 @@ export class ConstraintsView extends React.PureComponent { {' '} {this.props.constraints.map(constraint => ( - {constraint} + {constraint} ))} ); diff --git a/src/components/SecuritySchemes/SecuritySchemes.tsx b/src/components/SecuritySchemes/SecuritySchemes.tsx index 34a92ada33..eb2e2cf79d 100644 --- a/src/components/SecuritySchemes/SecuritySchemes.tsx +++ b/src/components/SecuritySchemes/SecuritySchemes.tsx @@ -31,7 +31,7 @@ export class SecurityDefs extends React.PureComponent { Security Scheme Type: - {AUTH_TYPES[scheme.type] || scheme.type} + {AUTH_TYPES[scheme.type] || scheme.type} diff --git a/src/utils/jsonToHtml.ts b/src/utils/jsonToHtml.ts index 3db6cd9159..a51434928f 100644 --- a/src/utils/jsonToHtml.ts +++ b/src/utils/jsonToHtml.ts @@ -27,11 +27,11 @@ function stringifyStringLiteral(str: string) { } function decorateWithSpan(value, className) { - return '' + htmlEncode(value) + ''; + return '' + htmlEncode(value) + ''; } function punctuation(val) { - return '' + val + ''; + return '' + val + ''; } function valueToHTML(value, maxExpandLevel: number) { @@ -106,7 +106,7 @@ function objectToHTML(json, maxExpandLevel: number) { const key = keys[i]; hasContents = true; output += '
  • GitLab_OpenIdConnect

    GitLab OpenIdConnect description

    +
    Security Scheme Type: OpenID Connect

    basicAuth

    Security Scheme Type: HTTP
    HTTP Authorization Scheme: basic
    " `; exports[`SecurityRequirement should render authDefinition 1`] = `"
    Authorizations:
    (API Key: GitLab_PersonalAccessTokenOpenID Connect: GitLab_OpenIdConnectHTTP: basicAuth) OAuth2: petstore_auth
    ,"`; diff --git a/src/utils/jsonToHtml.ts b/src/utils/jsonToHtml.ts index a51434928f..8751c2cf31 100644 --- a/src/utils/jsonToHtml.ts +++ b/src/utils/jsonToHtml.ts @@ -3,7 +3,7 @@ let level = 1; export function jsonToHTML(json, maxExpandLevel) { level = 1; let output = ''; - output += '
    '; + output += '
    '; output += ''; output += valueToHTML(json, maxExpandLevel); output += ''; @@ -106,7 +106,8 @@ function objectToHTML(json, maxExpandLevel: number) { const key = keys[i]; hasContents = true; output += '
  • GitLab_OpenIdConnect

    GitLab OpenIdConnect description

    +
    Security Scheme Type: OpenID Connect

    basicAuth

    Security Scheme Type: HTTP
    HTTP Authorization Scheme: basic
    " `; exports[`SecurityRequirement should render authDefinition 1`] = `"
    Authorizations:
    (API Key: GitLab_PersonalAccessTokenOpenID Connect: GitLab_OpenIdConnectHTTP: basicAuth) OAuth2: petstore_auth
    ,"`; exports[`SecurityRequirement should render authDefinition 2`] = ` -"
    Authorizations:
    (API Key: GitLab_PersonalAccessTokenOpenID Connect: GitLab_OpenIdConnectHTTP: basicAuth) OAuth2: petstore_auth (write:petsread:pets)
    OAuth2: petstore_auth

    Get access to data while protecting your account credentials. +"

    Authorizations:
    (API Key: GitLab_PersonalAccessTokenOpenID Connect: GitLab_OpenIdConnectHTTP: basicAuth) OAuth2: petstore_auth (write:petsread:pets)
    OAuth2: petstore_auth

    Get access to data while protecting your account credentials. OAuth2 is also a safer and more secure way to give you access.

    -
    Flow type: implicit
    Required scopes: write:pets read:pets
    Scopes:
    • write:pets -

      modify pets in your account

      -
    • read:pets -

      read your pets

      +
      Flow type: implicit
      Required scopes: write:pets read:pets
      Scopes:
      • write:pets -

        modify pets in your account

        +
      • read:pets -

        read your pets

    API Key: GitLab_PersonalAccessToken

    GitLab Personal Access Token description

    -
    Header parameter name: PRIVATE-TOKEN
    OpenID Connect: GitLab_OpenIdConnect

    GitLab OpenIdConnect description

    -
    HTTP: basicAuth
    HTTP Authorization Scheme: basic
    ," +
    Header parameter name: PRIVATE-TOKEN
    OpenID Connect: GitLab_OpenIdConnect

    GitLab OpenIdConnect description

    +
    HTTP: basicAuth
    HTTP Authorization Scheme: basic
    ," `; diff --git a/src/services/MarkdownRenderer.ts b/src/services/MarkdownRenderer.ts index ba0c3d77de..51ae2386ab 100644 --- a/src/services/MarkdownRenderer.ts +++ b/src/services/MarkdownRenderer.ts @@ -6,6 +6,16 @@ import type { MarkdownHeading, MDXComponentMeta } from './types'; const renderer = new marked.Renderer(); +// Code in descriptions is still code: keep machine translators away from it while +// leaving the surrounding prose translatable. +const renderCode = renderer.code.bind(renderer); +renderer.code = (...args: Parameters) => + `
    ${renderCode(...args)}
    `; + +const renderCodespan = renderer.codespan.bind(renderer); +renderer.codespan = (...args: Parameters) => + `${renderCodespan(...args)}`; + marked.setOptions({ renderer, highlight: (str, lang) => { diff --git a/src/services/__tests__/mdtranslate.test.ts b/src/services/__tests__/mdtranslate.test.ts new file mode 100644 index 0000000000..59bbdff855 --- /dev/null +++ b/src/services/__tests__/mdtranslate.test.ts @@ -0,0 +1,22 @@ +import { MarkdownRenderer } from '../MarkdownRenderer'; +import { RedocNormalizedOptions } from '../RedocNormalizedOptions'; + +describe('MarkdownRenderer translate="no"', () => { + const render = (md: string) => new MarkdownRenderer(new RedocNormalizedOptions({})).renderMd(md); + + it('marks fenced code blocks as non-translatable', () => { + expect(render('```json\n{"a":1}\n```\n')).toContain('
    ');
    +  });
    +
    +  it('marks inline code spans as non-translatable', () => {
    +    expect(render('Set `limit` to 10.')).toContain(
    +      'limit',
    +    );
    +  });
    +
    +  it('leaves surrounding prose translatable', () => {
    +    const out = render('Set `limit` to 10.');
    +    expect(out).toContain('

    Set '); + expect(out).not.toContain('

    Date: Fri, 4 Sep 2026 16:08:38 -0400 Subject: [PATCH 4/4] test: assert translate=no invariants directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snapshots recorded the attribute incidentally, but nothing asserted it, and several changed surfaces (code samples, media type labels, callback titles, extension keys) had no assertion at all. Adds `translateNo.test.tsx` with both directions: - code-sensitive surfaces are marked: type names, patterns, example/default values, Prism-highlighted samples via `SourceCode`, the `.redoc-json` container, OAuth scope names, flow type and URLs, callback verb and name, and `x-` extension keys; - prose stays translatable: humanized constraints, `TypePrefix`, `TypeTitle`, and `RecursiveLabel`. The negative cases are the regression guard for the `ConstraintItem` fix — `humanizeConstraints()` emits English prose, so marking it was wrong. Verified each assertion is load-bearing by stripping the attribute from the sources under test and confirming 10/11 fail, then separately marking `TypePrefix`/`TypeTitle`/`RecursiveLabel` to confirm the 11th fails too. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/__tests__/translateNo.test.tsx | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 src/components/__tests__/translateNo.test.tsx diff --git a/src/components/__tests__/translateNo.test.tsx b/src/components/__tests__/translateNo.test.tsx new file mode 100644 index 0000000000..27be3b8a46 --- /dev/null +++ b/src/components/__tests__/translateNo.test.tsx @@ -0,0 +1,108 @@ +/* tslint:disable:no-implicit-dependencies */ +import { mount } from 'enzyme'; +import * as React from 'react'; + +// Import through the barrel first: importing the leaf modules directly hits a +// circular-import cycle via common-elements/index. +import { JsonViewer, SourceCode } from '../'; +import { CallbackTitle } from '../Callbacks/CallbackTitle'; +import { Extensions } from '../Fields/Extensions'; +import { RequiredScopesRow } from '../SecurityRequirement/RequiredScopesRow'; +import { OAuthFlow } from '../SecurityRequirement/OAuthFlow'; +import { ConstraintsView } from '../Fields/FieldConstraints'; +import { OptionsProvider } from '../OptionsProvider'; +import { RedocNormalizedOptions } from '../../services/RedocNormalizedOptions'; +import { + ConstraintItem, + ExampleValue, + PatternLabel, + RecursiveLabel, + TypeName, + TypePrefix, + TypeTitle, +} from '../../common-elements/fields'; +import { withTheme } from '../testProviders'; + +const html = (node: React.ReactElement) => mount(withTheme(node)).html(); + +// Elements whose text is derived from the spec: type keywords, patterns, +// example/default/enum values. These must never be machine-translated. +describe('translate="no" on code-sensitive renderings', () => { + it('marks schema type names', () => { + expect(html(string)).toContain('translate="no"'); + }); + + it('marks schema patterns', () => { + expect(html(^[a-z]+$)).toContain('translate="no"'); + }); + + it('marks example and default values', () => { + expect(html({'"abc"'})).toContain('translate="no"'); + }); + + it('marks highlighted code samples', () => { + // Covers request samples (curl/JS/Python) and every non-JSON example. + expect(html()).toContain('translate="no"'); + }); + + it('marks the JSON sample container', () => { + const out = html(); + expect(out).toContain('class="redoc-json" translate="no"'); + }); + + it('marks OAuth scope names', () => { + expect(html()).toContain( + 'write:pets', + ); + }); + + it('marks OAuth flow type and URLs', () => { + const out = html( + , + ); + expect(out).toContain('authorizationCode '); + expect(out).toContain('https://e.x/token'); + expect(out.match(/ { + const out = html(); + expect(out.match(/translate="no"/g)!.length).toBe(2); + expect(out).toContain('{$request.body#/callbackUrl}'); + }); + + it('marks x- extension names and their values', () => { + // Extensions only render when showExtensions is on. + const out = mount( + withTheme( + + + , + ), + ).html(); + expect(out).toContain('internal-id'); + // Both the extension key and its value are marked. + expect(out.match(/translate="no"/g)!.length).toBe(2); + }); +}); + +// Redoc-authored UI prose. `humanizeConstraints()` produces English strings such +// as "non-empty" and ">= 5 characters"; schema titles and "Array of" are author +// or label text. All of these must stay translatable. +describe('prose stays translatable', () => { + it('does not mark humanized constraints', () => { + expect(html(non-empty)).not.toContain('translate='); + expect(html(= 5 characters']} />)).not.toContain( + 'translate=', + ); + }); + + it('does not mark the type prefix, schema title, or recursive label', () => { + expect(html(Array of )).not.toContain('translate='); + expect(html(Pet)).not.toContain('translate='); + expect(html(recursive)).not.toContain('translate='); + }); +});