From dfbb55b66060692cd1508ee537283e39866e281e Mon Sep 17 00:00:00 2001 From: ShashiSubramanya Date: Thu, 15 Oct 2026 16:39:25 +0530 Subject: [PATCH 1/2] SDK reference fixes for SCAL-251659, SCAL-297673, and SCAL-337086 --- .gitignore | 3 + modules/ROOT/pages/common/nav-embedding.adoc | 17 - .../pages/common/nav-in-product-help.adoc | 18 +- .../ROOT/pages/common/nav-sdk-reference.adoc | 10 + modules/ROOT/pages/embed-action-ref.adoc | 15 +- scripts/Converter/index.ts | 1118 +++++++++++++++-- scripts/Converter/sdk-reference-config.json | 190 +++ src/assets/styles/index.scss | 114 +- src/components/DevDocTemplate/index.tsx | 9 +- src/components/Document/index.tsx | 8 + src/components/EmbedTypeFilter/index.scss | 164 +++ src/components/EmbedTypeFilter/index.tsx | 203 +++ src/components/LeftSidebar/helper.tsx | 14 +- src/components/LeftSidebar/index.tsx | 7 + src/components/SecondaryHeader/index.tsx | 21 +- 15 files changed, 1739 insertions(+), 172 deletions(-) create mode 100644 modules/ROOT/pages/common/nav-sdk-reference.adoc create mode 100644 scripts/Converter/sdk-reference-config.json create mode 100644 src/components/EmbedTypeFilter/index.scss create mode 100644 src/components/EmbedTypeFilter/index.tsx diff --git a/.gitignore b/.gitignore index 366f88c39..26bc5a08c 100644 --- a/.gitignore +++ b/.gitignore @@ -126,3 +126,6 @@ modules/**/generated/ !.idea/dictionaries/*.xml .DS_store *.swp + +# Local report for the docs writers, written by scripts/Converter/index.ts +scripts/Converter/reports/ diff --git a/modules/ROOT/pages/common/nav-embedding.adoc b/modules/ROOT/pages/common/nav-embedding.adoc index e9ea19007..8b6bac6c7 100644 --- a/modules/ROOT/pages/common/nav-embedding.adoc +++ b/modules/ROOT/pages/common/nav-embedding.adoc @@ -165,23 +165,6 @@ Customize and integrate Reference guides and changelog * link:{{navprefix}}/VisualEmbedSdk[Visual Embed SDK Reference] -include::generated/typedoc/CustomSideNav.adoc[] -** Custom styles -*** [.typedoc-Interface]#link:{{navprefix}}/Interface_CustomStyles[CustomStyles]# -*** [.typedoc-Interface]#link:{{navprefix}}/Interface_CustomisationsInterface[CustomisationsInterface]# -*** [.typedoc-Interface]#link:{{navprefix}}/Interface_customCssInterface[customCssInterface]# -*** [.typedoc-Interface]#link:{{navprefix}}/Interface_CustomCssVariables[customCssVariables]# -** Runtime filters -*** [.typedoc-Interface]#link:{{navprefix}}/Interface_RuntimeFilter[RuntimeFilter]# -*** [.typedoc-Enumeration]#link:{{navprefix}}/Enumeration_RuntimeFilterOp[RuntimeFilterOp]# -** Others -*** [.typedoc-Enumeration]#link:{{navprefix}}/Enumeration_Action[Action]# -*** [.typedoc-Enumeration]#link:{{navprefix}}/Enumeration_ContextMenuTriggerOptions[ContextMenuTriggerOptions]# -*** [.typedoc-Enumeration]#link:{{navprefix}}/Enumeration_DataSourceVisualMode[DataSourceVisualMode]# -*** [.typedoc-Enumeration]#link:{{navprefix}}/Enumeration_Page[Page]# -*** [.typedoc-Enumeration]#link:{{navprefix}}/Enumeration_PrefetchFeatures[PrefetchFeatures]# -*** [.typedoc-Function]#link:{{navprefix}}/Function_executeTML[executeTML]# -*** [.typedoc-Function]#link:{{navprefix}}/Function_exportTML[exportTML]# * link:{{navprefix}}/embed-sdk-changelog[Changelog] ** link:{{navprefix}}/embed-sdk-changelog[Visual Embed SDK] ** link:{{navprefix}}/mobile-sdk-changelog[Mobile Embed SDK] diff --git a/modules/ROOT/pages/common/nav-in-product-help.adoc b/modules/ROOT/pages/common/nav-in-product-help.adoc index 86cc82b95..be7f7993a 100644 --- a/modules/ROOT/pages/common/nav-in-product-help.adoc +++ b/modules/ROOT/pages/common/nav-in-product-help.adoc @@ -149,23 +149,7 @@ Customize and integrate Visual Embed SDK reference guide * link:{{navprefix}}/VisualEmbedSdk[Visual Embed SDK Reference] -include::generated/typedoc/CustomSideNav.adoc[] -** Custom styles -*** [.typedoc-Interface]#link:{{navprefix}}/Interface_CustomStyles[CustomStyles]# -*** [.typedoc-Interface]#link:{{navprefix}}/Interface_CustomisationsInterface[CustomisationsInterface]# -*** [.typedoc-Interface]#link:{{navprefix}}/Interface_customCssInterface[customCssInterface]# -*** [.typedoc-Interface]#link:{{navprefix}}/Interface_CustomCssVariables[customCssVariables]# -** Runtime filters -*** [.typedoc-Interface]#link:{{navprefix}}/Interface_RuntimeFilter[RuntimeFilter]# -*** [.typedoc-Enumeration]#link:{{navprefix}}/Enumeration_RuntimeFilterOp[RuntimeFilterOp]# -** Others -*** [.typedoc-Enumeration]#link:{{navprefix}}/Enumeration_Action[Action]# -*** [.typedoc-Enumeration]#link:{{navprefix}}/Enumeration_ContextMenuTriggerOptions[ContextMenuTriggerOptions]# -*** [.typedoc-Enumeration]#link:{{navprefix}}/Enumeration_DataSourceVisualMode[DataSourceVisualMode]# -*** [.typedoc-Enumeration]#link:{{navprefix}}/Enumeration_Page[Page]# -*** [.typedoc-Enumeration]#link:{{navprefix}}/Enumeration_PrefetchFeatures[PrefetchFeatures]# -*** [.typedoc-Function]#link:{{navprefix}}/Function_executeTML[executeTML]# -*** [.typedoc-Function]#link:{{navprefix}}/Function_exportTML[exportTML]# +include::generated/typedoc/SdkReferenceNav.adoc[] [.sidebar-title] diff --git a/modules/ROOT/pages/common/nav-sdk-reference.adoc b/modules/ROOT/pages/common/nav-sdk-reference.adoc new file mode 100644 index 000000000..fb456b000 --- /dev/null +++ b/modules/ROOT/pages/common/nav-sdk-reference.adoc @@ -0,0 +1,10 @@ +:page-pageid: nav-sdk-reference +:page-description: Visual Embed SDK reference navigation + +[navSection] + +[.sidebar-title] +SDK Reference Guide + +* link:{{navprefix}}/VisualEmbedSdk[Visual Embed SDK] +include::generated/typedoc/SdkReferenceNav.adoc[] diff --git a/modules/ROOT/pages/embed-action-ref.adoc b/modules/ROOT/pages/embed-action-ref.adoc index 64335416c..0abf2461b 100644 --- a/modules/ROOT/pages/embed-action-ref.adoc +++ b/modules/ROOT/pages/embed-action-ref.adoc @@ -8,7 +8,7 @@ ThoughtSpot application components such as Liveboards, Answers, visualizations, Search and Spotter interface, and application pages support several actions and menu commands for user-initiated operations. To allow developers to customize the menu options and feature availability for their embedded application users, Visual Embed SDK provides action IDs for the actions in the ThoughtSpot application interface. Developers can use these action IDs in the `visibleActions`, `disabledActions`, or `hiddenActions` arrays to show, disable, or hide specific actions in the embedded view. -The availability of these actions may vary based on user privileges and the SDK library used for embedding. For information about the action IDs available for embed components, browse through the lists in the following sections. +The availability of these actions may vary based on user privileges and the SDK library used for embedding. For information about the action IDs available for embed components, browse through the lists in the following sections. To see only the actions that apply to a specific embed type, such as `LiveboardEmbed` or `SpotterEmbed`, use the filter on the xref:Action.adoc[Action] page. [#liveboardv2-action] == Liveboard @@ -100,14 +100,6 @@ Controls the visibility of the Liveboard style panel. `AppEmbed` |The Liveboard verified banner text. + Indicates the Liveboard is verified. + See also, xref:LiveboardViewConfig.adoc#_showliveboardverifiedbadge[showLiveboardVerifiedBadge]. -|xref:Action.adoc#_personalisedviewsdropdown[`Action.PersonalisedViewsDropdown`]| `LiveboardEmbed` + -`AppEmbed` | The Liveboard personalized views drop-down. + -Available if personalized views are saved on the Liveboard. + -Allows switching between the saved personalized views of a Liveboard. + -[NOTE] -==== -This action is deprecated. Use `Action.PersonalizedViewsDropdown` instead. -==== |xref:Action.adoc#_personalizedviewsdropdown[`Action.PersonalizedViewsDropdown`]| `LiveboardEmbed` + `AppEmbed` | The Liveboard personalized views drop-down. + Allows switching between the saved personalized views of a Liveboard. @@ -632,11 +624,6 @@ a|xref:Action.adoc#_addtowatchlist[`Action.AddToWatchlist`] a| `AppEmbed` | The Adds a KPI chart to the watchlist on the Home page. a|xref:Action.adoc#_removefromwatchlist[`Action.RemoveFromWatchlist`] a| `AppEmbed` | The *Remove from watchlist* menu action on KPI watchlist. + Removes a KPI chart from the watchlist on the Home page. -a|xref:Action.adoc#_organisefavourites[`Action.OrganiseFavourites`] a| `AppEmbed` | The *Organize Favourites* action on Homepage Favorites module. + -[NOTE] -==== -This action is deprecated. Use `Action.OrganizeFavorites` instead. -==== a|xref:Action.adoc#_organizefavorites[`Action.OrganizeFavorites`] a| `AppEmbed` | The *Organize Favorites* action on Homepage Favorites module. |xref:Action.adoc#_copylink[`Action.CopyLink`] a|`AppEmbed`|*Copy link* + Allows users to copy a link from the *Watchlist* on the Homepage. diff --git a/scripts/Converter/index.ts b/scripts/Converter/index.ts index 937460d01..98d40c9ca 100644 --- a/scripts/Converter/index.ts +++ b/scripts/Converter/index.ts @@ -2,6 +2,44 @@ const path = require('path'); const fs = require('fs'); const nodeFetch = require('node-fetch'); +interface SdkReferenceConfig { + groupOrder: string[]; + fallbackGroup: string; + // Leave the deprecated top-level items out of the docs. + excludeDeprecated: boolean; + // Corrections to the wording of an SDK comment, until the comment itself is fixed. + commentFixes: { node: string; reason: string; find: string; replace: string }[]; + // Enumerations whose deprecated members are left out as well (e.g. Action). + excludeDeprecatedMembersOf: string[]; + kindFallbackGroups: Record; + groupOverrides: Record; + groupPatterns: { group: string; pattern: string }[]; + descriptions: Record; + landingPage: { + description: string; + intro: string[]; + groupDescriptions: Record; + typeDescriptions: Record; + }; + // Groups listed as a page, under a parent group, instead of in the menu. + childGroups: Record; + actionEmbedTypes: { + sourcePage: string; + // Embed types that a writer has confirmed for Actions not in the table yet. + confirmed: Record; + }; +} + +// Internal report for the docs writers. It is outside the folders that Gatsby reads +// (src/page and modules/), so it can never be part of the site. +// It is local only (git ignored) and written only for this branch of the SDK. +const ACTION_REPORT_PATH = 'scripts/Converter/reports/action-embed-types.md'; +const ACTION_REPORT_BRANCH = 'main'; + +const sdkReferenceConfig: SdkReferenceConfig = JSON.parse( + fs.readFileSync(path.join(__dirname, 'sdk-reference-config.json'), 'utf8'), +); + enum TypeDocReflectionKind { Project = 'Project', Module = 'Module', @@ -161,23 +199,128 @@ class TypeDocInternalParser { static GITHUB_LINK = 'https://github.com/thoughtspot/visual-embed-sdk/blob/main/src'; - static covertTypeDocText = (text: string) => { + // In AsciiDoc a list must be preceded by a blank line, otherwise its first item + // runs on into the paragraph above it. SDK comments are written as Markdown, + // where that is not needed, so add the blank line. Code blocks are left alone. + static separateListsFromParagraphs = (text: string) => { + const isBullet = (line: string) => /^\s*[-*]\s+\S/.test(line); + let inCodeBlock = false; + const lines = text.split('\n'); + const result: string[] = []; + lines.forEach((line, index) => { + if (line.trimStart().startsWith('```')) inCodeBlock = !inCodeBlock; + const previous = lines[index - 1]; + if ( + !inCodeBlock && + index > 0 && + isBullet(line) && + previous.trim() && + !isBullet(previous) && + !previous.trimStart().startsWith('```') + ) { + result.push(''); + } + result.push(line); + }); + return result.join('\n'); + }; + + // Resolves the target of a {@link Target} to an AsciiDoc link, relative to the page + // being written. Set by TypedocConverter. + static resolveLinkTarget: ( + target: string, + label?: string, + ) => string | undefined; + + // `bareLinks` leaves out the typedoc style around a link. It is needed inside + // the #...# marks of the deprecated and version tags, which it would end early. + static covertTypeDocText = (text: string, bareLinks = false) => { // 1) Convert Markdown links -> AsciiDoc links - const updated = text.replace( + const updated = TypeDocInternalParser.separateListsFromParagraphs( + text, + ).replace( /\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, 'link:$2[$1]', ); - // 2) Existing logic: convert {@link Name.hash} -> xref:... - const matches = updated.match(/{@link\s[^{]+}/g); - if (!matches) return updated; + // 2) {@link Target} and {@link Target text} (also {@linkcode}, {@linkplain}). + // A target that is not documented, such as a type the SDK does not export, is + // shown as plain code, never as the braces of the tag. + return updated.replace( + /\{@link(?:code|plain)?\s+([^\s}|]+)(?:[\s|]+([^}]*))?\}/g, + (_match, target: string, label?: string) => { + const shown = label?.trim(); + if (/^https?:\/\//.test(target)) { + return `link:${target}[${shown || target}]`; + } + const link = TypeDocInternalParser.resolveLinkTarget(target, shown); + if (!link) return `\`${shown || target}\``; + return bareLinks ? link.replace(/^\[[^\]]*\]#(.*)#$/, '$1') : link; + }, + ); + }; - return matches.reduce((prevUpdatedText, curLinkText) => { - const linkTo = curLinkText.split(/\s/)[1].replace(/}/g, ''); - const newLinkText = this.convertNameToLink(linkTo, true); - if (!newLinkText) return prevUpdatedText; - return prevUpdatedText.replace(curLinkText, newLinkText); - }, updated); + // Plain text of a comment, for the description in the page metadata and search + // results, where no markup is shown. + static plainText = (text: string | undefined) => + (text || '') + .replace( + /\{@link(?:code|plain)?\s+([^\s}|]+)(?:[\s|]+([^}]*))?\}/g, + (_match, target: string, label?: string) => + (label || target).trim(), + ) + .replace(/\s+/g, ' ') + .trim(); + + // An @example is mostly code, which must stay as it is, but the SDK also writes prose + // in it, after the code. Convert the prose (its {@link}s and lists), not the code. + static convertOutsideCodeBlocks = (text: string) => { + const parts: { code: boolean; lines: string[] }[] = []; + let inCode = false; + text.split('\n').forEach((line) => { + const isFence = line.trimStart().startsWith('```'); + const code = inCode || isFence; + if (isFence) inCode = !inCode; + const last = parts[parts.length - 1]; + if (last && last.code === code) last.lines.push(line); + else parts.push({ code, lines: [line] }); + }); + return parts + .map(({ code, lines }) => + code + ? lines.join('\n') + : TypeDocInternalParser.covertTypeDocText(lines.join('\n')), + ) + .join('\n'); + }; + + // Repairs the tags of a comment that the SDK source wrote wrongly: a closing code + // fence repeated after an example, and a @version line written after the example so + // that it became part of the example text. + static repairTags = (tags: TypeDocCommentTag[]): TypeDocCommentTag[] => { + const repaired: TypeDocCommentTag[] = []; + const extra: TypeDocCommentTag[] = []; + tags.forEach((tag) => { + if (tag.tag !== 'example') { + repaired.push(tag); + return; + } + let { text } = tag; + const version = text.match(/^[ \t]*@version[ \t]+(.+)$/m); + if (version) { + text = text.replace(version[0], ''); + if (!tags.some((other) => other.tag === 'version')) { + extra.push({ tag: 'version', text: `${version[1].trim()}\n` }); + } + } + text = text.replace(/^(```)[ \t]*\n(?:[ \t]*\n)*```[ \t]*$/gm, '$1'); + const fences = text + .split('\n') + .filter((line) => line.trimStart().startsWith('```')).length; + if (fences % 2) text = `${text.trimEnd()}\n\`\`\``; + repaired.push({ ...tag, text }); + }); + return [...repaired, ...extra]; }; // function to parse a tag @@ -188,16 +331,18 @@ class TypeDocInternalParser { } if (tag.tag === 'group') return ''; if (tag.tag === 'version') - return `[version]#Version : ${tag.text.replace(/\n/g, '')}#\n`; - if (tag.tag === 'example') return `${tag.text}\n`; + return `[version]#*Version* : ${tag.text.replace(/\n/g, '')}#\n`; + if (tag.tag === 'example') { + return `${this.convertOutsideCodeBlocks(tag.text)}\n`; + } if (tag.tag === 'param') { return `\nParameter::\n${this.covertTypeDocText(tag.text)}\n`; } if (tag.tag === 'deprecated') { - return `[deprecated]#Deprecated : ${tag.text.replace( - /\n/g, - '', + return `[deprecated]#*Deprecated* : ${this.covertTypeDocText( + tag.text.replace(/\s*\n\s*/g, ' ').trim(), + true, )}#\n`; } if (tag.tag === 'tryItOut') { @@ -220,7 +365,36 @@ class TypeDocInternalParser { return content; } - static parseTags(tags: TypeDocCommentTag[] | undefined) { + // Where the Version and Deprecated blocks of an item go: + // - an item that is not deprecated has its Version block right under the signature, + // before the description; + // - a deprecated item has its description first, then the Deprecated block, then + // the Version block, and then the other tags (default value, examples). + // The two are never in one row. Use parseTags(tags, false) for the other tags. + private static metaBlocks(tags: TypeDocCommentTag[] | undefined) { + const repaired = this.repairTags(tags || []); + const render = (name: string) => + repaired + .filter((tag) => tag.tag === name) + .map((tag) => this.parseTag(tag).trim()) + .filter(Boolean); + return { + version: render('version'), + deprecated: render('deprecated'), + }; + } + + static metaBeforeDescription(tags: TypeDocCommentTag[] | undefined) { + const { version, deprecated } = this.metaBlocks(tags); + return deprecated.length ? '' : version.join('\n\n'); + } + + static metaAfterDescription(tags: TypeDocCommentTag[] | undefined) { + const { version, deprecated } = this.metaBlocks(tags); + return deprecated.length ? [...deprecated, ...version].join('\n\n') : ''; + } + + static parseTags(tags: TypeDocCommentTag[] | undefined, includeMeta = true) { // process tags let content = ''; @@ -231,16 +405,19 @@ class TypeDocInternalParser { }; if (tags) { - const versionTags = tags.filter((tag) => tag.tag === 'version'); - const deprecatedTags = tags.filter( + const repairedTags = this.repairTags(tags); + const versionTags = repairedTags.filter((tag) => tag.tag === 'version'); + const deprecatedTags = repairedTags.filter( (tag) => tag.tag === 'deprecated', ); - const restTags = tags.filter( + const restTags = repairedTags.filter( (tag) => tag.tag !== 'version' && tag.tag !== 'deprecated', ); - addTagsToContent(versionTags); - addTagsToContent(deprecatedTags); + if (includeMeta) { + addTagsToContent(versionTags); + addTagsToContent(deprecatedTags); + } addTagsToContent(restTags); } @@ -384,8 +561,33 @@ class TypeDocParser { private childrenNameMap: Record = {}; + // The top-level items by name, and the one whose page is being written. + private topLevelByName: Record = {}; + + private currentTopNode?: TypeDocLinkingNode; + private groupMap: Record = {}; + private actionEmbedTypes: Record = {}; + + // The link for the Target of a {@link Target}: Item, Item.member, or just a member + // name, which is looked for on the page being written first (so that a property + // that refers to another property of the same interface finds it). Nothing when the + // target is not documented. + public resolveLinkTarget = (target: string, label?: string) => { + const [name, member] = target.split(/[.#]/); + const node = member + ? this.topLevelByName[name]?.children?.find( + (child) => child.name === member, + ) + : this.currentTopNode?.children?.find((child) => child.name === name) || + this.topLevelByName[name]; + if (!node) return undefined; + const link = this.convertNodeToLink(node, Boolean(member)); + if (!link || link === node.name) return undefined; + return label ? link.replace(/\[[^\]]*\]#$/, `[${label}]#`) : link; + }; + public convertNameToLink = ( linkTo: string | undefined, includeParent = false, @@ -497,32 +699,89 @@ class TypeDocParser { ].join('\n'); }; - private createTypeDocTable = ( - data: string[], - noOfColumns: number, - ): string => { - let content = `[cols="${'1,'.repeat(noOfColumns - 1)}1"]\n|===\n`; - data.forEach((str) => { - content += `| ${str}\n`; - }); + // An index of links as a list. The page styles lay it out in columns, so + // long names wrap instead of overflowing the fixed-width cells of a table. + // A long camelCase name gets a line break opportunity () at each word + // boundary, so it wraps between words and never leaves the icon on a line of + // its own. adds no text, so copying and searching are not affected. + private createTypeDocIndex = (items: string[]): string => { + if (!items.length) return ''; + const breakable = (item: string) => + item.replace(/\[([^\]]+)\]#$/, (_match, name: string) => { + const withBreaks = name + .replace(/([a-z0-9])(?=[A-Z])/g, '$1++++++') + .replace(/([A-Z])(?=[A-Z][a-z])/g, '$1++++++'); + return `[${withBreaks}]#`; + }); + return `[.typedoc-index]\n${items + .map((item) => `* ${breakable(item)}`) + .join('\n')}\n`; + }; - for (let i = 0; i < noOfColumns - 1; i++) { - content += '| \n'; - } - content += '|===\n'; - return content; + private addToGroup = (groupName: string, node: TypeDocLinkingNode) => { + if (!this.groupMap[groupName]) this.groupMap[groupName] = []; + this.groupMap[groupName].push(node); }; - private generateMap = (node: TypeDocLinkingNode) => { - const groupTag = + private getGroupTag = (node: TypeDocLinkingNode) => + ( node.comment?.tags?.filter((e) => e.tag === 'group')[0] || node?.signatures?.[0].comment?.tags?.filter( (e) => e.tag === 'group', - )[0]; - if (groupTag) { - const groupName = groupTag.text.trim(); - if (!this.groupMap[groupName]) this.groupMap[groupName] = []; - this.groupMap[groupName].push(node); + )[0] + )?.text.trim(); + + // Sidebar group for a top-level node. Precedence: the docs-side override, + // then the SDK's @group tag, then a name pattern for ungrouped nodes, then + // the fallback group, so every generated page is reachable from the nav. + private resolveTopLevelGroup = (node: TypeDocLinkingNode): string => { + const { + groupOverrides, + groupPatterns, + fallbackGroup, + kindFallbackGroups, + } = sdkReferenceConfig; + const override = Object.keys(groupOverrides).find((group) => + groupOverrides[group].includes(node.name), + ); + if (override) return override; + + const tagGroup = this.getGroupTag(node); + if (tagGroup) return tagGroup; + + const isTypeShape = [ + TypeDocReflectionKind.Interface, + TypeDocReflectionKind.TypeAlias, + TypeDocReflectionKind.Enumeration, + ].includes(node.kindString); + const patternGroup = isTypeShape + ? groupPatterns.find(({ pattern }) => + new RegExp(pattern).test(node.name), + )?.group + : undefined; + return ( + patternGroup || kindFallbackGroups[node.kindString] || fallbackGroup + ); + }; + + // The SDK comment if it has one, else the docs-side description (if any). + private getDescription = (node: TypeDocNode): string | undefined => { + const hasSdkComment = + node.comment?.shortText?.trim() || node.comment?.text?.trim(); + if (hasSdkComment) return undefined; + return sdkReferenceConfig.descriptions[node.name]; + }; + + private generateMap = (node: TypeDocLinkingNode) => { + const isTopLevel = + this.childrenIdMap[node.parentId as number]?.kindString === + TypeDocReflectionKind.Project; + if (isTopLevel) { + this.topLevelByName[node.name] = node; + this.addToGroup(this.resolveTopLevelGroup(node), node); + } else { + const groupName = this.getGroupTag(node); + if (groupName) this.addToGroup(groupName, node); } if (!this.childrenIdMap[node.id]) this.childrenIdMap[node.id] = node; if (!this.childrenNameMap[node.name]) @@ -597,6 +856,14 @@ class TypeDocParser { return ''; }; + private parseNodeComment = (node: TypeDocNode): string => { + const description = this.getDescription(node); + if (description) { + return `${TypeDocInternalParser.covertTypeDocText(description)}\n\n`; + } + return TypeDocInternalParser.parseComment(node.comment); + }; + // handles the Main page nodes (Enum, Class, Interface, Type Alias) private handleMainNode = (node: TypeDocNode) => { const pageTitle = `= ${node.name}`; @@ -605,12 +872,11 @@ class TypeDocParser { let enumIndexContent = '\n\n[div boxDiv boxFullWidth]\n--\n'; // Special handling if (['Enumeration', 'Class', 'Interface'].includes(node.kindString)) { - enumIndexContent += `${this.createTypeDocTable( + enumIndexContent += this.createTypeDocIndex( node.children?.map((childNode) => this.convertNodeToLink(childNode), ) || [], - 3, - )}`; + ); } enumIndexContent += '\n--\n\n'; @@ -629,33 +895,418 @@ class TypeDocParser { return [ pageTitle, - TypeDocInternalParser.parseComment(node.comment), - TypeDocInternalParser.parseTags(node.comment?.tags), + TypeDocInternalParser.metaBeforeDescription(node.comment?.tags), + this.parseNodeComment(node), + TypeDocInternalParser.metaAfterDescription(node.comment?.tags), + TypeDocInternalParser.parseTags(node.comment?.tags, false), '== Index', + node.name === 'Action' ? this.buildEmbedTypeFilter() : '', enumIndexContent, mainPageContent, groupContent, ].join('\n\n'); }; + // The SDK does not record which embed types support each Action, so the + // mapping is read from the "Required SDK library" column of the hand + // written Action IDs page. Rows look like: + // |xref:Action.adoc#_share[`Action.Share`]| `LiveboardEmbed` + `AppEmbed` | label + // A leading `a` (a|xref:...) marks an AsciiDoc-style cell and is also a row start. + // Items that are left out of the docs: anything deprecated, and anything the + // SDK marks @hidden or @internal (the typedoc normally drops those already). + private isExcludedFromDocs = (node: TypeDocNode, includeDeprecated = true) => + [ + ...(node.comment?.tags || []), + ...((node as FunctionNode).signatures?.[0]?.comment?.tags || []), + ].some( + (tag) => + ['hidden', 'internal'].includes(tag.tag) || + (includeDeprecated && tag.tag === 'deprecated'), + ); + + // Removes the excluded top-level items (see excludeDeprecated in the config), and + // the deprecated members of the enumerations in excludeDeprecatedMembersOf, + // before anything is built. They get no page, no nav entry or index entry, and + // a reference to one renders as plain text, not as a broken link. + private excludeDeprecatedNodes = (project: TypeDocNode) => { + const removeFrom = ( + parent: TypeDocNode, + excluded: TypeDocNode[], + label: string, + ) => { + if (!excluded.length) return; + const excludedIds = new Set(excluded.map((node) => node.id)); + // eslint-disable-next-line no-param-reassign + parent.children = parent.children?.filter( + (node) => !excludedIds.has(node.id), + ); + parent.groups?.forEach((group) => { + // eslint-disable-next-line no-param-reassign + group.children = group.children.filter( + (id) => !excludedIds.has(id), + ); + }); + // eslint-disable-next-line no-param-reassign + parent.groups = parent.groups?.filter((group) => group.children.length); + console.info( + `Excluded ${excluded.length} ${label} : ${excluded + .map((node) => node.name) + .join(', ')}`, + ); + }; + + removeFrom( + project, + (project.children || []).filter((node) => + this.isExcludedFromDocs(node, sdkReferenceConfig.excludeDeprecated), + ), + 'deprecated or hidden items', + ); + sdkReferenceConfig.excludeDeprecatedMembersOf.forEach((name) => { + const parent = (project.children || []).find( + (node) => node.name === name, + ); + if (!parent) return; + removeFrom( + parent, + (parent.children || []).filter((member) => + this.isExcludedFromDocs(member), + ), + `deprecated or hidden members of ${name}`, + ); + }); + }; + + // Applies the corrections in commentFixes to the comments of top-level items. The + // match ignores how the text is wrapped. A fix that no longer matches (the SDK + // fixed its comment) is reported, so that it can be removed. + private applyCommentFixes = (project: TypeDocNode) => { + sdkReferenceConfig.commentFixes.forEach(({ node, find, replace }) => { + const target = (project.children || []).find( + (child) => child.name === node, + ); + const pattern = new RegExp( + find + .trim() + .split(/\s+/) + .map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('\\s+'), + ); + let applied = false; + (['shortText', 'text'] as const).forEach((field) => { + const current = target?.comment?.[field]; + if (target?.comment && current && pattern.test(current)) { + target.comment[field] = current.replace(pattern, () => replace); + applied = true; + } + }); + if (!applied) { + console.warn( + `Comment fix for ${node} no longer matches the SDK comment, so it can be removed from commentFixes in sdk-reference-config.json`, + ); + } + }); + }; + + // Warns about top-level items that this converter has no page for, which + // would otherwise be missing from the docs without any sign of it. + private warnAboutUnsupportedNodes = (project: TypeDocNode) => { + const supported: TypeDocReflectionKind[] = [ + TypeDocReflectionKind.Enumeration, + TypeDocReflectionKind.Class, + TypeDocReflectionKind.Interface, + TypeDocReflectionKind.TypeAlias, + TypeDocReflectionKind.Function, + ]; + const unsupported = (project.children || []).filter( + (node) => !supported.includes(node.kindString), + ); + if (unsupported.length) { + console.warn( + `Not documented, no page for this kind of item : ${unsupported + .map((node) => `${node.name} (${node.kindString})`) + .join(', ')}`, + ); + } + console.info( + `Documented ${(project.children || []).length - unsupported.length} items, ${ + unsupported.length + } not supported`, + ); + }; + + private loadActionEmbedTypes = () => { + const { sourcePage } = sdkReferenceConfig.actionEmbedTypes; + this.actionEmbedTypes = {}; + if (!fs.existsSync(sourcePage)) { + console.warn(`Action embed types skipped, not found : ${sourcePage}`); + return; + } + const rows = fs + .readFileSync(sourcePage, 'utf8') + .split(/\n(?=a?\|xref:Action\.adoc)/) + .slice(1); + rows.forEach((row) => { + const nameMatch = row.match( + /^a?\|xref:Action\.adoc#_[a-z0-9]+\[`Action\.([A-Za-z0-9]+)`\]/, + ); + if (!nameMatch) return; + const libraryCell = row + .slice(nameMatch[0].length) + .split(/\n?\|/)[1]; + const embedTypes = (libraryCell?.match(/`[A-Za-z]+Embed`/g) || []).map( + (embed) => embed.replace(/`/g, ''), + ); + const known = this.actionEmbedTypes[nameMatch[1]] || []; + this.actionEmbedTypes[nameMatch[1]] = [ + ...known, + ...embedTypes.filter((embed) => !known.includes(embed)), + ]; + }); + console.info( + `Embed types found for ${ + Object.keys(this.actionEmbedTypes).length + } actions`, + ); + + this.addConfirmedEmbedTypes(sourcePage); + + // A row for an Action that is not documented (excluded or removed) links to an + // anchor that no longer exists on the Action page. + const documented = new Set( + (this.childrenNameMap.Action?.children || []).map((member) => member.name), + ); + const staleRows = Object.keys(this.actionEmbedTypes).filter( + (name) => !documented.has(name), + ); + if (staleRows.length) { + console.warn( + `Action page : ${staleRows.length} rows in ${sourcePage} are for Actions that are not documented (excluded or removed), so their links are dead : ${staleRows.join( + ', ', + )}`, + ); + } + + // An Action ID that is not in the table has no "Supported in" line. It is + // still on the Action page and in the Index, so it shows when no embed type + // is selected ("All") and under "Other" in the filter. The writers get the list, + // with a suggestion to check, in the report (see buildActionReport). + this.uncategorizedActions = ( + this.childrenNameMap.Action?.children || [] + ).filter((member) => !this.actionEmbedTypes[member.name]?.length); + if (this.uncategorizedActions.length) { + console.warn( + `Action page : ${this.uncategorizedActions.length} Action IDs have no embed type yet. They show under "All" and "Other" on the Action page. The list, with suggestions for a writer to check, is in ${ACTION_REPORT_PATH}`, + ); + } + }; + + // The Actions with no embed type yet, in the order of the Action page. + private uncategorizedActions: TypeDocNode[] = []; + + // An internal report for the writers: which Actions have no embed type yet, and a + // suggestion for each to check. It is written outside the folders that the site + // is built from, and nothing in it is used by the pages or the filter. + public buildActionReport = (): string => { + const { sourcePage } = sdkReferenceConfig.actionEmbedTypes; + const sdkVersion = (member: TypeDocNode) => + member.comment?.tags + ?.find((tag) => tag.tag === 'version') + ?.text.match(/SDK:\s*([0-9.]+)/)?.[1] || ''; + const oneLine = (member: TypeDocNode) => + `${member.comment?.shortText || ''}` + .replace(/\*\*|`/g, '') + .replace(/\s+/g, ' ') + .replace(/\|/g, '\\|') + .trim(); + + const rows = this.uncategorizedActions.map((member) => ({ + member, + suggestion: this.suggestEmbedTypes(member), + })); + const withSuggestion = rows.filter((row) => row.suggestion); + const jsonEntry = (name: string, embedTypes: string[]) => + ` "${name}": [${embedTypes.map((embed) => `"${embed}"`).join(', ')}]`; + + const table = rows + .map(({ member, suggestion }) => + [ + `\`${member.name}\``, + sdkVersion(member) || '-', + suggestion ? suggestion.embedTypes.join(' + ') : 'no suggestion', + suggestion ? suggestion.reason : '-', + oneLine(member) || '(no description)', + ].join(' | '), + ) + .map((row) => `| ${row} |`); + + const confirmed = Object.keys(sdkReferenceConfig.actionEmbedTypes.confirmed || {}); + const staleRows = Object.keys(this.actionEmbedTypes).filter( + (name) => !(this.childrenNameMap.Action?.children || []).some((m) => m.name === name), + ); + + return [ + '# Actions with no embed type yet', + `INTERNAL. For the docs writers. Generated locally by \`npm run generate-typedoc\` (scripts/Converter/index.ts) from the ${ACTION_REPORT_BRANCH} branch of the SDK. Do not edit it by hand and do not commit it: it is git ignored. It is not part of the site, and nothing in it is shown to readers.`, + `${rows.length} Action IDs have no embed type. They show under "All" and under "Other" in the filter on the Action page, with no "Supported in" line. ${withSuggestion.length} have a suggestion below. A suggestion is inferred from the description only, so check it (the SDK pull request that added the Action can help) before you use it.`, + '## How to categorize an Action', + [ + `1. Add a row for it to \`${sourcePage}\`, in the section it belongs to, with its embed types in the "Required SDK library" column. This is the full reference entry.`, + '2. Or, to categorize it before that row is written, add its name and embed types under `actionEmbedTypes.confirmed` in `scripts/Converter/sdk-reference-config.json`. Remove the entry once the row is on the page; the row wins if both exist.', + 'On the next `npm run generate-typedoc` the Action gets its "Supported in" line and moves out of "Other" into its embed types.', + ].join('\n'), + '## Actions to categorize', + [ + '| Action | Since SDK | Suggested embed types | Why | Description |', + '| --- | --- | --- | --- | --- |', + ...table, + ].join('\n'), + '## Suggestions as config entries', + withSuggestion.length + ? `NOT confirmed. Check each one, delete the ones that are wrong, then paste the rest into \`actionEmbedTypes.confirmed\`:\n\n\`\`\`json\n${withSuggestion + .map(({ member, suggestion }) => + jsonEntry(member.name, suggestion?.embedTypes || []), + ) + .join(',\n')}\n\`\`\`` + : 'None.', + '## To tidy up', + [ + `- Entries under \`actionEmbedTypes.confirmed\` in the config: ${ + confirmed.length ? confirmed.join(', ') : 'none' + }.`, + `- Rows in \`${sourcePage}\` for Actions that are not documented (excluded or removed), whose links are dead: ${ + staleRows.length ? staleRows.join(', ') : 'none' + }.`, + ].join('\n'), + ].join('\n\n') + '\n'; + }; + + // Embed types that a writer confirmed in the config, for Actions that are not in the + // table yet. The table wins when an Action is in both, and a mistake in an entry + // (a name that does not exist) is reported, not silently ignored. + private addConfirmedEmbedTypes = (sourcePage: string) => { + const confirmed = sdkReferenceConfig.actionEmbedTypes.confirmed || {}; + const documented = new Set( + (this.childrenNameMap.Action?.children || []).map((member) => member.name), + ); + Object.entries(confirmed).forEach(([name, embedTypes]) => { + const unknownEmbeds = embedTypes.filter( + (embed) => + this.childrenNameMap[embed]?.kindString !== + TypeDocReflectionKind.Class, + ); + if (!documented.has(name)) { + console.warn( + `Action page : confirmed embed types for ${name} are ignored, there is no such documented Action`, + ); + } else if (unknownEmbeds.length) { + console.warn( + `Action page : confirmed embed types for ${name} are ignored, not embed classes : ${unknownEmbeds.join( + ', ', + )}`, + ); + } else if (this.actionEmbedTypes[name]?.length) { + console.warn( + `Action page : ${name} is in ${sourcePage} as well, so the confirmed entry in the config is not needed and can be removed`, + ); + } else { + this.actionEmbedTypes[name] = this.sortEmbedTypes(embedTypes); + } + }); + }; + + // A cautious guess at the embed types of an Action from its description. It only + // answers when the description is unambiguous, and returns nothing otherwise. It + // is a hint for the writers: it is never put on a page or used by the filter. + private suggestEmbedTypes = ( + member: TypeDocNode, + ): { embedTypes: string[]; reason: string } | undefined => { + const description = `${member.comment?.shortText || ''} ${ + member.comment?.text || '' + }`.replace(/\s+/g, ' '); + const lower = description.toLowerCase(); + + const named = [ + ...new Set( + description.match( + /\b(?:App|Liveboard|Search|SearchBar|Spotter|SpotterAgent)Embed\b/g, + ), + ), + ]; + if (named.length) { + return { + embedTypes: this.sortEmbedTypes(named), + reason: 'the description names the embed type', + }; + } + + // Pages that only the full application has + if ( + /homepage|home page|liveboard schedules|liveboards page|schedules page/.test( + lower, + ) + ) { + return { + embedTypes: ['AppEmbed'], + reason: 'the description is about a page of the full application', + }; + } + const mentions = (word: string) => new RegExp(`\\b${word}`).test(lower); + const others = (...words: string[]) => words.some(mentions); + if (mentions('liveboard') && !others('answer', 'search', 'spotter')) { + return { + embedTypes: ['AppEmbed', 'LiveboardEmbed'], + reason: 'the description mentions only Liveboard', + }; + } + if (mentions('spotter') && !others('liveboard', 'answer', 'search')) { + return { + embedTypes: ['AppEmbed', 'SpotterEmbed'], + reason: 'the description mentions only Spotter', + }; + } + return undefined; + }; + + private embedTypeLink = (embedType: string) => { + const embedNode = this.childrenNameMap[embedType]; + return embedNode ? this.convertNodeToLink(embedNode) : embedType; + }; + private handleEnumMember = (enumMember: EnumerationMemberNode) => { // debugger; const sourceContent = TypeDocInternalParser.parseSources( enumMember.sources, ); + const parentName = this.childrenIdMap[ + (this.childrenIdMap[enumMember.id] as TypeDocLinkingNode) + ?.parentId as number + ]?.name; + const embedTypes = + parentName === 'Action' + ? this.actionEmbedTypes[enumMember.name] + : undefined; + const supportedIn = embedTypes?.length + ? `[.supported-in]\n**Supported in:** ${embedTypes + .map(this.embedTypeLink) + .join(', ')}\n` + : ''; return [ `=== ${enumMember.name}`, '[div typeDocBlock boxFullWidth]\n--', `\`${enumMember.name}:= ${enumMember.defaultValue}\`\n`, + TypeDocInternalParser.metaBeforeDescription(enumMember.comment?.tags), TypeDocInternalParser.parseComment(enumMember.comment), + TypeDocInternalParser.metaAfterDescription(enumMember.comment?.tags), + supportedIn, sourceContent, - TypeDocInternalParser.parseTags(enumMember.comment?.tags), + TypeDocInternalParser.parseTags(enumMember.comment?.tags, false), '--', ].join('\n'); }; private handleFunctionNode = (node: FunctionNode) => { - const name = node.name !== 'constructor' ? `=== ${node.name}` : ''; + const name = `=== ${node.name}`; let overwrites = ''; if (node.overwrites) { @@ -677,13 +1328,15 @@ class TypeDocParser { return [ name, + TypeDocInternalParser.metaBeforeDescription(node.comment?.tags), TypeDocInternalParser.parseComment(node.comment), + TypeDocInternalParser.metaAfterDescription(node.comment?.tags), '[div typeDocBlock boxFullWidth]\n--', signatureContent, sources, overwrites, inheritedFrom, - TypeDocInternalParser.parseTags(node.comment?.tags), + TypeDocInternalParser.parseTags(node.comment?.tags, false), '--', ].join('\n\n'); }; @@ -694,9 +1347,11 @@ class TypeDocParser { `* ${node.name}: ${this.parseTypeDocType(node.type, true)}${ node?.defaultValue ? ` = ${node.defaultValue}` : '' }`, + TypeDocInternalParser.metaBeforeDescription(node.comment?.tags), TypeDocInternalParser.parseComment(node.comment), + TypeDocInternalParser.metaAfterDescription(node.comment?.tags), this.handleTypeNode(node.type), - TypeDocInternalParser.parseTags(node.comment?.tags), + TypeDocInternalParser.parseTags(node.comment?.tags, false), ].join('\n\n'); }; @@ -708,12 +1363,16 @@ class TypeDocParser { return [ `=== ${node.name}`, '[div typeDocBlock boxFullWidth]\n--', - sig, - // TODO : move this iwth aobve line - node.flags.isOptional ? '_Optional_' : '', + // Optional is on the same line as the signature + // A role, not _underscores_: a link target in the signature can contain an underscore + // (an enumeration member such as SPOTTER_2026_11), which AsciiDoc would pair + // with the closing underscore of _Optional_ and turn into broken italics. + node.flags.isOptional ? `${sig} [.typedoc-optional]#Optional#` : sig, + TypeDocInternalParser.metaBeforeDescription(node.comment?.tags), TypeDocInternalParser.parseComment(node.comment), + TypeDocInternalParser.metaAfterDescription(node.comment?.tags), this.handleTypeNode(node.type), - TypeDocInternalParser.parseTags(node.comment?.tags), + TypeDocInternalParser.parseTags(node.comment?.tags, false), '--\n', ].join('\n\n'); }; @@ -724,14 +1383,16 @@ class TypeDocParser { .join('\n\n'); return [ + TypeDocInternalParser.metaBeforeDescription(node.comment?.tags), TypeDocInternalParser.parseComment(node.comment), + TypeDocInternalParser.metaAfterDescription(node.comment?.tags), node.parameters?.length ? '**Function Parameters**' : '', parmContent, '**Returns**', TypeDocInternalParser.parseTypeDocType(node.type, true), // SCAL-182339 // this.handleTypeNode(node.type), - TypeDocInternalParser.parseTags(node.comment?.tags), + TypeDocInternalParser.parseTags(node.comment?.tags, false), ].join('\n\n'); }; @@ -783,8 +1444,14 @@ class TypeDocParser { private handleTypeNode = (node: TypeDocType | undefined) => { const content = [ + TypeDocInternalParser.metaBeforeDescription( + node?.declaration?.comment?.tags, + ), TypeDocInternalParser.parseComment(node?.declaration?.comment), - TypeDocInternalParser.parseTags(node?.declaration?.comment?.tags), + TypeDocInternalParser.metaAfterDescription( + node?.declaration?.comment?.tags, + ), + TypeDocInternalParser.parseTags(node?.declaration?.comment?.tags, false), this.handleTypeLiteralNode(node?.declaration), ].join('\n\n'); @@ -795,7 +1462,7 @@ class TypeDocParser { return [ `= ${node.name}`, `\`${node.name} : ${this.parseTypeDocType(node.type, true)}\``, - TypeDocInternalParser.parseComment(node.comment), + this.parseNodeComment(node), TypeDocInternalParser.parseSources(node.sources), `${this.handleTypeNode(node.type)}`, ].join('\n\n'); @@ -861,15 +1528,21 @@ class TypeDocParser { callBack: (pageId: string, content: string) => void, ) => { const projectNode = node; + this.excludeDeprecatedNodes(projectNode); + this.applyCommentFixes(projectNode); + this.warnAboutUnsupportedNodes(projectNode); this.generateMap(projectNode); - // creating an index page - let indexPageContent = '= Visual Embed SDK\n\n'; - /* const indexPageHeading = ''; - */ + this.loadActionEmbedTypes(); + // Pages that list every item of one kind (Enumeration, Class, ...), + // linked from the landing page. + const typePages: { title: string; pageId: string }[] = []; const indexPageHeading = this.getHeadingString({ title: indexPageId, pageId: indexPageId, - description: node?.comment?.shortText, + description: TypeDocInternalParser.plainText( + node?.comment?.shortText || + sdkReferenceConfig.landingPage.description, + ), }); let sideNavContent = `* link:{{navprefix}}/${encodePageId( @@ -877,13 +1550,21 @@ class TypeDocParser { )}[Visual Embed SDK Reference]\n`; projectNode?.groups?.forEach((group) => { - // create table group content - let groupContent = `== ${group.title}\n\n[div boxDiv boxFullWidth]\n--\n`; - groupContent += this.createTypeDocTable( + // create index group content + const typeDescription = + sdkReferenceConfig.landingPage.typeDescriptions[group.title]; + if (!typeDescription) { + console.warn( + `Type page : no description in sdk-reference-config.json for ${group.title}`, + ); + } + let groupContent = `== ${group.title}\n\n${ + typeDescription ? `${typeDescription}\n\n` : '' + }[div boxDiv boxFullWidth]\n--\n`; + groupContent += this.createTypeDocIndex( group.children.map((id) => this.convertNodeToLink(this.childrenIdMap[id]), ), - 3, ); groupContent += '--\n\n'; @@ -905,52 +1586,263 @@ class TypeDocParser { const heading = this.getHeadingString({ title: child.name, pageId, - description: child?.comment?.shortText, + description: TypeDocInternalParser.plainText( + child?.comment?.shortText || this.getDescription(child), + ), }); sideNavContent += `*** link:{{navprefix}}/${encodePageId( pageId, )}[${child.name}]\n`; + this.currentTopNode = child; const content = this.convertTypeDocNode(child); callBack(pageId, `${heading}\n\n${content}`); }); callBack(groupPageId, `${groupHeading}\n\n${groupContent}`); - - indexPageContent += groupContent; + typePages.push({ title: group.title, pageId: groupPageId }); }); callBack('VisualEmbedSdkNavLinks', sideNavContent); - callBack(indexPageId, `${indexPageHeading}\n\n${indexPageContent}`); + callBack( + indexPageId, + `${indexPageHeading}\n\n${this.buildLandingPage(typePages)}`, + ); - let customSideNavContent = ''; - Object.keys(this.groupMap).forEach((k) => { - customSideNavContent += `*** ${k}\n`; + Object.entries(sdkReferenceConfig.childGroups).forEach( + ([group, { pageId }]) => { + const content = this.buildChildGroupPage(group, pageId); + if (content) callBack(pageId, content); + }, + ); - this.groupMap[k].forEach((toLinkNode) => { - const linkToNode = `link:{{navprefix}}/${this.getPageId( - toLinkNode, - )}`; + // Included under a "Visual Embed SDK Reference" entry by both + // nav-sdk-reference.adoc and the merged in-product nav. + callBack('SdkReferenceNav', this.buildSdkReferenceNav(typePages)); + }; - customSideNavContent += `**** [.${this.getTypeCSSClass( - toLinkNode, - )}]#${linkToNode}[${toLinkNode.name}]#\n`; + // Landing page: an intro, then one section per sidebar category with its + // description and an index of its items. The text is kept in + // sdk-reference-config.json, not in the generated file, so it is still + // there after every regeneration. A category with no text still gets its + // heading and index, so a group added to the SDK shows up on its own. + private buildLandingPage = ( + typePages: { title: string; pageId: string }[], + ): string => { + const { intro, groupDescriptions } = sdkReferenceConfig.landingPage; + const groups = this.getOrderedGroupNames(); + + const withoutText = groups.filter((group) => !groupDescriptions[group]); + if (withoutText.length) { + console.warn( + `Landing page : no description in sdk-reference-config.json for ${withoutText.join( + ', ', + )}`, + ); + } + const unused = Object.keys(groupDescriptions).filter( + (group) => + !groups.includes(group) && + group !== sdkReferenceConfig.fallbackGroup, + ); + if (unused.length) { + console.warn( + `Landing page : description for a category that no longer exists : ${unused.join( + ', ', + )}`, + ); + } + + const browseByType = typePages + .map( + ({ title, pageId }) => + `link:{{navprefix}}/${encodePageId(pageId)}[${title}]`, + ) + .join(', '); + + const sections = groups.map((group) => + [ + `== ${group}`, + groupDescriptions[group] || '', + `[div boxDiv boxFullWidth]\n--\n${this.createTypeDocIndex( + this.getGroupItems(group).map((groupNode) => + this.convertNodeToLink(groupNode), + ), + )}--`, + ].join('\n\n'), + ); + + return [ + '= Visual Embed SDK', + ...intro, + `To list every item of one kind, browse by type: ${browseByType}.`, + ...sections, + ].join('\n\n'); + }; + + // The items of a group. A group that the config lists in full (groupOverrides) + // keeps the order of that list, so the embed class and its view config come + // first. Any other group keeps the order of the SDK, enumerations first. + private getGroupItems = (group: string): TypeDocLinkingNode[] => { + const items = this.groupMap[group] || []; + const listed = sdkReferenceConfig.groupOverrides[group]; + if (!listed || !items.every((item) => listed.includes(item.name))) { + return items; + } + return [...items].sort( + (a, b) => listed.indexOf(a.name) - listed.indexOf(b.name), + ); + }; + + // Groups in the order set in the config, then any other group A-Z. + private getOrderedGroupNames = () => { + const { groupOrder } = sdkReferenceConfig; + const rank = (group: string) => { + const index = groupOrder.indexOf(group); + return index === -1 ? groupOrder.length : index; + }; + return Object.keys(this.groupMap).sort( + (a, b) => rank(a) - rank(b) || a.localeCompare(b), + ); + }; + + // A child group (see childGroups in the config) appears as one entry under its + // parent group, linking to a page that lists its items. Its items are still in + // the nav, in a list that the sidebar does not show (`nav-hidden-children`), so + // their pages keep the right sidebar and breadcrumbs. + private getChildGroups = (parent: string) => + Object.entries(sdkReferenceConfig.childGroups) + .filter( + ([group, config]) => + config.parent === parent && + this.groupMap[group] && + this.groupMap[config.parent], + ) + .map(([group, config]) => ({ group, pageId: config.pageId })); + + private isNestedChildGroup = (group: string) => { + const config = sdkReferenceConfig.childGroups[group]; + return Boolean(config && this.groupMap[config.parent]); + }; + + private buildNavItem = (toLinkNode: TypeDocLinkingNode, bullet: string) => + `${bullet} [.${this.getTypeCSSClass( + toLinkNode, + )}]#link:{{navprefix}}/${this.getPageId(toLinkNode)}[${ + toLinkNode.name + }]#\n`; + + private buildSdkReferenceNav = ( + typePages: { title: string; pageId: string }[], + ) => { + const groupBullet = '**'; + const itemBullet = '***'; + const subItemBullet = '****'; + + let content = ''; + this.getOrderedGroupNames() + .filter((group) => !this.isNestedChildGroup(group)) + .forEach((group) => { + content += `${groupBullet} ${group}\n`; + + this.getGroupItems(group).forEach((toLinkNode) => { + content += this.buildNavItem(toLinkNode, itemBullet); + }); + + this.getChildGroups(group).forEach((child) => { + content += `${itemBullet} link:{{navprefix}}/${child.pageId}[${child.group}]\n`; + content += '[.nav-hidden-children]\n'; + this.getGroupItems(child.group).forEach((toLinkNode) => { + content += this.buildNavItem(toLinkNode, subItemBullet); + }); + }); + + content += '\n'; + }); + + // The pages that list every item of one kind. They are plain links: the + // items themselves are already in the categories above. + if (typePages.length) { + content += `${groupBullet} Browse by type\n`; + typePages.forEach(({ title, pageId }) => { + content += `${itemBullet} link:{{navprefix}}/${encodePageId( + pageId, + )}[${title}]\n`; }); + content += '\n'; + } - customSideNavContent += '\n'; + return content; + }; + + // The page of a child group: its description and an index of its items. + private buildChildGroupPage = (group: string, pageId: string): string => { + if (!this.groupMap[group]) return ''; + const description = + sdkReferenceConfig.landingPage.groupDescriptions[group] || ''; + const heading = this.getHeadingString({ + toc: false, + title: group, + pageId, + description: TypeDocInternalParser.plainText( + description.replace(/`/g, ''), + ), }); + return [ + heading, + `= ${group}`, + description, + `[div boxDiv boxFullWidth]\n--\n${this.createTypeDocIndex( + this.getGroupItems(group).map((groupNode) => + this.convertNodeToLink(groupNode), + ), + )}--`, + ].join('\n\n'); + }; - callBack('CustomSideNav', customSideNavContent); + private sortEmbedTypes = (embedTypes: string[]) => { + const preferredOrder = [ + 'AppEmbed', + 'LiveboardEmbed', + 'SearchEmbed', + 'SearchBarEmbed', + 'SpotterEmbed', + ]; + const rank = (embed: string) => { + const index = preferredOrder.indexOf(embed); + return index === -1 ? preferredOrder.length : index; + }; + return [...embedTypes].sort( + (a, b) => rank(a) - rank(b) || a.localeCompare(b), + ); + }; + + // Placeholder that the Document component swaps for the EmbedTypeFilter + // component, which filters the Action sections by their "Supported in" line. + private buildEmbedTypeFilter = (): string => { + const embedTypes = this.sortEmbedTypes([ + ...new Set(Object.values(this.actionEmbedTypes).flat()), + ]); + if (!embedTypes.length) return ''; + return `++++\n
\n++++\n`; }; + } class TypedocConverter { private typedDocParser = new TypeDocParser(); + // The SDK branch that the typedoc is read from. + private branch: string; + constructor(branch: string) { + this.branch = branch; TypeDocInternalParser.convertNameToLink = this.typedDocParser.convertNameToLink; + TypeDocInternalParser.resolveLinkTarget = this.typedDocParser.resolveLinkTarget; TypeDocInternalParser.GITHUB_LINK = `https://github.com/thoughtspot/visual-embed-sdk/blob/${branch}/src`; console.info('Source link : ', TypeDocInternalParser.GITHUB_LINK); @@ -973,19 +1865,67 @@ class TypedocConverter { return; } const indexPageId = 'VisualEmbedSdk'; + const pagesDir = 'modules/ROOT/pages/generated/typedoc'; + const navDir = 'modules/ROOT/pages/common/generated/typedoc'; + const navPageIds = ['VisualEmbedSdkNavLinks', 'SdkReferenceNav']; + + // Build every file first, so that a failure part way leaves the previous + // output in place instead of a half written one. + const files: { filePath: string; content: string }[] = []; this.typedDocParser.handleProjectNode( typedocNode, indexPageId, (pageId, content) => { const updatedPageId = pageId.replace('_', '/'); - const filePath = - pageId === 'VisualEmbedSdkNavLinks' || - pageId === 'CustomSideNav' - ? `modules/ROOT/pages/common/generated/typedoc/${updatedPageId}.adoc` - : `modules/ROOT/pages/generated/typedoc/${updatedPageId}.adoc`; - this.writeFile(filePath, content); + files.push({ + filePath: `${ + navPageIds.includes(pageId) ? navDir : pagesDir + }/${updatedPageId}.adoc`, + content, + }); }, ); + + // Two pages whose file names differ only by letter case (the SDK has DrillDownRequest + // and DrilldownRequest) are two files on Linux, but on macOS and Windows the second + // overwrites the first, so a page and every link to it is missing there. + const byLowerCase: Record = {}; + files.forEach(({ filePath }) => { + const key = filePath.toLowerCase(); + byLowerCase[key] = [...(byLowerCase[key] || []), path.basename(filePath, '.adoc')]; + }); + const collisions = Object.values(byLowerCase).filter((names) => names.length > 1); + if (collisions.length) { + console.warn( + `File names that differ only by letter case overwrite each other on case-insensitive file systems (macOS, Windows), so one page of each pair is missing there : ${collisions + .map((names) => names.join(' and ')) + .join(', ')}`, + ); + } + + // Both folders hold only generated files (they are git ignored). Clear + // them so that a page for an item that was removed, or excluded, from + // the SDK docs does not stay behind from an earlier run. + [pagesDir, navDir].forEach((dir) => + fs.rmSync(dir, { recursive: true, force: true }), + ); + files.forEach(({ filePath, content }) => + this.writeFile(filePath, content), + ); + + // The report for the writers is local only (the folder is git ignored) and it is + // written only for the main branch of the SDK. Another branch has other + // Actions, and its list would only get in the way of the one for main. + if (this.branch === ACTION_REPORT_BRANCH) { + this.writeFile( + ACTION_REPORT_PATH, + this.typedDocParser.buildActionReport(), + ); + } else { + console.info( + `Action report skipped : it is written only for the ${ACTION_REPORT_BRANCH} branch of the SDK, this run reads ${this.branch}`, + ); + } }; } diff --git a/scripts/Converter/sdk-reference-config.json b/scripts/Converter/sdk-reference-config.json new file mode 100644 index 000000000..5327f6fe1 --- /dev/null +++ b/scripts/Converter/sdk-reference-config.json @@ -0,0 +1,190 @@ +{ + "_comment": "Docs-side overrides for the Visual Embed SDK reference generated by scripts/converter/index.ts. The SDK source comments stay the source of truth; an entry here only applies when the SDK has no value of its own (descriptions) or when the docs need a different sidebar grouping (groups).", + "groupOrder": [ + "Authentication / Init", + "Full application embed", + "Liveboard embed", + "Search embed", + "Spotter embed", + "Shared embed options", + "Events", + "Host event payloads", + "Actions", + "Visual Overrides", + "Custom styles", + "Runtime filters", + "Error Handling", + "Global methods", + "Others" + ], + "fallbackGroup": "Others", + "kindFallbackGroups": { + "Function": "Global methods" + }, + "groupOverrides": { + "Authentication / Init": [ + "LogLevel" + ], + "Full application embed": [ + "AppEmbed", + "AppViewConfig", + "HomeLeftNavItem", + "HomePage", + "HomePageSearchBarMode", + "HomepageModule", + "ListPage", + "ListPageColumns", + "Page", + "PrimaryNavbarVersion" + ], + "Liveboard embed": [ + "LiveboardEmbed", + "LiveboardViewConfig", + "LiveboardOverride", + "LiveboardDataSourceOverride", + "LiveboardFilterQuery", + "LiveboardParameterQuery" + ], + "Search embed": [ + "SearchEmbed", + "SearchViewConfig", + "SearchBarEmbed", + "SearchBarViewConfig", + "DataSourceVisualMode", + "DataPanelCustomColumnGroupsAccordionState" + ], + "Spotter embed": [ + "SpotterEmbed", + "SpotterEmbedViewConfig", + "SpotterAgentEmbed", + "SpotterAgentEmbedViewConfig", + "SpotterAnalystConfig", + "SpotterChatViewConfig", + "SpotterShareConversationConfig", + "SpotterSidebarViewConfig", + "SpotterVizConfig", + "SpotterVizLoaderTip", + "SpotterVizStarterPrompt", + "SpotterExperienceVersion", + "SpotterQueryMode", + "SpotterSearchRequest", + "StarterPreviewDataCategory", + "StarterPromptCategory", + "StarterPromptQuestion", + "StarterPromptsConfig" + ], + "Shared embed options": [ + "FrameParams", + "DOMSelector", + "InterceptedApiType" + ], + "Events": [ + "SessionInterface", + "UnderlyingDataPoint", + "VizPoint", + "MessageCallback", + "SortOptions" + ], + "Host event payloads": [ + "CrossFilterCondition", + "UpdateFiltersFilterParam" + ], + "Global methods": [ + "executeTMLInput", + "exportTMLInput", + "PrefetchFeatures", + "AutoMCPFrameRendererViewConfig" + ], + "Custom styles": [ + "CustomStyles", + "CustomisationsInterface", + "customCssInterface", + "CustomCssVariables" + ], + "Runtime filters": [ + "RuntimeFilter", + "RuntimeFilterOp", + "RuntimeParameter", + "Applicability", + "ApplicabilityLevel" + ], + "Actions": [ + "Action", + "CustomAction", + "CustomActionTarget", + "CustomActionsPosition", + "ContextMenuTriggerOptions" + ] + }, + "groupPatterns": [ + { + "group": "Host event payloads", + "pattern": "(Request|Response|Payload)$|^(UIPassthrough|HostEvent|Trigger|FilterUpdate|HostFilterUpdate|Liveboard(Filter|Group|Tab|Parameter)$)" + } + ], + "descriptions": { + "executeTMLInput": "Input for the `executeTML` function. Specifies the TML objects to import, the import policy, and whether to create new objects.", + "exportTMLInput": "Input for the `exportTML` function. Specifies the objects to export and the format and options for the exported TML.", + "SessionInterface": "Identifies an Answer session on the ThoughtSpot server. Used by `AnswerService` and returned in the `session` property of Answer-related payloads.", + "UnderlyingDataPoint": "A data point (a column ID and its value) used to fetch the underlying data of a selected point in a chart or table. Passed to `AnswerService.getUnderlyingDataForPoint`.", + "PrefetchFeatures": "The embed features whose static resources can be prefetched. Pass these values in the `prefetchFeatures` parameter of the `prefetch` function.", + "DOMSelector": "A CSS selector string or an HTMLElement that identifies the DOM node in which to render an embedded component. Used by the constructors of the embed components, such as `AppEmbed`, `LiveboardEmbed`, and `SearchEmbed`.", + "HomePageSearchBarMode": "Modes for the search bar on the home page of an embedded ThoughtSpot application. Set this value in the `homePageSearchBarMode` property of `AppViewConfig`.", + "VizPoint": "A selected point in a visualization, described by its selected attributes and measures. Used by `AnswerService` and in the context menu payload of a custom action.", + "UIPassthroughEvent": "Identifiers of the UI passthrough requests. Each value maps to a request and response pair, as described in `UIPassthroughContractBase`." + }, + "landingPage": { + "description": "Reference for the classes, interfaces, enumerations, types, and functions of the Visual Embed SDK.", + "intro": [ + "The Visual Embed SDK reference describes the classes, interfaces, enumerations, types, and functions of the `@thoughtspot/visual-embed-sdk` package. It is generated from the comments in the SDK source code.", + "Browse the categories on this page, or use the navigation menu on the left. For an overview of how to embed ThoughtSpot with the SDK, see xref:getting-started.adoc[Embed with Visual Embed SDK]." + ], + "groupDescriptions": { + "Authentication / Init": "Initialize the SDK and authenticate users. Call `init` with an `EmbedConfig` object before you embed any ThoughtSpot component. This category also includes the enumerations for the authentication type, status, and log level, and the event emitter that `init` returns.", + "Full application embed": "Embed the full ThoughtSpot application with `AppEmbed` and configure it with `AppViewConfig`. This category also includes the enumerations for the options of the full application: the pages to show, the versions of the home page, list page, and primary navigation bar, and the home page modules, left navigation items, and search bar modes.", + "Liveboard embed": "Embed a Liveboard with `LiveboardEmbed` and configure it with `LiveboardViewConfig`. This category also includes the interfaces for the filters, Parameters, and data source overrides that you apply when a Liveboard loads.", + "Search embed": "Embed the ThoughtSpot Search page with `SearchEmbed`, or only the search bar with `SearchBarEmbed`, and configure them with `SearchViewConfig` and `SearchBarViewConfig`. This category also includes the options for the data source panel and the data panel on the Search page.", + "Spotter embed": "Classes and configuration types for embedding Spotter, the ThoughtSpot AI analytics experience. Use `SpotterEmbed` for the Spotter interface and `SpotterAgentEmbed` for chatbots and other conversational interfaces. This category also includes the interfaces for customizing the chat, the sidebar, conversation sharing, and starter prompts.", + "Shared embed options": "Types that all embed components use: the iframe settings (`FrameParams`), the element in which a component renders (`DOMSelector`), and the groups of ThoughtSpot APIs that you can intercept (`InterceptedApiType`).", + "Events": "Events and callbacks for communication between your app and an embedded component. Use `EmbedEvent` to listen to the events that the embedded application emits, `HostEvent` to trigger actions in the embedded application, and `AnswerService` to work with Answers. This category also includes the types that these events and `AnswerService` use.", + "Host event payloads": "Types for the data that you send to, or receive from, the embedded application: the payloads that `trigger` accepts for host events, and the request and response shapes of UI passthrough events. This category also includes the Liveboard filter, parameter, group, and tab types that these payloads use.", + "Actions": "Action IDs and custom action types. Use the `Action` IDs to show, hide, or disable the menu actions of an embedded component, and `CustomAction` to add your own code-based actions. On the `Action` page, you can filter the actions by embed type.", + "Visual Overrides": "Enumerations and an interface for customizing how charts and tables render in embedded visualizations, such as conditional formatting, data labels, legend position, and table theme and density.", + "Custom styles": "Interfaces for customizing the look and feel of embedded ThoughtSpot content, such as CSS variables and custom CSS rules. Set them in the `customizations` property of `EmbedConfig`.", + "Runtime filters": "Filters and parameters that you apply to embedded content when it loads: the `RuntimeFilter` and `RuntimeParameter` interfaces, the `RuntimeFilterOp` operators that you use in runtime filters, and the types that set the level at which a filter or parameter applies.", + "Error Handling": "Error codes, severity levels, and event types for handling the errors that embedded components emit.", + "Global methods": "Functions that you call directly, without an embed component: log out, prefetch static resources, reload the embedded frame, call ThoughtSpot APIs with an authenticated request (`tokenizedFetch`), get session and initialization information, import and export TML (`executeTML` and `exportTML`), create a Liveboard from Answers (`createLiveboardWithAnswers`), get an Answer from a natural language query (`getAnswerFromQuery`), convert a captured filter state (`convertFilterChangedToUpdateFiltersPayload`), and automatically render the ThoughtSpot MCP app frames on a page (`startAutoMCPFrameRenderer`). This category also includes the types that these functions use.", + "Others": "Additional enumerations, interfaces, and types that the SDK uses and that do not belong to another category, such as the log level, the levels at which a filter applies, and the groups of APIs that you can intercept." + }, + "typeDescriptions": { + "Enumerations": "Enumerations define fixed sets of values that you use to configure the SDK or to identify an event, an action, or an error. For example, `AuthType` sets the authentication method, `HostEvent` and `EmbedEvent` name the events that you trigger and listen to, and `Action` lists the action IDs of the embedded ThoughtSpot UI.", + "Classes": "Classes are the building blocks that you create in your app. The embed classes, such as `AppEmbed`, `LiveboardEmbed`, `SearchEmbed`, and `SpotterEmbed`, render ThoughtSpot content in your page. `AnswerService` works with the data of an Answer.", + "Interfaces": "Interfaces describe the shape of the objects that you pass to, or receive from, the SDK: the configuration that you pass to `init` (`EmbedConfig`), the view configuration of each embed component, and the payloads of events and callbacks.", + "Type aliases": "Type aliases name the union and helper types that the SDK uses, such as the types of event payloads, callbacks, and the selector that identifies the element in which to render an embedded component.", + "Functions": "Functions that you call directly, without an embed component: `init` to initialize the SDK and authenticate users, `logout`, `prefetch`, `executeTML` and `exportTML` to import and export TML, and other utilities." + } + }, + "actionEmbedTypes": { + "sourcePage": "modules/ROOT/pages/embed-action-ref.adoc", + "_confirmed_help": "For an Action that is not in the table on the page above yet: once a writer has confirmed its embed types, add \"ActionName\": [\"LiveboardEmbed\", \"AppEmbed\"] to this list. It gets a 'Supported in' line and shows under those embed types in the filter. The table wins if an Action is in both, so remove the entry here once the row is on the page.", + "confirmed": {} + }, + "childGroups": { + "Host event payloads": { + "parent": "Events", + "pageId": "HostEventPayloads" + } + }, + "excludeDeprecated": true, + "excludeDeprecatedMembersOf": [ + "Action" + ], + "commentFixes": [ + { + "node": "Action", + "reason": "The SDK comment lists three bullets, but the second and third are wrapped into the middle of a line, so they show as one run-on paragraph. Remove this entry once the comment in the SDK source is fixed (the converter says when the match no longer applies).", + "find": "To control actions in the embedded view: - disabledActions — the action is grayed out and still visible, but non-interactive (user can see but not click). - hiddenActions — the action is completely removed from the UI (user cannot see it at all). - visibleActions — allowlist, only these actions are shown; all others are hidden.", + "replace": "To control actions in the embedded view:\n\n* disabledActions — the action is grayed out and still visible, but non-interactive (user can see but not click).\n* hiddenActions — the action is completely removed from the UI (user cannot see it at all).\n* visibleActions — allowlist, only these actions are shown; all others are hidden." + } + ] +} diff --git a/src/assets/styles/index.scss b/src/assets/styles/index.scss index 32e85f14d..69af435d3 100644 --- a/src/assets/styles/index.scss +++ b/src/assets/styles/index.scss @@ -257,18 +257,39 @@ button { padding: 9px 5px; } -.version { +/* The Version and Deprecated blocks on the reference pages: the same size, and the + background of the buttons. They are one line high, but grow when a message wraps. */ +.version, +.deprecated { display: inline-block; font-size: $font-size-small; - height: 25px; - line-height: $line-height-doc; + min-height: 25px; + line-height: 23px; text-align: center; margin: 2px; min-width: $tag-width; - padding: $tag-padding; + // Less padding on the left than on the right, as asked: the bold label starts closer + // to the edge of the block. + padding: 0 10px 0 6px; color: var(--primary-color); - border: solid 1px; - border-color: $disabledcolor; + background: var(--copy-btn-bg); +} + +/* The same border as the div blocks and the horizontal lines (a Radiant token, so it follows + the theme). The Deprecated block keeps its orange border in both themes. */ +.version { + border: 1px solid var(--border-color); +} + +/* Code in these blocks (text in backticks in the SDK comment) keeps the monospace font + but not the chip that code has in the page text, which would sit on the block's own + background. */ +.documentView .version code, +.documentView .deprecated code { + padding: 0; + font-size: inherit; + background: none; + border: none; } .version-badge { @@ -309,15 +330,6 @@ button { } .deprecated { - display: inline-block; - font-size: 14px; - height: 25px; - line-height: $line-height-doc; - text-align: center; - margin: 2px; - min-width: $tag-width; - padding: $tag-padding; - color: var(--primary-color); border: solid 1px #fea85f; } @@ -1009,6 +1021,52 @@ a.anchor { /* typedoc styles*/ +/* The "Optional" after a property signature. */ +.documentView .typedoc-optional { + font-style: italic; +} + +/* Link colours use Radiant tokens (radiant-colors.css), which switch with the theme: + blue for classes, methods and constructors, purple for type aliases and functions. + Enumerations are gold and interfaces and properties olive, the colours of their icons + in the sprite (icons.png, a fixed image). Radiant has no equivalent hues, so these two + are fixed values, the same in both themes. */ +:root { + --typedoc-gold: #cc9900; + --typedoc-olive: #7da01f; +} + +/* Index of a typedoc page (see createTypeDocIndex in scripts/Converter/index.ts): + a list in columns that read top to bottom. The typedoc link style below sets + `white-space: nowrap`, which would overflow a narrow column, so links wrap here. */ +.documentView .ulist.typedoc-index { + ul { + margin: 0; + padding: 0; + list-style: none; + column-width: 250px; + column-gap: 24px; + } + + li { + margin: 0; + padding: 3px 0; + break-inside: avoid; + + > p { + // Hanging indent: wrapped lines align with the name, not the icon. + margin: 0; + padding-left: 22px; + text-indent: -22px; + } + } + + a { + white-space: normal; + overflow-wrap: anywhere; + } +} + .typedoc-Enumeration, .typedoc-Enumeration_member, .typedoc-Class, @@ -1037,7 +1095,7 @@ a.anchor { .typedoc-Enumeration { a { - color: #bf982a; + color: var(--typedoc-gold); } :before { background-position: 0px -136px; @@ -1047,7 +1105,7 @@ a.anchor { .typedoc-Enumeration_member { a { - color: #937210; + color: var(--typedoc-gold); } :before { background-position: 0px -136px; @@ -1057,7 +1115,7 @@ a.anchor { .typedoc-Constructor { a { - color: #0672de; + color: var(--rd-sys-color-content-accent-blue); } :before { background-position: -51px -102px; @@ -1067,7 +1125,7 @@ a.anchor { .typedoc-Method { a { - color: #0672de; + color: var(--rd-sys-color-content-accent-blue); } :before { background-position: -68px -68px; @@ -1077,7 +1135,7 @@ a.anchor { .typedoc-Property { a { - color: #647f1b; + color: var(--typedoc-olive); } :before { background-position: -221px 0px; @@ -1087,7 +1145,7 @@ a.anchor { .typedoc-signature { a { - color: #937210; + color: var(--rd-sys-color-content-primary); } :before { background-position: 0px -153px; @@ -1096,7 +1154,7 @@ a.anchor { .typedoc-Class { a { - color: #0550ae; + color: var(--rd-sys-color-content-accent-blue); } :before { background-position: 0px -34px; @@ -1105,7 +1163,7 @@ a.anchor { .typedoc-Class_member { a { - color: #0550ae; + color: var(--rd-sys-color-content-accent-blue); } :before { background-position: -51px -102px; @@ -1114,7 +1172,7 @@ a.anchor { .typedoc-Interface { a { - color: #647f1b; + color: var(--typedoc-olive); } :before { background-position: 0px -68px; @@ -1125,7 +1183,7 @@ a.anchor { .typedoc-Type_alias { a { - color: rgb(144, 10, 227); + color: var(--rd-sys-color-content-accent-purple); } :before { background-position: 0px -170px; @@ -1134,7 +1192,7 @@ a.anchor { .typedoc-Function { a { - color: rgb(144, 10, 227); + color: var(--rd-sys-color-content-accent-purple); } :before { background-position: -136px -68px; @@ -1145,12 +1203,12 @@ a.anchor { #docsModal[data-theme='dark'] { .typedoc-Type_alias a, .typedoc-Function a { - color: #71a1f4; + color: var(--rd-sys-color-content-accent-purple); } .typedoc-Type_alias :before, .typedoc-Function :before { background-image: none; - background-color: #71a1f4; + background-color: var(--rd-sys-color-content-accent-purple); -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; -webkit-mask-image: url(../icons/icons.png); diff --git a/src/components/DevDocTemplate/index.tsx b/src/components/DevDocTemplate/index.tsx index e3760503f..6c305dade 100644 --- a/src/components/DevDocTemplate/index.tsx +++ b/src/components/DevDocTemplate/index.tsx @@ -56,6 +56,8 @@ import VersionIframe from '../VersionIframe'; // Key of the merged nav-in-product-help.adoc entry in processedNavMap (pageid minus 'nav-' prefix). // Not a real DocCategory/tab — used only to pick the left sidebar content when embedded in-product. const IN_PRODUCT_NAV_KEY = 'in-product-help'; +// Key of nav-sdk-reference.adoc in processedNavMap; also the 'sdk-reference' DocCategory. +const SDK_REFERENCE_NAV_KEY = 'sdk-reference'; const DevDocTemplate: FC = (props) => { const { @@ -211,7 +213,12 @@ const isVersionedIframe = VERSION_DROPDOWN.some( if (typeof window === 'undefined') return {}; const knownCategories = new Set(Object.keys(CATEGORY_NAV_ID)); const map: Record = {}; - Object.entries(processedNavMap).forEach(([cat, html]) => { + // Entries are applied in order and the last one wins, so the SDK reference nav goes last: + // it claims the pages (e.g. VisualEmbedSdk) that the Embedding nav also links to. + const orderedNavs = Object.entries(processedNavMap).sort( + ([a], [b]) => Number(a === SDK_REFERENCE_NAV_KEY) - Number(b === SDK_REFERENCE_NAV_KEY), + ); + orderedNavs.forEach(([cat, html]) => { if (cat === IN_PRODUCT_NAV_KEY || !knownCategories.has(cat)) return; const doc = new DOMParser().parseFromString(html as string, 'text/html'); doc.querySelectorAll('a[href]').forEach((a) => { diff --git a/src/components/Document/index.tsx b/src/components/Document/index.tsx index 56f4a54df..e1a857a19 100644 --- a/src/components/Document/index.tsx +++ b/src/components/Document/index.tsx @@ -6,6 +6,7 @@ import Breadcrums from '../Breadcrums'; import LinkableHeader from '../LinkableHeader'; import WasThisHelpful from '../WasThisHelpful'; import CopyPageDropdown from '../CopyPageDropdown'; +import EmbedTypeFilter from '../EmbedTypeFilter'; import { HOME_PAGE_ID } from '../../configs/doc-configs'; import { isTutorialsPath } from '../../utils/app-utils'; import parse, { HTMLReactParserOptions, domToReact, attributesToProps } from 'html-react-parser'; @@ -176,6 +177,13 @@ const Document = (props: { const options: HTMLReactParserOptions = { replace: (domNode: any) => { + // Placeholder emitted by the typedoc converter on the Action reference page. + if (domNode.type === 'tag' && domNode.attribs?.class === 'embed-type-filter') { + const embedTypes = (domNode.attribs['data-embed-types'] || '') + .split(',') + .filter(Boolean); + return ; + } if (domNode.type === 'tag' && ['h2', 'h3', 'h4'].includes(domNode.name) && !domNode.parent?.attribs?.class?.includes('non-link') diff --git a/src/components/EmbedTypeFilter/index.scss b/src/components/EmbedTypeFilter/index.scss new file mode 100644 index 000000000..497459d4f --- /dev/null +++ b/src/components/EmbedTypeFilter/index.scss @@ -0,0 +1,164 @@ +/* Same pattern and look as the release-notes filter bar (Document/index.scss on + the release-notes-template-update branch): a pill-shaped Filter button that + opens a panel of checkbox sections, plus a pill-shaped search box. */ +.embed-type-filter { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 16px; + margin: 12px 0 20px; + + &__controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + } + + &__dropdown { + position: relative; + } + + &__trigger { + position: relative; + display: inline-flex; + align-items: center; + gap: 5px; + height: 30px; + padding: 0 12px; + font-family: inherit; + font-size: 13px; + font-weight: 500; + color: var(--primary-color); + background: var(--copy-btn-bg); + border: 1px solid var(--rd-sys-color-border-bold); + border-radius: 999px; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + border-color: var(--link-text-color); + color: var(--link-text-color); + } + + &.active { + color: var(--link-text-color); + border-color: var(--link-text-color); + } + } + + &__icon { + width: 16px; + height: 16px; + } + + &__menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + min-width: 16rem; + max-height: 360px; + overflow-y: auto; + padding: 6px; + background: var(--copy-page-menu-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: var(--box-shadow-hover); + z-index: 50; + } + + &__option { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 7px 12px; + font-size: 13px; + font-weight: 400; + color: var(--primary-color); + border-radius: 5px; + cursor: pointer; + + &:hover { + background: var(--copy-btn-bg); + } + + input[type='checkbox'] { + flex-shrink: 0; + width: 14px; + height: 14px; + margin: 0; + accent-color: var(--link-text-color); + cursor: pointer; + } + } + + &__clear { + display: block; + width: 100%; + margin-top: 4px; + padding: 7px 12px; + font-family: inherit; + font-size: 12.5px; + font-weight: 500; + text-align: left; + color: var(--link-text-color); + background: transparent; + border: none; + border-top: 1px solid var(--border-color); + cursor: pointer; + + &:hover { + background: var(--copy-btn-bg); + } + } + + &__search { + position: relative; + display: inline-flex; + align-items: center; + + input { + width: 180px; + height: 30px; + padding: 0 10px 0 30px; + font-family: inherit; + font-size: 13px; + color: var(--primary-color); + background: var(--copy-btn-bg); + border: 1px solid var(--rd-sys-color-border-bold); + border-radius: 999px; + transition: border-color 0.15s ease, width 0.15s ease; + + &::placeholder { + color: var(--primary-color); + } + + &:focus-visible { + outline: none; + border-color: var(--link-text-color); + width: 220px; + } + + &::-webkit-search-cancel-button, + &::-webkit-search-decoration { + display: none; + } + } + } + + &__search-icon { + position: absolute; + left: 10px; + width: 14px; + height: 14px; + color: var(--primary-color); + pointer-events: none; + } + + &__status { + font-size: 13px; + color: var(--primary-color); + opacity: 0.75; + } +} diff --git a/src/components/EmbedTypeFilter/index.tsx b/src/components/EmbedTypeFilter/index.tsx new file mode 100644 index 000000000..f099da137 --- /dev/null +++ b/src/components/EmbedTypeFilter/index.tsx @@ -0,0 +1,203 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { MdFilterList } from '@react-icons/all-files/md/MdFilterList'; +import { BiSearch } from '@react-icons/all-files/bi/BiSearch'; +import './index.scss'; + +// "A", "A and B", "A, B, and C" +const joinWithAnd = (items: string[]) => { + if (items.length <= 2) return items.join(' and '); + return `${items.slice(0, -1).join(', ')}, and ${items[items.length - 1]}`; +}; + +// The option for the actions that are not categorized by embed type yet. +const OTHER = 'Other'; + +/* + * Filter for the generated Action reference page, using the same pattern as + * the release-notes filter bar: a "Filter" button that opens a panel of + * multi-select checkboxes, plus a search box. + * + * The converter (scripts/Converter/index.ts) writes a "Supported in" paragraph + * with the class `supported-in` under each action, and a placeholder for this + * component with the embed types as a comma separated `data-embed-types` + * attribute. An action shows when its "Supported in" line lists at least one + * checked embed type (OR) and, if there is a search term, its text contains it + * (AND). An action with no "Supported in" line is not categorized by embed type + * yet. It shows under "All", in a search, and when "Other" is checked, which is + * only offered while such actions exist. + * + * Hidden actions are also removed from the Index and from the right-hand + * "on this page" list. + */ +const EmbedTypeFilter = (props: { embedTypes: string[] }) => { + const { embedTypes } = props; + const [selected, setSelected] = useState([]); + const [query, setQuery] = useState(''); + const [open, setOpen] = useState(false); + // How many actions have no embed type yet; "Other" is only offered while there are some. + const [uncategorized, setUncategorized] = useState(0); + const rootRef = useRef(null); + + const toggle = (embedType: string) => { + setSelected((current) => + current.includes(embedType) + ? current.filter((value) => value !== embedType) + : [...current, embedType], + ); + }; + + // What is being shown, in the order of the panel: "for SpotterEmbed", "for SpotterEmbed + // and ungrouped Action IDs", or just "ungrouped Action IDs" when only Other is checked. + const checkedEmbedTypes = embedTypes.filter((embedType) => + selected.includes(embedType), + ); + const checkedOther = selected.includes(OTHER); + let statusText = ''; + if (checkedEmbedTypes.length) { + statusText = `Showing Action IDs for ${joinWithAnd([ + ...checkedEmbedTypes, + ...(checkedOther ? ['ungrouped Action IDs'] : []), + ])}.`; + } else if (checkedOther) { + statusText = 'Showing ungrouped Action IDs.'; + } + + // Close the panel on an outside click or Escape. + useEffect(() => { + if (!open) return undefined; + const onClick = (event: MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) setOpen(false); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false); + }; + document.addEventListener('click', onClick); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('click', onClick); + document.removeEventListener('keydown', onKeyDown); + }; + }, [open]); + + useEffect(() => { + const view = rootRef.current?.closest('.documentView'); + if (!view) return undefined; + + const hiddenEntries: HTMLElement[] = []; + const hide = (el: HTMLElement | null) => { + if (!el) return; + el.style.display = 'none'; + hiddenEntries.push(el); + }; + + const search = query.trim().toLowerCase(); + let uncategorizedCount = 0; + const actionSections = Array.from( + view.querySelectorAll('.sect2'), + ); + actionSections.forEach((section) => { + const supportedIn = Array.from( + section.querySelectorAll('.supported-in a'), + ).map((link) => link.textContent?.trim() || ''); + if (!supportedIn.length) uncategorizedCount += 1; + const matchesEmbedType = + !selected.length || + selected.some((option) => + option === OTHER + ? !supportedIn.length + : supportedIn.includes(option), + ); + const matchesSearch = + !search || (section.textContent || '').toLowerCase().includes(search); + if (matchesEmbedType && matchesSearch) return; + hide(section); + const id = section.querySelector('h3')?.id; + if (!id) return; + // The Index links end in "#id" after the page path; the right-hand + // "on this page" list links to "#id" directly. + document.querySelectorAll(`a[href$="#${id}"]`).forEach((link) => { + hide(link.closest('li')); + }); + }); + setUncategorized(uncategorizedCount); + + return () => { + hiddenEntries.forEach((el) => { + // eslint-disable-next-line no-param-reassign + el.style.display = ''; + }); + }; + }, [selected, query]); + + return ( +
+
+
+ + {open && ( +
+ {[ + ...embedTypes, + ...(uncategorized > 0 ? [OTHER] : []), + ].map((embedType) => ( + + ))} + +
+ )} +
+ +
+ + {statusText} + +
+ ); +}; + +export default EmbedTypeFilter; diff --git a/src/components/LeftSidebar/helper.tsx b/src/components/LeftSidebar/helper.tsx index c0d1651bf..08b0a1d23 100644 --- a/src/components/LeftSidebar/helper.tsx +++ b/src/components/LeftSidebar/helper.tsx @@ -28,6 +28,15 @@ export const addExpandCollapseImages = ( nav.querySelectorAll('li').forEach((el, index) => { if (el.children.length === 2) { + // A nested list marked `nav-hidden-children` stays out of the menu: + // no arrow, never expanded. Its items are reached from the page of + // the entry itself. They stay in the HTML so their breadcrumbs and + // sidebar category still resolve. + if (el.children[1].classList.contains('nav-hidden-children')) { + el.children[1].classList.add('displayNone'); + el.children[0].classList.add('node-child'); + return; + } const paragraphElement = el.children[0]; if (paragraphElement.childNodes.length < 2) { paragraphElement.classList.add('linkTitle'); @@ -181,7 +190,10 @@ export const collapseAndExpandLeftNav = ( }); doc?.querySelectorAll('li').forEach((el, i) => { - if (el.children.length === 2) { + if ( + el.children.length === 2 && + !el.children[1].classList.contains('nav-hidden-children') + ) { const spanElement = el.children[0].children.length === 2 ? el.children[0].children[1] diff --git a/src/components/LeftSidebar/index.tsx b/src/components/LeftSidebar/index.tsx index 891fb64b6..ffb2216db 100644 --- a/src/components/LeftSidebar/index.tsx +++ b/src/components/LeftSidebar/index.tsx @@ -60,6 +60,13 @@ const LeftSideBar = (props: { }); if (tag) { tag.classList.add('active'); + // On a page that is only reachable from a hidden sub-menu, mark the + // visible parent entry as the active one instead. + divElement + .querySelector('.nav-hidden-children a.active') + ?.closest('.nav-hidden-children') + ?.parentElement?.querySelector(':scope > p a') + ?.classList.add('active'); } const updatedHTML = addExpandCollapseImages( diff --git a/src/components/SecondaryHeader/index.tsx b/src/components/SecondaryHeader/index.tsx index b21551f12..0dfcc5513 100644 --- a/src/components/SecondaryHeader/index.tsx +++ b/src/components/SecondaryHeader/index.tsx @@ -11,6 +11,7 @@ export type DocCategory = | 'guides' | 'walkthroughs' | 'embedding' + | 'sdk-reference' | 'rest-api' | 'mcp-server' | 'spottercode' @@ -22,6 +23,7 @@ export const CATEGORY_LABELS: Record = { guides: 'Developer guides', walkthroughs: 'Quickstarts', embedding: 'Embedding', + 'sdk-reference': 'Visual Embed SDK reference', 'rest-api': 'REST APIs', 'mcp-server': 'MCP server', spottercode: 'SpotterCode', @@ -37,6 +39,7 @@ export const CATEGORY_LANDING: Record = { guides: '/introduction', walkthroughs: '/tutorials/quickstarts', embedding: '/getting-started', + 'sdk-reference': '/VisualEmbedSdk', 'rest-api': '/rest-apis', 'mcp-server': '/mcp-integration', spottercode: '/SpotterCode', @@ -53,6 +56,7 @@ export const CATEGORY_NAV_ID: Record = { guides: 'nav', walkthroughs: 'nav-walkthroughs', embedding: 'nav-embedding', + 'sdk-reference': 'nav-sdk-reference', 'rest-api': 'nav-rest-api', 'mcp-server': 'nav-mcp-server', spottercode: 'nav-spottercode', @@ -125,6 +129,11 @@ export const CATEGORY_PAGEIDS: Record = { 'SpotterCode', 'integrate-SpotterCode', 'spottercode-prompting-guide', ], tutorials: [], + // The pages that list every item of one kind are linked from the landing page. + 'sdk-reference': [ + 'VisualEmbedSdk', + 'Enumeration', 'Class', 'Interface', 'Type alias', 'Function', + ], 'whats-new': [ 'whats-new', 'fixed-issues', 'known-issues', 'deprecated-features', 'embed-sdk-changelog', 'mobile-sdk-changelog', @@ -151,6 +160,8 @@ const SecondaryHeader = (props: { isVersionedIframe, } = props; const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + // The SDK reference has its own sidebar but no tab of its own: it is part of Embedding. + const activeTab: DocCategory = activeCategory === 'sdk-reference' ? 'embedding' : activeCategory; const menuRef = useRef(null); // 'tutorials' and 'walkthroughs' intentionally excluded — reachable from links @@ -227,8 +238,8 @@ const SecondaryHeader = (props: {