diff --git a/src/common-elements/Dropdown/styled.ts b/src/common-elements/Dropdown/styled.ts index 313de2b3b5..7aec802ea7 100644 --- a/src/common-elements/Dropdown/styled.ts +++ b/src/common-elements/Dropdown/styled.ts @@ -86,7 +86,9 @@ export const SimpleDropdown = styled(Dropdown)` } `; -export const MimeLabel = styled.span` +export const MimeLabel = styled.span.attrs({ + translate: 'no', +})` margin-left: 10px; text-transform: none; font-size: 0.929em; diff --git a/src/common-elements/fields.ts b/src/common-elements/fields.ts index f95781f49c..789438ec91 100644 --- a/src/common-elements/fields.ts +++ b/src/common-elements/fields.ts @@ -65,7 +65,9 @@ export const TypePrefix = styled(FieldLabel)` color: ${props => transparentize(0.1, props.theme.schema.typeNameColor)}; `; -export const TypeName = styled(FieldLabel)` +export const TypeName = styled(FieldLabel).attrs({ + translate: 'no', +})` color: ${props => props.theme.schema.typeNameColor}; `; @@ -95,7 +97,9 @@ export const RecursiveLabel = styled(FieldLabel)` font-size: 13px; `; -export const PatternLabel = styled(FieldLabel)` +export const PatternLabel = styled(FieldLabel).attrs({ + translate: 'no', +})` color: #0e7c86; font-family: ${props => props.theme.typography.code.fontFamily}; font-size: 12px; @@ -105,7 +109,9 @@ export const PatternLabel = styled(FieldLabel)` } `; -export const ExampleValue = styled(FieldLabel)` +export const ExampleValue = styled(FieldLabel).attrs({ + translate: 'no', +})` border-radius: 2px; word-break: break-word; ${({ theme }) => ` diff --git a/src/common-elements/samples.tsx b/src/common-elements/samples.tsx index ce4a96321e..b28ac1d7ed 100644 --- a/src/common-elements/samples.tsx +++ b/src/common-elements/samples.tsx @@ -34,6 +34,7 @@ export const SampleControlsWrap = styled.div` export const StyledPre = styled(PrismDiv).attrs({ as: 'pre', + translate: 'no', })` font-family: ${props => props.theme.typography.code.fontFamily}; font-size: ${props => props.theme.typography.code.fontSize}; 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/Callbacks/CallbackTitle.tsx b/src/components/Callbacks/CallbackTitle.tsx index 5b123ff8ca..8f33ff1078 100644 --- a/src/components/Callbacks/CallbackTitle.tsx +++ b/src/components/Callbacks/CallbackTitle.tsx @@ -22,9 +22,13 @@ export const CallbackTitle = (props: CallbackTitleProps) => { return ( - {shortenHTTPVerb(httpVerb)} + + {shortenHTTPVerb(httpVerb)} + - {name} + + {name} + {deprecated ? {l('deprecated')} : null} ); 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/EnumValues.tsx b/src/components/Fields/EnumValues.tsx index a26673985f..6038377ac9 100644 --- a/src/components/Fields/EnumValues.tsx +++ b/src/components/Fields/EnumValues.tsx @@ -93,7 +93,7 @@ export class EnumValues extends React.PureComponent { return ( - {value} + {value} diff --git a/src/components/Fields/Extensions.tsx b/src/components/Fields/Extensions.tsx index 5a450ce4bd..a554e6b6f8 100644 --- a/src/components/Fields/Extensions.tsx +++ b/src/components/Fields/Extensions.tsx @@ -28,7 +28,7 @@ export class Extensions extends React.PureComponent { {options.showExtensions && Object.keys(exts).map(key => ( - {key.substring(2)}: {' '} + {key.substring(2)}: {' '} {typeof exts[key] === 'string' ? exts[key] : JSON.stringify(exts[key])} diff --git a/src/components/Fields/Field.tsx b/src/components/Fields/Field.tsx index 8ac4ef7cf2..f889f7edd8 100644 --- a/src/components/Fields/Field.tsx +++ b/src/components/Fields/Field.tsx @@ -80,11 +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} @@ -92,11 +94,13 @@ export class Field extends React.Component { ) : ( - {!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} ); diff --git a/src/components/PayloadSamples/styled.elements.ts b/src/components/PayloadSamples/styled.elements.ts index b9dd40c1f1..b3e6df8b91 100644 --- a/src/components/PayloadSamples/styled.elements.ts +++ b/src/components/PayloadSamples/styled.elements.ts @@ -2,7 +2,9 @@ import { transparentize } from 'polished'; import styled from '../../styled-components'; import { Dropdown } from '../../common-elements/Dropdown'; -export const MimeLabel = styled.div` +export const MimeLabel = styled.div.attrs({ + translate: 'no', +})` padding: 0.9em; background-color: ${({ theme }) => transparentize(0.6, theme.rightPanel.backgroundColor)}; margin: 0 0 10px 0; diff --git a/src/components/Responses/styled.elements.ts b/src/components/Responses/styled.elements.ts index 65a3c9ccdc..ee37a88df0 100644 --- a/src/components/Responses/styled.elements.ts +++ b/src/components/Responses/styled.elements.ts @@ -49,6 +49,8 @@ export const HeadersCaption = styled(UnderlinedHeader).attrs({ caption-side: top; `; -export const Code = styled.strong` +export const Code = styled.strong.attrs({ + translate: 'no', +})` vertical-align: top; `; diff --git a/src/components/SecurityRequirement/OAuthFlow.tsx b/src/components/SecurityRequirement/OAuthFlow.tsx index 1c261e34ca..7d524f4d3c 100644 --- a/src/components/SecurityRequirement/OAuthFlow.tsx +++ b/src/components/SecurityRequirement/OAuthFlow.tsx @@ -18,12 +18,12 @@ export function OAuthFlowComponent(props: OAuthFlowProps) { <> Flow type: - {type} + {type} {(type === 'implicit' || type === 'authorizationCode') && ( Authorization URL: - + {(flow as any).authorizationUrl} @@ -33,13 +33,13 @@ export function OAuthFlowComponent(props: OAuthFlowProps) { {(type === 'password' || type === 'clientCredentials' || type === 'authorizationCode') && ( Token URL: - {(flow as any).tokenUrl} + {(flow as any).tokenUrl} )} {flow!.refreshUrl && ( Refresh URL: - {flow!.refreshUrl} + {flow!.refreshUrl} )} {!!scopesNames.length && ( @@ -52,7 +52,7 @@ export function OAuthFlowComponent(props: OAuthFlowProps) {
    {scopesNames.map(scope => (
  • - {scope} -{' '} + {scope} -{' '} { return ( - {scope}{' '} + {scope}{' '} ); })} diff --git a/src/components/SecurityRequirement/SecurityDetails.tsx b/src/components/SecurityRequirement/SecurityDetails.tsx index 54cd61f86e..7d65a189ea 100644 --- a/src/components/SecurityRequirement/SecurityDetails.tsx +++ b/src/components/SecurityRequirement/SecurityDetails.tsx @@ -18,7 +18,7 @@ export function SecurityDetails(props: SecuritySchemaProps) { <> {titleize(scheme.apiKey.in || '')} parameter name: - {scheme.apiKey.name} + {scheme.apiKey.name} {RequiredScopes} @@ -26,13 +26,13 @@ export function SecurityDetails(props: SecuritySchemaProps) { <> HTTP Authorization Scheme: - {scheme.http.scheme} + {scheme.http.scheme} {scheme.http.scheme === 'bearer' && scheme.http.bearerFormat && ( <> Bearer format: - {scheme.http.bearerFormat} + {scheme.http.bearerFormat} )} @@ -42,7 +42,7 @@ export function SecurityDetails(props: SecuritySchemaProps) { <> Connect URL: - + {scheme.openId.connectUrl} diff --git a/src/components/SecurityRequirement/styled.elements.ts b/src/components/SecurityRequirement/styled.elements.ts index 9c501df2e4..09df119dab 100644 --- a/src/components/SecurityRequirement/styled.elements.ts +++ b/src/components/SecurityRequirement/styled.elements.ts @@ -24,7 +24,9 @@ export const ScopeNameList = styled.ul` } `; -export const ScopeName = styled.code` +export const ScopeName = styled.code.attrs({ + translate: 'no', +})` font-size: ${props => props.theme.typography.code.fontSize}; font-family: ${props => props.theme.typography.code.fontFamily}; margin: 0 3px; 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/components/SideMenu/MenuItem.tsx b/src/components/SideMenu/MenuItem.tsx index 2b7577b007..66fb0fb00b 100644 --- a/src/components/SideMenu/MenuItem.tsx +++ b/src/components/SideMenu/MenuItem.tsx @@ -117,11 +117,13 @@ export const OperationMenuItemContent = observer((props: OperationMenuItemConten ))} {item.isWebhook ? ( - + {showWebhookVerb ? item.httpVerb : l('webhook')} ) : ( - {shortenHTTPVerb(item.httpVerb)} + + {shortenHTTPVerb(item.httpVerb)} + )} {item.sidebarLabel} diff --git a/src/components/__tests__/__snapshots__/FieldDetails.test.tsx.snap b/src/components/__tests__/__snapshots__/FieldDetails.test.tsx.snap index aa1cc03cb1..dfc51a6926 100644 --- a/src/components/__tests__/__snapshots__/FieldDetails.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/FieldDetails.test.tsx.snap @@ -10,6 +10,7 @@ exports[`FieldDetailsComponent renders correctly 1`] = ` array @@ -36,6 +37,7 @@ exports[`FieldDetailsComponent renders correctly 1`] = ` [] @@ -50,6 +52,7 @@ exports[`FieldDetailsComponent renders correctly 1`] = ` "example" @@ -76,6 +79,7 @@ exports[`FieldDetailsComponent renders correctly when default value is object in /> object @@ -102,6 +106,7 @@ exports[`FieldDetailsComponent renders correctly when default value is object in {"properties":{}} @@ -116,6 +121,7 @@ exports[`FieldDetailsComponent renders correctly when default value is object in "example" @@ -142,6 +148,7 @@ exports[`FieldDetailsComponent renders correctly when field items have string ty /> Array of strings @@ -172,6 +179,7 @@ exports[`FieldDetailsComponent renders correctly when field items have string ty ^see regex[0-9]$ @@ -188,6 +196,7 @@ exports[`FieldDetailsComponent renders correctly when field items have string ty "example" diff --git a/src/components/__tests__/__snapshots__/OneOfSchema.test.tsx.snap b/src/components/__tests__/__snapshots__/OneOfSchema.test.tsx.snap index 3347a046af..1583604b59 100644 --- a/src/components/__tests__/__snapshots__/OneOfSchema.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/OneOfSchema.test.tsx.snap @@ -37,6 +37,7 @@ exports[`Components SchemaView OneOf deprecated should match snapshot 1`] = ` /> string diff --git a/src/components/__tests__/__snapshots__/SecurityRequirement.test.tsx.snap b/src/components/__tests__/__snapshots__/SecurityRequirement.test.tsx.snap index cfea66c7fe..5ee85eb469 100644 --- a/src/components/__tests__/__snapshots__/SecurityRequirement.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/SecurityRequirement.test.tsx.snap @@ -3,21 +3,21 @@ exports[`SecurityRequirement should render SecurityDefs 1`] = ` "

    petstore_auth

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

    -
    Security Scheme Type: OAuth2
    Flow type: implicit
    Scopes:
    • write:pets -

      modify pets in your account

      -
    • read:pets -

      read your pets

      +
      Security Scheme Type: OAuth2
      Flow type: implicit
      Scopes:
      • write:pets -

        modify pets in your account

        +
      • read:pets -

        read your pets

    GitLab_PersonalAccessToken

    GitLab Personal Access Token description

    -
    Security Scheme Type: API Key
    Header parameter name: PRIVATE-TOKEN

    GitLab_OpenIdConnect

    GitLab OpenIdConnect description

    -
    Security Scheme Type: OpenID Connect

    basicAuth

    Security Scheme Type: HTTP
    HTTP Authorization Scheme: basic
    " +
    Security Scheme Type: API Key
    Header parameter name: PRIVATE-TOKEN

    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/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='); + }); +}); 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('

    ' + htmlEncode(value) + ''; + return '' + htmlEncode(value) + ''; } function punctuation(val) { - return '' + val + ''; + return '' + val + ''; } function valueToHTML(value, maxExpandLevel: number) { @@ -106,7 +106,8 @@ function objectToHTML(json, maxExpandLevel: number) { const key = keys[i]; hasContents = true; output += '