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..8364564c8 --- /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 + +* 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/modules/ROOT/pages/embed-intro.adoc b/modules/ROOT/pages/embed-intro.adoc deleted file mode 100644 index 403e519c8..000000000 --- a/modules/ROOT/pages/embed-intro.adoc +++ /dev/null @@ -1,48 +0,0 @@ -= Embedded Analytics with ThoughtSpot -:toc: true - -:page-title: Embedded Analytics with ThoughtSpot -:page-pageid: index -:page-description: Embedded Analytics with ThoughtSpot - -The ThoughtSpot embedding solution enables you to integrate ThoughtSpot functionality and infuse search-driven data and insights into your application, website, portal, product, or business solution. With ThoughtSpot embedding, you can enrich your applications with ThoughtSpot capabilities and provide context-driven analytics with a seamless experience for your application users. - -ThoughtSpot provides a suite of APIs, SDK, and developer tools to programmatically embed search analytics, Liveboards, visualizations, and even the entire ThoughtSpot application. The ThoughtSpot embedding solution also provides a set of UI-based functions to customize and rebrand the embedded ThoughtSpot components. - -== Types of embedding -ThoughtSpot supports embedding the full application or individual application components into your business solution. -Based on your business needs and integration environment, you can use one of the following embedding approaches: - -Full-application embedding:: -You can embed the entire ThoughtSpot application into your host application or portal. Your application users can navigate to the ThoughtSpot UI through a tab or a link.  -Page-level embedding:: -You can embed a ThoughtSpot page and hide the navigation bar, help, and profile links. The client applications can allow page-to-page navigation. -Object-level embedding:: -You can embed a specific component such as the search functionality, Liveboards, or charts. - -== Embedding methods -You can use one of the following methods to embed a ThoughtSpot functionality or the full application into your host application: -//// -* *Using iFrames via ThoughtSpot UI*::In this method, you can copy the embed link for a Liveboard or visualization from the ThoughtSpot UI and add it your webpage or an HTML file. For more information, see Embedding a Liveboard or Visualization. -//// -*Using the Visual Embed SDK*:: -In this method, you can install the Visual Embed SDK, import the Javascript library, and programmatically integrate ThoughtSpot components or the full application. The Visual Embed SDK includes separate libraries for embedding ThoughtSpot content, such as search, visualizations, Liveboards, and the full application. -//// - -//// - -*Using REST APIs*:: -In this method, you can use the REST APIs to programmatically fetch, embed, and customize data from the ThoughtSpot application. -//// - -//// -== UI customization and rebranding -If you have embedded the ThoughtSpot application in your host application, you can customize the look and feel of ThoughtSpot UI to match your organization's branding guidelines. - -== Custom actions -The ThoughtSpot embedding solution allows you to build additional actions to your embedded content, such as Liveboards and visualizations. For example, if you want to email a Liveboard, you can create a custom action and add it to your menu component on the Liveboards. - -== ThoughtSpot Developer portal -To enable your developers with API-related tools and to facilitate faster application integration, ThoughtSpot provides the SpotDev developer portal. -The Developer portal includes a playground for application developers to learn, explore, and interact with the Visual Embed APIs and build sample content. -The portal also allows developers to customize the ThoughtSpot UI and configure custom actions. diff --git a/modules/ROOT/pages/faqs.adoc b/modules/ROOT/pages/faqs.adoc index b5943d7ce..4488af50f 100644 --- a/modules/ROOT/pages/faqs.adoc +++ b/modules/ROOT/pages/faqs.adoc @@ -151,7 +151,7 @@ ThoughtSpot Embedded is a low-code embedded analytics platform with several feat ** Customization controls for the embedded view ** Advanced security settings -For more information, see xref:embed-intro.adoc[Embedded analytics with ThoughtSpot Embedded]. +For more information, see xref:onboarding-guide.adoc[Get started with ThoughtSpot Embedded]. ==== diff --git a/modules/ROOT/pages/mobile-embed-qs-flutter.adoc b/modules/ROOT/pages/mobile-embed-qs-flutter.adoc index 684d0f0b5..ecdbc78be 100644 --- a/modules/ROOT/pages/mobile-embed-qs-flutter.adoc +++ b/modules/ROOT/pages/mobile-embed-qs-flutter.adoc @@ -156,7 +156,7 @@ You can use ThoughtSpot's xref:css-customization.adoc[CSS customization framewor To listen to the events emitted by the embedded ThoughtSpot component, register xref:event-embedEvents.adoc[embed event] handlers. -The following code adds event listeners for xref:EmbedEvent.adoc#_load[EmbedEvent.Load], xref:EmbedEvent.adoc#__error[EmbedEvent.Error], and xref:EmbedEvent.adoc#_data[EmbedEvent.Data]. +The following code adds event listeners for xref:EmbedEvent.adoc#_load[EmbedEvent.Load], xref:EmbedEvent.adoc#_error[EmbedEvent.Error], and xref:EmbedEvent.adoc#_data[EmbedEvent.Data]. [source,Dart] ---- diff --git a/modules/ROOT/pages/mobile-embed.adoc b/modules/ROOT/pages/mobile-embed.adoc index 7d22aa27a..de749c723 100644 --- a/modules/ROOT/pages/mobile-embed.adoc +++ b/modules/ROOT/pages/mobile-embed.adoc @@ -39,7 +39,7 @@ Allows adding filters to the embedded Liveboard Allows applying filters across all visualizations in a Liveboard. * **Drill down** (xref:Action.adoc#_drilldown[`Action.DrillDown`]) + Allows drilling down on a data point in the visualization to get granular details. -* **Personalized view** (xref:Action.adoc#_personalisedviewsdropdown[`Action.PersonalisedViewsDropdown`]) + +* **Personalized view** (xref:Action.adoc#_personalizedviewsdropdown[`Action.PersonalizedViewsDropdown`]) + The Liveboard personalized views drop-down. * **Filter** action (xref:Action.adoc#_axismenufilter[`Action.AxisMenuFilter`]) in the chart axis or table column customization menu + Allows adding, editing, or removing filters on a visualization. diff --git a/scripts/Converter/index.ts b/scripts/Converter/index.ts index 0bed39e50..31ec648af 100644 --- a/scripts/Converter/index.ts +++ b/scripts/Converter/index.ts @@ -2,6 +2,46 @@ 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; + // The groups whose items follow the order of their list in groupOverrides. + curatedOrder: string[]; + 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', @@ -162,25 +202,138 @@ 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]; + }; + + // The SDK sometimes adds more lines under @version (e.g. "Payload: {@link X}"). Only the + // first line is the version; the rest is text that follows the description (see parseTags). + private static versionLine = (tag: TypeDocCommentTag) => + tag.text.trim().split('\n')[0].trim(); + + private static versionExtraText = (tag: TypeDocCommentTag) => + tag.text.trim().split('\n').slice(1).join('\n').trim(); + // function to parse a tag static parseTag(tag: TypeDocCommentTag): string { if (!tag.tag.trim() || !tag.text.trim()) { @@ -189,16 +342,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* : ${this.versionLine(tag)}#\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') { @@ -221,7 +376,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 = ''; @@ -232,16 +416,23 @@ 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); + } + versionTags.forEach((tag) => { + const extra = this.versionExtraText(tag); + if (extra) content += `\n${this.covertTypeDocText(extra)}\n\n`; + }); addTagsToContent(restTags); } @@ -385,8 +576,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, @@ -498,32 +714,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]) @@ -571,9 +844,9 @@ class TypeDocParser { this.childrenIdMap[parent]?.kindString === TypeDocReflectionKind.Project ) { - return `[.typedoc-${node.kindString.replace(/ /g, '_')}]#xref:${ - node.name - }.adoc[${node.name}]#`; + return `[.typedoc-${node.kindString.replace(/ /g, '_')}]#xref:${this.fileKeyOf( + node, + )}.adoc[${node.name}]#`; } const grandParent = this.childrenIdMap[parent]?.parentId; @@ -586,7 +859,7 @@ class TypeDocParser { let newLinkText = `[.typedoc-${node.kindString.replace( / /g, '_', - )}]#xref:${this.childrenIdMap[parent].name}.adoc`; + )}]#xref:${this.fileKeyOf(this.childrenIdMap[parent])}.adoc`; newLinkText += `#${this.convertToAdocSectionLink(node.name)}`; const visibleName = includeParent ? `${this.childrenIdMap[parent].name}.${node.name}` @@ -598,6 +871,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); + }; + // "Indexable" section: e.g. `[key: string]: any` private handleIndexable = (node: TypeDocNode) => { const signature = node.indexSignature; @@ -617,18 +898,19 @@ class TypeDocParser { const pageTitle = `= ${node.name}`; const mainPageContent = ''; - // The Index section is only emitted when it has a table to show. - // A table with no rows is dropped by Asciidoctor and breaks the markdown conversion. + // The Index section is only emitted when it has entries to show. + // An empty block is dropped by Asciidoctor and breaks the markdown conversion. let indexSection = ''; if ( ['Enumeration', 'Class', 'Interface'].includes(node.kindString) && node.children?.length ) { - indexSection = `== Index\n\n\n[div boxDiv boxFullWidth]\n--\n${this.createTypeDocTable( + indexSection = `== Index\n\n${ + node.name === 'Action' ? `${this.buildEmbedTypeFilter()}\n\n` : '' + }\n\n[div boxDiv boxFullWidth]\n--\n${this.createTypeDocIndex( node.children.map((childNode) => this.convertNodeToLink(childNode), ), - 3, )}\n--\n\n`; } @@ -647,8 +929,10 @@ 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), this.handleIndexable(node), indexSection, mainPageContent, @@ -658,24 +942,410 @@ class TypeDocParser { .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. + // The top-level items that were left out of the docs, for the reference check. + public excludedPageNames: string[] = []; + + 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(', ')}`, + ); + }; + + const excludedItems = (project.children || []).filter((node) => + this.isExcludedFromDocs(node, sdkReferenceConfig.excludeDeprecated), + ); + this.excludedPageNames = excludedItems.map((node) => node.name); + removeFrom(project, excludedItems, '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, ' ') + // Backslashes first, then the pipes: escaping a pipe adds a backslash, and a + // backslash already in the text would otherwise change what that means. + .replace(/\\/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) { @@ -697,13 +1367,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'); }; @@ -714,9 +1386,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'); }; @@ -728,12 +1402,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'); }; @@ -744,14 +1422,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'); }; @@ -803,8 +1483,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'); @@ -815,7 +1501,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'); @@ -871,25 +1557,72 @@ class TypeDocParser { } }; + // The name of the file and the page of an item. It is the name of the item, except on a + // case-insensitive file system (macOS, Windows), where two items of one kind whose + // names differ only by letter case would overwrite each other. The later one then gets + // a suffix, here and in every link to it, so that its page is not lost. + private fileKeys: Record = {}; + + private fileKeyOf = (node: TypeDocNode) => this.fileKeys[node.id] || node.name; + + private assignFileKeys = (project: TypeDocNode, caseInsensitive: boolean) => { + this.fileKeys = {}; + const byLowerCase: Record = {}; + (project.children || []).forEach((node) => { + const key = `${node.kindString}/${node.name.toLowerCase()}`; + byLowerCase[key] = [...(byLowerCase[key] || []), node]; + }); + const collisions = Object.values(byLowerCase).filter( + (nodes) => nodes.length > 1, + ); + collisions.forEach((nodes) => { + [...nodes] + .sort((a, b) => (a.name < b.name ? -1 : 1)) + .slice(1) + .forEach((node, index) => { + if (caseInsensitive) { + this.fileKeys[node.id] = `${node.name}-${index + 2}`; + } + }); + }); + if (!collisions.length) return; + const pairs = collisions + .map((nodes) => nodes.map((node) => node.name).join(' and ')) + .join(', '); + console.warn( + caseInsensitive + ? `File names that differ only by letter case (${pairs}). This file system is case-insensitive, so the later one of each pair is written under a name with a suffix, here and in its links. A Linux build writes each under its own name. The SDK should rename one of them.` + : `Names that differ only by letter case (${pairs}). They are separate files here, but on macOS and Windows they would overwrite each other. The SDK should rename one of them.`, + ); + }; + private getPageId = (node: TypeDocNode) => { - return encodePageId(`${node.kindString}_${node.name}`); + return encodePageId(`${node.kindString}_${this.fileKeyOf(node)}`); }; public handleProjectNode = ( node: TypeDocNode, indexPageId = 'VisualEmbedSdk', callBack: (pageId: string, content: string) => void, + options: { caseInsensitiveFileSystem?: boolean } = {}, ) => { 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.assignFileKeys(projectNode, Boolean(options.caseInsensitiveFileSystem)); + 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( @@ -897,13 +1630,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'; @@ -921,56 +1662,302 @@ class TypeDocParser { group.children.forEach((id) => { const child = this.childrenIdMap[id]; - const pageId = `${child.kindString}_${child.name}`; + const pageId = `${child.kindString}_${this.fileKeyOf(child)}`; 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)}`, + ); + + Object.entries(sdkReferenceConfig.childGroups).forEach( + ([group, { pageId }]) => { + const content = this.buildChildGroupPage(group, pageId); + if (content) callBack(pageId, content); + }, + ); + + // 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)); + }; + + // 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( + ', ', + )}`, + ); + } - let customSideNavContent = ''; - Object.keys(this.groupMap).forEach((k) => { - customSideNavContent += `*** ${k}\n`; + const browseByType = typePages + .map( + ({ title, pageId }) => + `link:{{navprefix}}/${encodePageId(pageId)}[${title}]`, + ) + .join(', '); - this.groupMap[k].forEach((toLinkNode) => { - const linkToNode = `link:{{navprefix}}/${this.getPageId( - toLinkNode, - )}`; + 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 in curatedOrder follows the order of its list in + // groupOverrides, so the embed class and its view config come first. An item that + // the SDK adds to such a group and that is not in the list yet comes after the listed + // ones, in SDK order, and is reported so that it gets added to the list. 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 || !sdkReferenceConfig.curatedOrder.includes(group)) return items; + + const inList = items + .filter((item) => listed.includes(item.name)) + .sort((a, b) => listed.indexOf(a.name) - listed.indexOf(b.name)); + const notInList = items.filter((item) => !listed.includes(item.name)); + if (notInList.length && !this.reportedUnlistedGroups.has(group)) { + this.reportedUnlistedGroups.add(group); + console.warn( + `Category ${group} : ${notInList.length} items are not in its list in groupOverrides in sdk-reference-config.json, so they come after the listed ones, in SDK order. Add them to the list to place them : ${notInList + .map((item) => item.name) + .join(', ')}`, + ); + } + return [...inList, ...notInList]; + }; + + private reportedUnlistedGroups = new Set(); + + // 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 })); - customSideNavContent += `**** [.${this.getTypeCSSClass( - toLinkNode, - )}]#${linkToNode}[${toLinkNode.name}]#\n`; + 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`; }; + } +// Whether the file system treats File and file as one name, as macOS and Windows do by +// default. Checked by writing a file and looking for it under another case. +const isCaseInsensitiveFileSystem = (dir: string) => { + const probe = path.join(dir, `.case-probe-${process.pid}`); + fs.writeFileSync(probe, ''); + try { + return fs.existsSync(path.join(dir, `.CASE-PROBE-${process.pid}`)); + } finally { + fs.unlinkSync(probe); + } +}; + +// The id that Asciidoctor gives a heading, which is what an xref#anchor has to match: +// lower case, other characters dropped, spaces and hyphens become underscores. +const asciidoctorId = (heading: string) => + `_${heading + .toLowerCase() + .replace(/[^a-z0-9 _.-]+/g, '') + .replace(/[ .-]+/g, '_') + .replace(/_+/g, '_') + .replace(/_$/, '')}`; + 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); @@ -987,25 +1974,134 @@ class TypedocConverter { fs.writeFileSync(filePath, content); } + // A hand-written page that links to a generated page, or to an anchor on it, that is not + // there any more (an item or member that was excluded or removed) has a dead link. Say + // so, for every hand-written page in modules/, not only the Action IDs page. + private warnAboutDeadReferences = ( + files: { filePath: string; content: string }[], + ) => { + const anchorsOfPage: Record> = {}; + files.forEach(({ filePath, content }) => { + anchorsOfPage[path.basename(filePath, '.adoc')] = new Set( + [...content.matchAll(/^={2,6} (.+)$/gm)].map((match) => + asciidoctorId(match[1].trim()), + ), + ); + }); + const excluded = new Set(this.typedDocParser.excludedPageNames); + + const dead: string[] = []; + const visit = (dir: string) => { + fs.readdirSync(dir, { withFileTypes: true }).forEach((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name !== 'generated' && entry.name !== 'node_modules') { + visit(full); + } + return; + } + if (!entry.name.endsWith('.adoc')) return; + fs.readFileSync(full, 'utf8') + .split('\n') + .forEach((line, index) => { + [...line.matchAll(/xref:([^\s#[\]]+)\.adoc(?:#([^\s[]+))?/g)].forEach( + ([, page, anchor]) => { + const where = `${full}:${index + 1}`; + if (anchorsOfPage[page]) { + if (anchor && !anchorsOfPage[page].has(anchor)) { + dead.push(`${where} ${page}.adoc#${anchor}`); + } + } else if (excluded.has(page)) { + dead.push(`${where} ${page}.adoc (excluded from the docs)`); + } + }, + ); + }); + }); + }; + visit('modules'); + + if (dead.length) { + console.warn( + `Hand-written pages link to generated pages or anchors that do not exist (an item or member was excluded or removed). Update or remove these links :\n${dead + .map((entry) => ` ${entry}`) + .join('\n')}`, + ); + } + }; + public generateFiles = (typedocNode: TypeDocNode) => { // starting node should be a project node if (typedocNode.kindString !== TypeDocReflectionKind.Project) { 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 caseInsensitiveFileSystem = isCaseInsensitiveFileSystem( + 'modules/ROOT/pages', + ); + 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, + }); }, + { caseInsensitiveFileSystem }, ); + + // On a case-insensitive file system the parser has already given the later one of two + // names that differ only by letter case a suffix. If two files would still be one, stop: + // writing them would silently lose a page. + if (caseInsensitiveFileSystem) { + const seen: Record = {}; + files.forEach(({ filePath }) => { + const key = filePath.toLowerCase(); + if (seen[key] && seen[key] !== filePath) { + throw new Error( + `${seen[key]} and ${filePath} are one file on this file system, so one page would be lost`, + ); + } + seen[key] = filePath; + }); + } + + this.warnAboutDeadReferences(files); + + // 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..551522bb6 --- /dev/null +++ b/scripts/Converter/sdk-reference-config.json @@ -0,0 +1,200 @@ +{ + "_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" + ] + }, + "curatedOrder": [ + "Full application embed", + "Liveboard embed", + "Search embed", + "Spotter embed", + "Shared embed options", + "Custom styles", + "Runtime filters", + "Actions" + ], + "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 215d72805..a6dc3e3e0 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; } @@ -1015,6 +1027,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, @@ -1043,7 +1101,7 @@ a.anchor { .typedoc-Enumeration { a { - color: #bf982a; + color: var(--typedoc-gold); } :before { background-position: 0px -136px; @@ -1053,7 +1111,7 @@ a.anchor { .typedoc-Enumeration_member { a { - color: #937210; + color: var(--typedoc-gold); } :before { background-position: 0px -136px; @@ -1063,7 +1121,7 @@ a.anchor { .typedoc-Constructor { a { - color: #0672de; + color: var(--rd-sys-color-content-accent-blue); } :before { background-position: -51px -102px; @@ -1073,7 +1131,7 @@ a.anchor { .typedoc-Method { a { - color: #0672de; + color: var(--rd-sys-color-content-accent-blue); } :before { background-position: -68px -68px; @@ -1083,7 +1141,7 @@ a.anchor { .typedoc-Property { a { - color: #647f1b; + color: var(--typedoc-olive); } :before { background-position: -221px 0px; @@ -1093,7 +1151,7 @@ a.anchor { .typedoc-signature { a { - color: #937210; + color: var(--rd-sys-color-content-primary); } :before { background-position: 0px -153px; @@ -1102,7 +1160,7 @@ a.anchor { .typedoc-Class { a { - color: #0550ae; + color: var(--rd-sys-color-content-accent-blue); } :before { background-position: 0px -34px; @@ -1111,7 +1169,7 @@ a.anchor { .typedoc-Class_member { a { - color: #0550ae; + color: var(--rd-sys-color-content-accent-blue); } :before { background-position: -51px -102px; @@ -1120,7 +1178,7 @@ a.anchor { .typedoc-Interface { a { - color: #647f1b; + color: var(--typedoc-olive); } :before { background-position: 0px -68px; @@ -1131,7 +1189,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; @@ -1140,7 +1198,7 @@ a.anchor { .typedoc-Function { a { - color: rgb(144, 10, 227); + color: var(--rd-sys-color-content-accent-purple); } :before { background-position: -136px -68px; @@ -1151,12 +1209,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/Docmap/index.tsx b/src/components/Docmap/index.tsx index 26a341106..42cb1508c 100644 --- a/src/components/Docmap/index.tsx +++ b/src/components/Docmap/index.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import t from '../../utils/lang-utils'; +import { DOC_FILTER_EVENT } from '../../utils/doc-filter-events'; import './index.scss'; const Docmap = (props: { @@ -9,6 +10,8 @@ const Docmap = (props: { }) => { const [toc, setToc] = useState(''); const [activeId, setActiveId] = useState(''); + // Sections that a page filter (the Action page) has hidden: their entries are hidden here too. + const [hiddenIds, setHiddenIds] = useState([]); const tocRef = useRef(null); const visibleHeadings = useRef>(new Set()); @@ -68,6 +71,36 @@ const Docmap = (props: { }; }, [toc]); + useEffect(() => { + const onFilter = (event: Event) => + setHiddenIds((event as CustomEvent).detail?.hiddenIds || []); + window.addEventListener(DOC_FILTER_EVENT, onFilter); + return () => window.removeEventListener(DOC_FILTER_EVENT, onFilter); + }, []); + + // Hide the entries of the hidden sections. This runs again whenever the list is rebuilt + // (toc), so the entries stay hidden. + useEffect(() => { + const root = tocRef.current; + if (!root || !hiddenIds.length) return undefined; + const hidden = new Set(hiddenIds); + const hiddenEntries: HTMLElement[] = []; + root.querySelectorAll('a[href^="#"]').forEach((link) => { + const id = (link.getAttribute('href') || '').slice(1); + const entry = link.closest('li'); + if (hidden.has(id) && entry) { + entry.style.display = 'none'; + hiddenEntries.push(entry); + } + }); + return () => { + hiddenEntries.forEach((entry) => { + // eslint-disable-next-line no-param-reassign + entry.style.display = ''; + }); + }; + }, [hiddenIds, toc]); + // Apply activeTag class to the li containing the active TOC link useEffect(() => { if (!tocRef.current) return; 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..7b2579d76 --- /dev/null +++ b/src/components/EmbedTypeFilter/index.scss @@ -0,0 +1,340 @@ +/* The search box on the left, the filter chip next to it, and the panel that the chip + opens: a search row, then a list of checkboxes. The colours are Radiant tokens (through + the docs variables), so they follow the theme. */ +.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: 10px; + } + + // Search box + &__search { + position: relative; + display: inline-flex; + align-items: center; + + input { + width: 200px; + height: 32px; + padding: 0 30px 0 34px; + font-family: inherit; + font-size: 13px; + color: var(--primary-color); + background: transparent; + border: 1px solid var(--rd-sys-color-border-bold); + border-radius: 8px; + transition: border-color 0.15s ease; + + &::placeholder { + color: var(--rd-sys-color-content-secondary); + } + + &:focus-visible { + outline: none; + border-color: var(--link-text-color); + } + + &::-webkit-search-cancel-button, + &::-webkit-search-decoration { + display: none; + } + } + } + + &__search-icon { + position: absolute; + left: 11px; + width: 15px; + height: 15px; + color: var(--rd-sys-color-content-secondary); + pointer-events: none; + } + + // The x that clears a search box (the page search and the search in the panel). + &__search-clear { + position: absolute; + right: 8px; + top: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + padding: 0; + color: var(--rd-sys-color-content-secondary); + background: transparent; + border: none; + border-radius: 50%; + cursor: pointer; + transform: translateY(-50%); + + svg { + width: 14px; + height: 14px; + } + + &:hover { + color: var(--primary-color); + background: var(--copy-btn-bg); + } + + &:focus-visible { + outline: 2px solid var(--rd-sys-color-border-focus); + } + } + + // Filter chip + &__dropdown { + position: relative; + } + + &__chip { + display: inline-flex; + align-items: center; + gap: 4px; + // The chip is as wide as its content, so there is no extra space after the icon. + // The text stays at the left, so "Action" does not move when the chip opens. + justify-content: flex-start; + height: 30px; + padding: 0 14px; + font-family: inherit; + font-size: 14px; + // The text is the link color, like the icon, until the panel is open; then it is the + // normal text color (see the aria-expanded rule below). + color: var(--link-text-color); + // The fill of the button in the product UI: the subtle surface in the light theme, + // the secondary action surface in the dark theme (see the override at the end). + background: var(--rd-sys-color-background-on-base); + border: none; + border-radius: 999px; + cursor: pointer; + transition: background 0.15s ease; + + &[aria-expanded='true'] { + color: var(--primary-color); + } + + strong { + font-weight: 700; + } + + // The filter icon at rest, which "Select" replaces while the panel is open. + .embed-type-filter__chip-icon { + width: 16px; + height: 16px; + color: var(--link-text-color); + } + + &:hover { + background: var(--copy-btn-hover); + } + + &:focus-visible { + outline: 2px solid var(--rd-sys-color-border-focus); + outline-offset: 2px; + } + } + + // The x on the corner of the chip, shown while something is selected. + &__chip-clear { + position: absolute; + top: -8px; + right: -8px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + padding: 0; + color: var(--rd-sys-color-background-base); + background: var(--rd-sys-color-content-secondary); + border: none; + border-radius: 50%; + cursor: pointer; + + svg { + width: 12px; + height: 12px; + } + + &:hover { + background: var(--primary-color); + } + } + + // The panel + &__menu { + position: absolute; + top: calc(100% + 8px); + left: 0; + z-index: 50; + width: 300px; + max-width: 80vw; + overflow: hidden; + background: var(--rd-sys-color-background-raised); + border: 1px solid var(--border-color); + border-radius: 10px; + box-shadow: var(--box-shadow-hover); + } + + &__menu-search { + position: relative; + display: flex; + align-items: center; + gap: 10px; + height: 44px; + padding: 0 14px; + color: var(--rd-sys-color-content-secondary); + border-bottom: 1px solid var(--border-color); + + > svg { + flex-shrink: 0; + width: 16px; + height: 16px; + } + + .embed-type-filter__search-clear { + right: 12px; + } + + input { + flex: 1; + min-width: 0; + height: 100%; + padding: 0 22px 0 0; + font-family: inherit; + font-size: 13px; + color: var(--primary-color); + background: transparent; + border: none; + + &::placeholder { + color: var(--rd-sys-color-content-secondary); + } + + &:focus-visible { + outline: none; + } + + &::-webkit-search-cancel-button, + &::-webkit-search-decoration { + display: none; + } + } + } + + &__options { + max-height: 288px; + padding: 6px 0; + overflow-y: auto; + } + + &__option { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 14px; + font-size: 14px; + color: var(--primary-color); + cursor: pointer; + + &:hover { + background: var(--copy-btn-bg); + } + + // A long name is cut with an ellipsis; the full name is in the tooltip. + span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + input[type='checkbox'] { + position: relative; + flex-shrink: 0; + width: 16px; + height: 16px; + margin: 0; + appearance: none; + -webkit-appearance: none; + background: transparent; + border: 1.5px solid var(--rd-sys-color-border-bold); + border-radius: 3px; + cursor: pointer; + + &:checked { + background: var(--link-text-color); + border-color: var(--link-text-color); + } + + &:checked::after { + content: ''; + position: absolute; + top: 0; + left: 4px; + width: 4px; + height: 8px; + border: solid var(--rd-sys-color-content-inverse); + border-width: 0 2px 2px 0; + transform: rotate(45deg); + } + + &:focus-visible { + outline: 2px solid var(--rd-sys-color-border-focus); + outline-offset: 2px; + } + } + } + + &__empty { + padding: 10px 14px; + font-size: 14px; + color: var(--rd-sys-color-content-secondary); + } + + // No action matches: a title and a line under it. + &__empty-state { + flex-basis: 100%; + padding: 16px 0 8px; + + h4 { + margin: 0 0 8px; + font-size: 16px; + font-weight: 600; + line-height: 1.2; + color: var(--primary-color); + } + + p { + margin: 0; + font-size: 13px; + color: var(--primary-color); + } + } + + &__status { + font-size: 13px; + color: var(--primary-color); + opacity: 0.75; + } +} + +#wrapper[data-theme='dark'], +#docsModal[data-theme='dark'] { + .embed-type-filter__chip { + background: var(--rd-sys-color-background-secondary-action); + + &:hover { + background: var(--copy-btn-hover); + } + } +} diff --git a/src/components/EmbedTypeFilter/index.tsx b/src/components/EmbedTypeFilter/index.tsx new file mode 100644 index 000000000..4cf8e83e5 --- /dev/null +++ b/src/components/EmbedTypeFilter/index.tsx @@ -0,0 +1,331 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { MdClear } from '@react-icons/all-files/md/MdClear'; +import { MdFilterList } from '@react-icons/all-files/md/MdFilterList'; +import { BiSearch } from '@react-icons/all-files/bi/BiSearch'; +import { dispatchHiddenIds } from '../../utils/doc-filter-events'; +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'; + +// The name on the filter chip: "Action" with the filter icon, and "Action Select" while the +// panel is open. +const COMPONENT_NAME = 'Action'; + +// Shown in place of the list when the search and the checked embed types match no action. +const EmptyState = () => ( +
+

No results for your search

+

You can change your search and try again.

+
+); + +// The x at the right end of a search box, shown while the box has text. It clears the text and +// puts the focus back in the box. +const ClearSearch = (props: { + label: string; + onClear: () => void; +}) => ( + +); + +/* + * Filter for the generated Action reference page. The search box is on the left and the + * filter chip ("Action Select") next to it, as in the product UI guidelines. The chip opens + * a panel with its own search row and a list of checkboxes, one per embed type. A small + * x on the chip clears the selection. + * + * 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 of the page. The right-hand "on this + * page" list (Docmap) is told which sections are hidden (see utils/doc-filter-events) + * and hides those entries itself. + */ +const EmbedTypeFilter = (props: { embedTypes: string[] }) => { + const { embedTypes } = props; + const [selected, setSelected] = useState([]); + const [query, setQuery] = useState(''); + const [open, setOpen] = useState(false); + // The search row inside the panel, which narrows the list of embed types. + const [optionQuery, setOptionQuery] = useState(''); + // How many actions have no embed type yet; "Other" is only offered while there are some. + const [uncategorized, setUncategorized] = useState(0); + // True when the search and the checked embed types match no action. + const [noResults, setNoResults] = useState(false); + const rootRef = useRef(null); + const searchInputRef = useRef(null); + const optionInputRef = 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.'; + } + + const options = [...embedTypes, ...(uncategorized > 0 ? [OTHER] : [])]; + const visibleOptions = options.filter((option) => + option.toLowerCase().includes(optionQuery.trim().toLowerCase()), + ); + + // Close the panel on an outside click or Escape. + useEffect(() => { + if (!open) { + setOptionQuery(''); + return undefined; + } + const onClick = (event: MouseEvent) => { + // Use the path of the click, not event.target: opening the panel replaces the filter + // icon on the chip with "Select", so the element that was clicked is no longer on the + // page when this listener runs, and "contains" would call the click an outside one + // and close the panel again straight away. + const clickedInside = rootRef.current + ? event.composedPath().includes(rootRef.current) + : false; + if (!clickedInside) 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; + let shownCount = 0; + const hiddenIds: string[] = []; + 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) { + shownCount += 1; + return; + } + hide(section); + const id = section.querySelector('h3')?.id; + if (!id) return; + hiddenIds.push(id); + // The entry in the Index of this page. Its link ends in "#id" after the page + // path. The query stays inside the page content: the right-hand "on this + // page" list belongs to Docmap, which hides its own entries (see below). + view.querySelectorAll(`a[href$="#${id}"]`).forEach((link) => { + hide(link.closest('li')); + }); + }); + setUncategorized(uncategorizedCount); + const nothingShown = actionSections.length > 0 && shownCount === 0; + setNoResults(nothingShown); + if (nothingShown) { + // Nothing is left to list: the empty box of the Index and the heading of the + // members are hidden, so the message stands on its own. + hide( + view + .querySelector('.typedoc-index') + ?.closest('.boxDiv') || null, + ); + const membersSection = actionSections[0].closest('.sect1'); + if (membersSection && !membersSection.contains(rootRef.current)) { + hide(membersSection); + } + } + dispatchHiddenIds(hiddenIds); + + return () => { + hiddenEntries.forEach((el) => { + // eslint-disable-next-line no-param-reassign + el.style.display = ''; + }); + }; + }, [selected, query]); + + // Leaving the page: the right-hand list must not keep entries hidden. + useEffect(() => () => dispatchHiddenIds([]), []); + + return ( +
+
+ +
+ + {selected.length > 0 && ( + + )} + {open && ( +
+ +
+ {visibleOptions.map((embedType) => ( + + ))} + {!visibleOptions.length && ( +
+ No matches +
+ )} +
+
+ )} +
+
+ + {statusText} + + {noResults && } +
+ ); +}; + +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.scss b/src/components/LeftSidebar/index.scss index cff9058b2..f15e50249 100644 --- a/src/components/LeftSidebar/index.scss +++ b/src/components/LeftSidebar/index.scss @@ -227,6 +227,10 @@ .ulist { ul { padding-left: $padding-nested-ul; + // The top level list keeps its right padding. A nested list must not add its + // own, or every level ends 5px further left and its arrows (which sit at the + // right end of the row) no longer line up with the ones above. + padding-right: 0; li { margin: 3px 0; p { @@ -323,6 +327,8 @@ .forwardArrowIcon { height: 13px; width: 13px; + // The same box width as the down arrow (16px), so the two arrows share a centre line. + margin: 0 1.5px; position: relative; top: 2px; } 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: {