diff --git a/CHANGES/2156.feature b/CHANGES/2156.feature new file mode 100644 index 0000000000..9685d75f6c --- /dev/null +++ b/CHANGES/2156.feature @@ -0,0 +1 @@ +Collection documentation: support semantic markup. diff --git a/package-lock.json b/package-lock.json index 0d0743dd42..aecaac39b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@types/node": "^16.18.24", "@types/react": "^17.0.2", "@types/react-dom": "^17.0.2", + "antsibull-docs": "^1.0.0", "axios": "~1.4.0", "classnames": "^2.3.2", "csstype": "^3.1.2", @@ -4776,6 +4777,11 @@ "node": ">=4" } }, + "node_modules/antsibull-docs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/antsibull-docs/-/antsibull-docs-1.0.0.tgz", + "integrity": "sha512-OqrXCQkKeqTTuFBmQc9RJnXPcjUtRnMq0rfmT3zlp6UHJWTwq1SLffwOEw1I5jC7FNLMoabUjPb8wxCNB3RaZw==" + }, "node_modules/anymatch": { "version": "3.1.2", "dev": true, @@ -18302,6 +18308,11 @@ "color-convert": "^1.9.0" } }, + "antsibull-docs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/antsibull-docs/-/antsibull-docs-1.0.0.tgz", + "integrity": "sha512-OqrXCQkKeqTTuFBmQc9RJnXPcjUtRnMq0rfmT3zlp6UHJWTwq1SLffwOEw1I5jC7FNLMoabUjPb8wxCNB3RaZw==" + }, "anymatch": { "version": "3.1.2", "dev": true, diff --git a/package.json b/package.json index 4c9c0c0ced..5b965d3c0e 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@types/node": "^16.18.24", "@types/react": "^17.0.2", "@types/react-dom": "^17.0.2", + "antsibull-docs": "^1.0.0", "axios": "~1.4.0", "classnames": "^2.3.2", "csstype": "^3.1.2", diff --git a/src/components/render-plugin-doc/render-plugin-doc.tsx b/src/components/render-plugin-doc/render-plugin-doc.tsx index 32a0171329..afbf80ecb7 100644 --- a/src/components/render-plugin-doc/render-plugin-doc.tsx +++ b/src/components/render-plugin-doc/render-plugin-doc.tsx @@ -1,3 +1,4 @@ +import { dom, parse } from 'antsibull-docs'; import * as React from 'react'; import { PluginContentType, @@ -17,7 +18,11 @@ interface IState { interface IProps { plugin: PluginContentType; - renderModuleLink: (moduleName: string) => React.ReactElement; + renderPluginLink: ( + pluginName: string, + pluginType: string, + text: React.ReactNode | undefined, + ) => React.ReactElement; renderDocLink: (name: string, href: string) => React.ReactElement; renderTableOfContentsLink: ( title: string, @@ -27,10 +32,6 @@ interface IProps { } export class RenderPluginDoc extends React.Component { - // checks if I(), B(), M(), U(), L(), or C() exists. Returns type (ex: B) - // and value in parenthesis. Based off of formatters in ansible: - // https://github.com/ansible/ansible/blob/devel/hacking/build_library/build_ansible/jinja2/filters.py#L26 - CUSTOM_FORMATTERS = /([IBMULC])\(([^)]+)\)/gm; subOptionsMaxDepth: number; returnContainMaxDepth: number; @@ -232,34 +233,148 @@ export class RenderPluginDoc extends React.Component { return returnValues; } - // This functions similar to how string.replace() works, except it returns - // a react object instead of a string - private reactReplace( - text: string, - reg: RegExp, - replacement: (matches: string[]) => React.ReactNode, + private formatPartError(part: dom.ErrorPart): React.ReactNode { + return ERROR while parsing: {part.message}; + } + + private formatPartBold(part: dom.BoldPart): React.ReactNode { + return {part.text}; + } + + private formatPartCode(part: dom.CodePart): React.ReactNode { + return {part.text}; + } + + private formatPartHorizontalLine( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + part: dom.HorizontalLinePart, ): React.ReactNode { - const fragments = []; + return
; + } + + private formatPartItalic(part: dom.ItalicPart): React.ReactNode { + return {part.text}; + } + + private formatPartLink(part: dom.LinkPart): React.ReactNode { + return this.props.renderDocLink(part.text, part.url); + } + + private formatPartModule(part: dom.ModulePart): React.ReactNode { + return this.props.renderPluginLink(part.fqcn, 'module', undefined); + } + + private formatPartRstRef(part: dom.RSTRefPart): React.ReactNode { + return part.text; + } + + private formatPartURL(part: dom.URLPart): React.ReactNode { + return ( + + {part.url} + + ); + } + + private formatPartText(part: dom.TextPart): React.ReactNode { + return part.text; + } + + private formatPartEnvVariable(part: dom.EnvVariablePart): React.ReactNode { + return {part.name}; + } - let match: string[]; - let prevIndex = 0; - while ((match = reg.exec(text)) !== null) { - fragments.push( - text.substr(prevIndex, reg.lastIndex - prevIndex - match[0].length), + private formatPartOptionNameReturnValue( + part: dom.OptionNamePart | dom.ReturnValuePart, + ): React.ReactNode { + const content = + part.value === undefined ? ( + + {part.name} + + ) : ( + + {part.name}={part.value} + ); - fragments.push(replacement(match)); - prevIndex = reg.lastIndex; + if (!part.plugin) { + return content; } + return this.props.renderPluginLink( + part.plugin.fqcn, + part.plugin.type, + content, + ); + } + + private formatPartOptionValue(part: dom.OptionValuePart): React.ReactNode { + return {part.value}; + } - if (fragments.length === 0) { - return {text}; + private formatPartPlugin(part: dom.PluginPart): React.ReactNode { + return this.props.renderPluginLink( + part.plugin.fqcn, + part.plugin.type, + undefined, + ); + } + + private formatPart(part: dom.Part): React.ReactNode { + switch (part.type) { + case dom.PartType.ERROR: + return this.formatPartError(part as dom.ErrorPart); + case dom.PartType.BOLD: + return this.formatPartBold(part as dom.BoldPart); + case dom.PartType.CODE: + return this.formatPartCode(part as dom.CodePart); + case dom.PartType.HORIZONTAL_LINE: + return this.formatPartHorizontalLine(part as dom.HorizontalLinePart); + case dom.PartType.ITALIC: + return this.formatPartItalic(part as dom.ItalicPart); + case dom.PartType.LINK: + return this.formatPartLink(part as dom.LinkPart); + case dom.PartType.MODULE: + return this.formatPartModule(part as dom.ModulePart); + case dom.PartType.RST_REF: + return this.formatPartRstRef(part as dom.RSTRefPart); + case dom.PartType.URL: + return this.formatPartURL(part as dom.URLPart); + case dom.PartType.TEXT: + return this.formatPartText(part as dom.TextPart); + case dom.PartType.ENV_VARIABLE: + return this.formatPartEnvVariable(part as dom.EnvVariablePart); + case dom.PartType.OPTION_NAME: + return this.formatPartOptionNameReturnValue(part as dom.OptionNamePart); + case dom.PartType.OPTION_VALUE: + return this.formatPartOptionValue(part as dom.OptionValuePart); + case dom.PartType.PLUGIN: + return this.formatPartPlugin(part as dom.PluginPart); + case dom.PartType.RETURN_VALUE: + return this.formatPartOptionNameReturnValue( + part as dom.ReturnValuePart, + ); } + } - // append any text after the last match - if (prevIndex != text.length - 1) { - fragments.push(text.substring(prevIndex)); + private applyDocFormatters(text: string): React.ReactNode { + // TODO: pass current plugin's type and name, and (if role) the current entrypoint as well + const parsed = parse(text); + + // Special case: result is a single paragraph consisting of a single text part + if ( + parsed.length === 1 && + parsed[0].length === 1 && + parsed[0][0].type === dom.PartType.TEXT + ) { + return {parsed[0][0].text}; } + const fragments = []; + for (const paragraph of parsed) { + for (const part of paragraph) { + fragments.push(this.formatPart(part)); + } + } return ( {fragments.map((x, i) => ( @@ -269,43 +384,6 @@ export class RenderPluginDoc extends React.Component { ); } - private applyDocFormatters(text: string): React.ReactNode { - const { renderModuleLink, renderDocLink } = this.props; - - const nstring = this.reactReplace(text, this.CUSTOM_FORMATTERS, (match) => { - const fullMatch = match[0]; - const type = match[1]; - const textMatch = match[2]; - - switch (type) { - case 'L': { - const url = textMatch.split(','); - return renderDocLink(url[0], url[1]); - } - case 'U': - return ( - - {textMatch} - - ); - case 'I': - return {textMatch}; - case 'C': - return {textMatch}; - case 'M': - return renderModuleLink(textMatch); - - case 'B': - return {textMatch}; - - default: - return fullMatch; - } - }); - - return nstring; - } - private ensureListofStrings(v) { if (typeof v === 'string') { return [v]; diff --git a/src/containers/collection-detail/collection-docs.tsx b/src/containers/collection-detail/collection-docs.tsx index dc4a47b6c2..7a9844c3e5 100644 --- a/src/containers/collection-detail/collection-docs.tsx +++ b/src/containers/collection-detail/collection-docs.tsx @@ -171,9 +171,11 @@ class CollectionDocs extends React.Component { // if plugin data is set render it - this.renderModuleLink( - moduleName, + renderPluginLink={(pluginName, pluginType, text) => + this.renderPluginLink( + pluginName, + pluginType, + text ?? pluginName, collection, params, content.contents, @@ -243,9 +245,16 @@ class CollectionDocs extends React.Component { } } - private renderModuleLink(moduleName, collection, params, allContent) { + private renderPluginLink( + pluginName, + pluginType, + text, + collection, + params, + allContent, + ) { const module = allContent.find( - (x) => x.content_type === 'module' && x.name === moduleName, + (x) => x.content_type === pluginType && x.name === pluginName, ); if (module) { @@ -256,18 +265,18 @@ class CollectionDocs extends React.Component { { namespace: collection.collection_version.namespace, collection: collection.collection_version.name, - type: 'module', - name: moduleName, + type: pluginType, + name: pluginName, repo: this.props.routeParams.repo, }, params, )} > - {moduleName} + {text} ); } else { - return moduleName; + return text; } }