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/deprecated-features.adoc b/modules/ROOT/pages/deprecated-features.adoc index c4b7310db..e56bc0199 100644 --- a/modules/ROOT/pages/deprecated-features.adoc +++ b/modules/ROOT/pages/deprecated-features.adoc @@ -30,7 +30,7 @@ a|xref:deprecated-features.adoc#v1-v2-exp-fullApp-embed[V1 and V2 UI experience a|xref:deprecated-features.adoc#PNGFlowDeprecation[Select PNG export options] a|ThoughtSpot Cloud 26.4.0.cl and later | April 2026 | August 2026 a|xref:deprecated-features.adoc#variableApis[Variable APIs] a|ThoughtSpot Cloud 26.4.0.cl and later | April 2026 | October 2026 a|xref:deprecated-features.adoc#metadataParameterization[Metadata parameterization] a|ThoughtSpot Cloud 26.4.0.cl and later | April 2026 | October 2026 -a|xref:deprecated-features.adoc#SagePrivilegeDeprecation[PREVIEW_THOUGHTSPOT_SAGE] + +a|xref:deprecated-features.adoc#SagePrivilegeDeprecation[`PREVIEW_THOUGHTSPOT_SAGE`] + privilege a|ThoughtSpot Cloud 26.3.0.cl and later | March 2026 | September 2026 a|xref:deprecated-features.adoc#_answer_data_panel_classic_experience_deprecation[Answer Data panel classic experience] |ThoughtSpot Cloud 26.4.0.cl and later | April 2026 | August 2026 a|xref:deprecated-features.adoc#_worksheet_deprecation_and_removal[Worksheets] a| ThoughtSpot Cloud 10.4.0.cl and later @@ -217,15 +217,15 @@ If you are using the legacy API endpoint, update your workflows to use the new A For more information, see xref:metadata-parameterization.adoc[Metadata parameterization documentation]. [#SagePrivilegeDeprecation] -== `PREVIEW_THOUGHTSPOT_SAGE` privilege deprecation -The `PREVIEW_THOUGHTSPOT_SAGE` privilege is renamed to `CAN_USE_SPOTTER` with the ThoughtSpot 26.3.0.cl release version. +== Sage privilege deprecation +The `PREVIEW_THOUGHTSPOT_SAGE` privilege was renamed to `CAN_USE_SPOTTER` (**Can use Spotter**) in ThoughtSpot 26.3.0.cl. Impact on your instance:: -* Both privileges remain supported until the ThoughtSpot 26.9.0.cl release, at which point `PREVIEW_THOUGHTSPOT_SAGE` will be removed. -* For existing ThoughtSpot instances which have enabled RBAC before the 26.3.0.cl release, there will be no automatic changes to the roles created using the `PREVIEW_THOUGHTSPOT_SAGE` privilege. However, the underlying privilege associated with such roles will be renamed to `CAN_USE_SPOTTER`. +* Both privileges are supported until ThoughtSpot 26.9.0.cl. +* Roles created with `PREVIEW_THOUGHTSPOT_SAGE` on instances that enabled RBAC before 26.3.0.cl are not modified automatically. However, the underlying privilege associated with these roles is renamed to `CAN_USE_SPOTTER`. Recommended action:: -For ThoughtSpot instances which have enabled RBAC before the 26.3.0.cl release, the admins will have to create a role in accordance with the newer privilege name. +Administrators of instances that enabled RBAC before 26.3.0.cl must create roles that use the `CAN_USE_SPOTTER` privilege. For more information about roles and privileges, see xref:roles.adoc[Role-based access control]. [#_answer_data_panel_classic_experience_deprecation] == Answer Data panel classic experience deprecation 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-event-error-best-practices.adoc b/modules/ROOT/pages/embed-event-error-best-practices.adoc index 36c89620e..711d51012 100644 --- a/modules/ROOT/pages/embed-event-error-best-practices.adoc +++ b/modules/ROOT/pages/embed-event-error-best-practices.adoc @@ -9,79 +9,116 @@ [IMPORTANT] ==== -In the upcoming ThoughtSpot Cloud 26.10.0.cl and Visual Embed SDK 1.53.0 versions, `EmbedEvent.Error` will include a `severity` field that categorizes errors into three severity levels: `SEV1`, `SEV2`, and `SEV3`. +In Visual Embed SDK v1.53.0 and later, `EmbedEvent.Error` includes a `severity` field that categorizes errors into three severity levels: `SEV1`, `SEV2`, and `SEV3`. If your app handles all errors the same way, you may want to update your integrations to distinguish between critical errors (`SEV1`) and less severe errors (`SEV2`, `SEV3`). -ThoughtSpot recommends reviewing your error handing logic to prepare your integrations for this change. If your application currently handles all errors uniformly, you may need to update your error handling to distinguish between critical errors (SEV1) that require tearing down the embed and less severe errors (SEV2, SEV3) that can be logged or ignored. - -//For more information, see the xref:embed-event-error-best-practices.adoc#_error_severity_levels[Error severity levels] section. +For more information, see xref:embed-event-error-best-practices.adoc#_error_severity_levels[Error severity levels]. ==== -The ThoughtSpot Visual Embed SDK provides a layered approach to error -handling. `EmbedEvent.Error` is a real-time outbound event emitted from the embedded ThoughtSpot iframe to the host application, signaling that something has gone wrong, either inside the SDK itself, inside the embedded app (the iframe), or as a result of an invalid HostEvent payload sent by the host. +The ThoughtSpot Visual Embed SDK provides a layered approach to error handling. `EmbedEvent.Error` is a real-time outbound event that the embedded ThoughtSpot iframe emits to the host app. It signals that something went wrong inside the SDK, inside the embedded app (the iframe), or because the host sent an invalid `HostEvent` payload. -Unlike most embed events, which are cached and can be replayed on late subscribers, `EmbedEvent.Error` is not cached or replayed. Every emission is independent, and a handler must be registered before an error occurs to receive it. +Unlike most embed events, which the SDK caches and replays to late subscribers, `EmbedEvent.Error` is not cached or replayed. Every emission is independent, so you must register a handler before an error occurs. -`EmbedEvent.Error` fires on a per-embed-component basis. Any handler must be registered before `render()` is called. +`EmbedEvent.Error` fires per embed component. Register the handler before you call `render()`. == Trigger sources + Embed errors surface through distinct channels for the following failure categories: * xref:embed-event-error-best-practices.adoc#_sdk_or_configuration_validation[SDK or configuration validation] * xref:embed-event-error-best-practices.adoc#_hostevent_validation[HostEvent validation errors] -* xref:embed-event-error-best-practices.adoc#_runtime_filter_or_cross_filter_errors[Runtime filter validation errors] +* xref:embed-event-error-best-practices.adoc#_runtime_filter_or_cross_filter_errors[Runtime filter or cross-filter errors] * xref:embed-event-error-best-practices.adoc#_network_events[Network events] === SDK or configuration validation -When SDK or configuration errors occur, the internal `handleError(error)` method in the embed component: +When SDK or configuration errors occur, the internal `handleError(errorDetails)` method in the embed component: -1. Sets `isError = true` and permanently blocks future `renderIFrame` calls on this instance. -2. Calls `executeCallbacks(EmbedEvent.Error, { error })`. -3. Logs via `logger.error(error)`. +1. Sets `isError = true`, which permanently blocks future `renderIFrame` calls on this instance. +2. Calls `executeCallbacks(EmbedEvent.Error, { severity: SEV3, ...errorDetails })`. The default severity is `SEV3` unless the error sets its own. +3. Logs via `logger.error(errorDetails)`. -[cols="2,3", options="header"] +[cols="3,2,1,4", options="header"] |=== -| Condition | Error message emitted +| Condition | `code` | `severity` | `message` emitted | `init()` was not called before `render()` -| `"Looks like render was called before calling init..."` +| `INIT_ERROR` +| `SEV1` +| `"You need to init the ThoughtSpot SDK module first"` + +| `liveboardId` is missing in `LiveboardEmbed` +| `LIVEBOARD_ID_MISSING` +| `SEV1` +| `"Please select a Liveboard to embed."` | Both `hiddenActions` and `visibleActions` passed +| `CONFLICTING_ACTIONS_CONFIG` +| `SEV3` | `"You cannot have both hidden actions and visible actions"` | Both `hiddenTabs` and `visibleTabs` passed +| `CONFLICTING_TABS_CONFIG` +| `SEV3` | `"You cannot have both hidden Tabs and visible Tabs"` | `trigger()` called before `render()` +| `RENDER_NOT_CALLED` +| `SEV3` | `"Please call render before triggering events"` | `trigger()` called with undefined event type +| `HOST_EVENT_TYPE_UNDEFINED` +| `SEV3` | `"Host event type is undefined"` | Auth failure during `renderIFrame` -| The caught auth/login error object +| `LOGIN_FAILED` +| `SEV1` +| The message of the caught auth or login error, or `"Login failed"`. The `error` field contains the caught error object. |=== +The SDK emits these errors with the following payload: + +[source,json] +---- +{ + "errorType": "VALIDATION_ERROR", + "message": "You cannot have both hidden actions and visible actions", + "code": "CONFLICTING_ACTIONS_CONFIG", + "severity": 3, + "error": "You cannot have both hidden actions and visible actions" +} +---- + +The `error` field is retained for backward compatibility. For more information about the payload fields, see xref:EmbedErrorDetailsEvent.adoc[EmbedErrorDetailsEvent]. + === HostEvent validation -When the host sends a HostEvent with an invalid payload or a context mismatch, the event bridge validates it against the HostEvent schema. On failure, `dispatchValidationError()` is triggered to emit `EmbedEvent.Error` with code `embedErrorCodes.HOST_EVENT_VALIDATION`. +When the host sends a HostEvent with an invalid payload or a context mismatch, the event bridge validates it against the HostEvent schema. On failure, the SDK emits `EmbedEvent.Error` with code `embedErrorCodes.HOST_EVENT_VALIDATION`. The `trigger()` call also rejects with the validation error. [source,json] ---- { - "type": "ValidationError", - "code": "HOST_EVENT_VALIDATION", - "message": "" + "type": "Error", + "data": { + "errorType": "VALIDATION_ERROR", + "message": "", + "code": "HOST_EVENT_VALIDATION", + "error": "" + }, + "status": "end", + "severity": 3 } ---- [IMPORTANT] ==== -If `shouldBypassPayloadValidation: true`, validation failures are silently logged as console warnings and never reach your `EmbedEvent.Error` handler. +If `useHostEventsV2` is not enabled, or `shouldBypassPayloadValidation` is `true`, the SDK logs validation failures as console warnings. They never reach your `EmbedEvent.Error` handler. ==== === Runtime filter or cross-filter errors + Filter operations can fail inside the iframe with typed errors: [cols="2,3", options="header"] @@ -127,104 +164,121 @@ Filter operations can fail inside the iframe with typed errors: === Network events -A global `window.offline` listener emits `EmbedEvent.Error` with a different payload shape: +A global listener for the browser `offline` event on `window` emits `EmbedEvent.Error` with a different payload shape: [source,json] ---- { + "errorType": "NETWORK", + "message": "Network not Detected. Embed is offline. Please reconnect and refresh", + "code": "NETWORK_ERROR", + "severity": 2, "offlineWarning": "Network not Detected. Embed is offline. Please reconnect and refresh" } ---- == Error types -The `EmbedEvent.Error` is fired when the following types of errors occur: +`EmbedEvent.Error` fires when the following types of errors occur: [cols="2,3", options="header"] |=== -|Error type| Description -|`API` a| API call failure error. This error event occurs when an API request is blocked. +| Error type | Description + +| `API` +a| API call failure. This error occurs when an API request is blocked. + Example: -[source,JavaScript] + +[source,javascript] ---- -SearchEmbed.on(EmbedEvent.Error, (error) => { +searchEmbed.on(EmbedEvent.Error, (error) => { console.log(error); // { type: "Error", data: { errorType: "API", error: { message: '...', error: '...' } } } }); ---- -|`FULLSCREEN` a| Error in presenting a Liveboard or visualization in full-screen mode. -[source,JavaScript] +| `FULLSCREEN` +a| Error in presenting a Liveboard or visualization in full-screen mode. + +[source,javascript] ---- -LiveboardEmbed.on(EmbedEvent.Error, (error) => { +liveboardEmbed.on(EmbedEvent.Error, (error) => { console.log(error); // { type: "Error", data: { errorType: "FULLSCREEN", error: { // message: "Fullscreen mode is not available", // } }} -}) +}); ---- -|`SINGLE_VALUE_FILTER` a| Error in updating filter values. This error occurs when a single-value filter is applied to a Liveboard and the user tries to update this filter with multiple values. -[source,JavaScript] +| `SINGLE_VALUE_FILTER` +a| Error in updating filter values. This error occurs when a single-value filter is applied to a Liveboard and the user tries to update this filter with multiple values. + +[source,javascript] ---- -LiveboardEmbed.on(EmbedEvent.Error, (error) => { +liveboardEmbed.on(EmbedEvent.Error, (error) => { console.log(error); // { type: "Error", data: { errorType: "SINGLE_VALUE_FILTER", error: { - // message: "Filter {filter_name}: Cannot pass multiple filtering elements to this single value filter.", + // message: "Filter {filter_name}: Cannot pass multiple filtering elements to this single value filter.", // } }} -}) +}); ---- -|`NON_EXIST_FILTER` a| Error in applying filter due to a non-existent filter. -[source,JavaScript] +| `NON_EXIST_FILTER` +a| Error in applying filter due to a non-existent filter. + +[source,javascript] ---- -LiveboardEmbed.on(EmbedEvent.Error, (error) => { +liveboardEmbed.on(EmbedEvent.Error, (error) => { console.log(error); // { type: "Error", data: { errorType: "NON_EXIST_FILTER", error: { - // message: "UpdateFilters could not update the filter on {filter_name} as it is not an existing filter in the Liveboard. Please edit the Liveboard and add {filter_name} as a filter chip in order to update it programmatically.", + // message: "UpdateFilters could not update the filter on {filter_name} as it is not an existing filter in the Liveboard. Please edit the Liveboard and add {filter_name} as a filter chip in order to update it programmatically.", // } }} -}) +}); ---- -|`INVALID_DATE_VALUE` a| Error due to an invalid date value in a filter. For example, if the column name is `Commit Date` and a correct date value is not specified, the `INVALID_DATE_VALUE` error event is fired. -[source,JavaScript] +| `INVALID_DATE_VALUE` +a| Error due to an invalid date value in a filter. For example, if the column name is `Commit Date` and a correct date value is not specified, the `INVALID_DATE_VALUE` error event is fired. + +[source,javascript] ---- -LiveboardEmbed.on(EmbedEvent.Error, (error) => { +liveboardEmbed.on(EmbedEvent.Error, (error) => { console.log(error); // { type: "Error", data: { errorType: "INVALID_DATE_VALUE", error: { - // message: "UpdateFilters could not update the filter on {filter_name} as invalid date value provided.", + // message: "UpdateFilters could not update the filter on {filter_name} as invalid date value provided.", // } }} -}) +}); ---- -|`INVALID_OPERATOR` a|Error due to an invalid operator in filter properties. For example, if you try to apply filters on the `Revenue` column with the operator as `LT` and specify multiple values, it may result in an error. -[source,JavaScript] +| `INVALID_OPERATOR` +a| Error due to an invalid operator in filter properties. For example, if you try to apply filters on the `Revenue` column with the operator as `LT` and specify multiple values, it may result in an error. + +[source,javascript] ---- -LiveboardEmbed.on(EmbedEvent.Error, (error) => { +liveboardEmbed.on(EmbedEvent.Error, (error) => { console.log(error); // { type: "Error", data: { errorType: "INVALID_OPERATOR", error: { - // message: "UpdateFilters could not update the filter on {filter_name} as invalid operator value provided.", + // message: "UpdateFilters could not update the filter on {filter_name} as invalid operator value provided.", // } }} -}) +}); ---- |=== -//// [#_error_severity_levels] == Error severity levels -Starting from ThoughtSpot Cloud 26.10.0.cl (Visual Embed SDK 1.53.0), the `EmbedEvent.Error` payload includes a `severity` field that categorizes errors into three levels: +The `EmbedEvent.Error` payload includes a `severity` field that categorizes errors into three levels: [cols="1,2,3", options="header"] |=== | Severity | Description | Recommended handling -| `SEV1` | The embed is down: the app crashed and a fallback page is showing. | Consider tearing down the embed and re-initializing, or showing a custom error message to the user. +| `SEV1` | The embed is down: nothing usable is on screen, for example, because of an SDK initialization failure, a login failure, or an app crash. | Consider tearing down the embed and re-initializing, or showing a custom error message to the user. | `SEV2` | An error was shown to the user; the embed still works. | Log the error for monitoring. No immediate action is required unless the error indicates a persistent issue. | `SEV3` | Everything else, including validation of the embedder's own input. | Log the error for debugging. These errors are typically not user-facing and can often be ignored if the application is functioning correctly. |=== -The `severity` field is included in the error payload as an optional integer value: +The `severity` field is an optional numeric value that maps to the xref:EmbedErrorSeverity.adoc[EmbedErrorSeverity] enum: `SEV1` is `1`, `SEV2` is `2`, and `SEV3` is `3`. If an error does not set a severity, the SDK defaults to `SEV3`. [source,json] ---- @@ -244,26 +298,47 @@ The `severity` field is included in the error payload as an optional integer val ==== The `severity` field is backward compatible. If your application does not use the `severity` field, the error handling behavior remains unchanged. ==== -//// + == Payload shapes +`EmbedEvent.Error` emits one of the following payload shapes: + [source,typescript] ---- // SDK / auth error -{ error: string | Record } +{ + errorType: string; + message: string | string[]; + code: string; + severity?: number; + error?: unknown; // retained for backward compatibility +} // HostEvent validation error -{ type: string; code: string; message: string } +{ + type: 'Error'; + data: { errorType: string; message: string; code: 'HOST_EVENT_VALIDATION'; error: string }; + status: string; + severity?: number; +} // Filter / runtime error (from embedded app) // — matches one of the eventErrorType patterns above // Network offline -{ offlineWarning: string } +{ + errorType: 'NETWORK'; + message: string; + code: 'NETWORK_ERROR'; + severity: number; + offlineWarning: string; +} ---- == How to subscribe +The following example shows how to handle each `EmbedEvent.Error` payload shape. + [source,typescript] ---- import { LiveboardEmbed, EmbedEvent } from '@thoughtspot/visual-embed-sdk'; @@ -277,93 +352,70 @@ embed.on(EmbedEvent.Error, (payload) => { // SDK/auth error if (payload?.error) { - console.error('[SDK Error]', payload.error); + console.error('[SDK Error]', payload.code, payload.error); return; } // HostEvent validation error (your payload is malformed) - if (payload?.code === 'HOST_EVENT_VALIDATION') { - console.error('[HostEvent validation failed]', payload.message); + if (payload?.data?.code === 'HOST_EVENT_VALIDATION') { + console.error('[HostEvent validation failed]', payload.data.message); return; } - // runtime filter/action error from the iframe - console.warn('[Embed runtime error]', payload); - // network offline if (payload?.offlineWarning) { showOfflineBanner(payload.offlineWarning); return; } + + // runtime filter/action error from the iframe + console.warn('[Embed runtime error]', payload); }); embed.render(); ---- +== Handling HostEvent errors - -//// -=== Recovery after an error - -Once `handleError` fires, the embed instance sets `isError = true` and all future -`renderIFrame` calls are silently skipped. To recover, you must destroy the existing -instance and create a new one: - -[source,typescript] ----- -embed.on(EmbedEvent.Error, async (payload) => { - logError(payload); - - if (isRecoverable(payload)) { - embed.destroy(); // removes window listeners and DOM node - - const freshEmbed = new LiveboardEmbed('#container', viewConfig); - freshEmbed.on(EmbedEvent.Error, errorHandler); - await freshEmbed.render(); - } -}); ----- -//// - -== Handling errors -The following example shows the sample code for handling errors. +The following example shows how to handle errors from a `trigger()` call. [source,typescript] ---- import { HostEvent } from '@thoughtspot/visual-embed-sdk'; -try { - const result = await embed.trigger(HostEvent.UpdateRuntimeFilters, [ - { columnName: 'Region', operator: 'EQ', values: ['West'] }, - ]); - - // Timeout is a resolve, not a reject — check explicitly - if (result instanceof Error) { - console.error('HostEvent timed out:', result.message); - return; +async function updateRegionFilter() { + try { + const result = await embed.trigger(HostEvent.UpdateRuntimeFilters, [ + { columnName: 'Region', operator: 'EQ', values: ['West'] }, + ]); + + // Timeout is a resolve, not a reject — check explicitly + if (result instanceof Error) { + console.error('HostEvent timed out:', result.message); + return; + } + + console.log('HostEvent succeeded:', result); + } catch (err) { + // Rejection = iframe returned an explicit error in the response + console.error('HostEvent rejected by iframe:', err); } - - console.log('HostEvent succeeded:', result); - -} catch (err) { - // Rejection = iframe returned an explicit error in the response - console.error('HostEvent rejected by iframe:', err); } ---- === Error handling guidelines [cols="2,4", options="header"] -|===== +|=== | Error category | Detection and recovery | SDK init config error -a|**Detection mechanism**: `EmbedEvent.Error` + +a|**Detection mechanism**: `EmbedEvent.Error` -**Payload/data**: `{ error: string }` +**Payload/data**: `{ errorType, message, code, severity, error }` -**Recovery action**: Fix configuration. Recreate and render embed. +**Recovery action**: Fix the configuration. xref:embed-event-error-best-practices.adoc#destroy-embed[Destroy] the embed, then create and render a new instance. | Auth failure (SDK layer) a| @@ -376,9 +428,9 @@ a| | Auth failure (iframe layer) a| -**Detection mechanism**: `EmbedEvent.AuthFailure` + +**Detection mechanism**: `EmbedEvent.AuthFailure` -**Recovery action**: Show login prompt. Recreate and render embed. +**Recovery action**: Show a login prompt. xref:embed-event-error-best-practices.adoc#destroy-embed[Destroy] the embed, then create and render a new instance. | Session expired @@ -392,7 +444,7 @@ a|**Detection mechanism**: `EmbedEvent.IdleSessionTimeout` and `AuthStatus.FAILU **Recovery action**: Use `autoLogin: true`. Prompt re-authentication. -|Third-party cookies blocked +| Third-party cookies blocked a|**Detection mechanism**: `EmbedEvent.NoCookieAccess` and `AuthStatus.FAILURE(NO_COOKIE_ACCESS)` @@ -401,7 +453,7 @@ a|**Detection mechanism**: `EmbedEvent.NoCookieAccess` and `AuthStatus.FAILURE(N | User logout a|**Detection mechanism**: `EmbedEvent.AuthLogout` and `AuthStatus.LOGOUT` -**Recovery action**: Redirect to login or clear state +**Recovery action**: Redirect to login or clear state. | Duplicate token a|**Detection mechanism**: `AuthStatus.FAILURE(SDK)` and `alert()` (suppressible) @@ -418,32 +470,32 @@ a|**Detection mechanism**: `AuthStatus.FAILURE(SDK)` and `logger.error` **Recovery action**: Verify token generation on the server. | Network offline -a| **Detection mechanism**: `EmbedEvent.Error` +a|**Detection mechanism**: `EmbedEvent.Error` -**Payload/data**: `{ offlineWarning: string }` +**Payload/data**: `{ errorType: "NETWORK", code: "NETWORK_ERROR", offlineWarning: string }` **Recovery action**: Show offline UI. Reconnect and reload. | HostEvent validation error a|**Detection mechanism**: `EmbedEvent.Error` -**Payload/data**: `{ type, code: "HOST_EVENT_VALIDATION", message }` +**Payload/data**: `{ type, data: { code: "HOST_EVENT_VALIDATION", message } }` -**Recovery action**: Fix the `HostEvent` payload; enable `useHostEventsV2: true` in development +**Recovery action**: Fix the `HostEvent` payload. Enable `useHostEventsV2: true` in development. | HostEvent timeout (30 seconds) a|**Detection mechanism**: `trigger()` resolves with `Error` **Payload/data**: `Error("Trigger timed-out...")` -**Recovery action**: Check if iframe is still mounted. Call `reload()` if needed +**Recovery action**: Check if the iframe is still mounted. Call `reload()` if needed. | HostEvent iframe rejection a|**Detection mechanism**: `trigger()` rejects **Payload/data**: Error from iframe -**Recovery action**: Inspect the rejection payload; see HostEvent validation error above +**Recovery action**: Inspect the rejection payload. See the HostEvent validation error row. | Filter errors at runtime a|**Detection mechanism**: `EmbedEvent.Error` @@ -451,22 +503,107 @@ a|**Detection mechanism**: `EmbedEvent.Error` **Payload/data**: `eventErrorType` message string **Recovery action**: Fix filter parameters. Validate against current Liveboard/answer filters. -|===== +|=== + +[#destroy-embed] +== Destroying an embed instance + +To remove an embedded ThoughtSpot component, for example, during route changes, error recovery, or cleanup in single-page apps, call `embed.destroy()`. This is the only supported way to tear down an embed instance. + +Calling `embed.destroy()` performs the following cleanup steps in order: + +1. Removes fullscreen change handlers. +2. Disconnects the resize observer. +3. Unsubscribes window-level event listeners registered by the embed. +4. Removes any pre-render wrappers created during initialization. +5. Sends an internal notification to the embedded iframe so that it can release its own resources. +6. Removes the iframe DOM node from the page. + +If the embed has not rendered yet, `destroy()` skips steps 5 and 6. + +After `destroy()` completes, the embed instance is no longer usable. To re-embed, create a new instance and call `render()` again. + +After an SDK or configuration error, the embed instance blocks all further `render()` calls. To recover, destroy the instance, fix the cause, and create a new one. + +=== Example: Destroy and re-create + +[source,TypeScript] +---- +import { + LiveboardEmbed, + HostEvent, + EmbedEvent, +} from '@thoughtspot/visual-embed-sdk'; + +// Create and render the embed +const embed = new LiveboardEmbed(document.getElementById('embed-container'), { + liveboardId: 'd084c256-e284-4fc4-b80c-111111111111', +}); +embed.render(); + +// Later, tear it down +embed.destroy(); + +// To re-embed, create a new instance +const newEmbed = new LiveboardEmbed(document.getElementById('embed-container'), { + liveboardId: 'd084c256-e284-4fc4-b80c-111111111111', +}); +newEmbed.render(); +---- + +=== Wait for iframe cleanup + +By default, `destroy()` sends the internal teardown notification to the iframe and removes the DOM node immediately, without waiting for the iframe to finish its own cleanup. + +If your application requires the iframe to complete its cleanup before the DOM node is removed, set `waitForCleanupOnDestroy` to `true` in the `init()` configuration: + +[source,typescript] +---- +import { init, AuthType } from '@thoughtspot/visual-embed-sdk'; + +init({ + thoughtSpotHost: 'https://.thoughtspot.cloud', + authType: AuthType.TrustedAuthTokenCookieless, + // ...other init options + waitForCleanupOnDestroy: true, + cleanupTimeout: 5000, // milliseconds; optional +}); +---- + +[cols="2,1,3", options="header"] +|=== +| Parameter | Default | Description + +| `waitForCleanupOnDestroy` +| `false` +| When `true`, `destroy()` waits for the embedded app to acknowledge the teardown notification before removing the iframe from the DOM. + +| `cleanupTimeout` +| `5000` +| The maximum time, in milliseconds, that `destroy()` waits for the embedded app's cleanup response when `waitForCleanupOnDestroy` is `true`. If the timeout is reached, the iframe is removed regardless. +|=== + +[WARNING] +==== +Do not call `embed.trigger(HostEvent.DestroyEmbed)` directly. The SDK triggers this event for you when you call `embed.destroy()`. Triggering it directly throws a `TypeError` and does not tear down the embed or remove the iframe. +==== == Best practices * Always register an event listener for `EmbedEvent.Error` on your embed instance to capture and handle errors emitted by the embedded ThoughtSpot application. Register `EmbedEvent.Error` before calling `render()`. -* Inspect and categorize error payloads. The error event provides a payload with fields such as errorType, message, and code. Do not assume that `payload.error` always exists in `EmbedEvent.Error`. Offline errors use `offlineWarning`; validation errors use `code`. -* Do not destroy the embed on errors. First, inspect the errors to determine if you need to display host fallback pages. Not all events are fatal. ThoughtSpot handles some failures internally, so only take action if necessary based on the specific error and event type. -* Provide contextual and user-friendly error messages based on the errorType and error details. Avoid exposing raw error objects or stack traces to your application users. +* Inspect and categorize error payloads. The error event provides a payload with fields such as `errorType`, `message`, and `code`. Do not assume that `payload.error` always exists. Offline errors use `offlineWarning`, and validation errors use `code`. +* Do not destroy the embed for every error. Inspect the error first to determine whether you need to display a host fallback page. Not all errors are fatal, and ThoughtSpot handles some failures internally. Destroy the embed only when the error leaves it unusable. For more information, see xref:embed-event-error-best-practices.adoc#destroy-embed[Destroying an embed instance]. +* Call `embed.destroy()` when you remove the embed container, navigate away from the page, or unmount the component. Do not reuse a destroyed instance. Create a new one instead. +* Provide contextual and user-friendly error messages based on the `errorType` and error details. Avoid exposing raw error objects or stack traces to your application users. * Enable `useHostEventsV2: true` in development/staging to surface `HostEvent` payload errors early. * Log errors for diagnostics. -* Simulate different error scenarios during development. For example, network failures and invalid filters to ensure the error handling logic works as expected. +* Simulate error scenarios during development, such as network failures and invalid filters, to verify that your error handling logic works as expected. * Do not set `shouldBypassPayloadValidation: true` in production environments. Validation errors become invisible console warnings. -* Do not call `trigger()` before `render()`; calling it before `render()` returns `null`. +* Do not call `trigger()` before `render()`. The SDK emits an `EmbedEvent.Error` with the `RENDER_NOT_CALLED` code and returns `null`. == Try it out in the Visual Embed Playground -Try out the embed events in the +++ Visual Embed Playground +++ and preview changes. + +Try out the embed events in the +++Visual Embed Playground+++ and preview changes. [.widthAuto] [.bordered] @@ -476,5 +613,3 @@ image::./images/embed-event-playground.png[Try Embed events in Playground] * To identify and log errors, use the error types and error codes. For more information, see xref:EmbedErrorDetailsEvent.adoc[EmbedErrorDetailsEvent] and xref:EmbedErrorCodes.adoc[EmbedErrorCodes]. * For information about the supported event objects and examples, see xref:EmbedEvent.adoc[EmbedEvent]. - - 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/home.adoc b/modules/ROOT/pages/home.adoc index f0f150e82..f6e036d4a 100644 --- a/modules/ROOT/pages/home.adoc +++ b/modules/ROOT/pages/home.adoc @@ -7,17 +7,9 @@ ++++
-
-
-
-
-
-
-
-
-
-
-
+
+

ThoughtSpot Embedded Documentation

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/modules/tutorials/pages/guided-walkthroughs.adoc b/modules/tutorials/pages/guided-walkthroughs.adoc index 600ff32a1..c7edf4496 100644 --- a/modules/tutorials/pages/guided-walkthroughs.adoc +++ b/modules/tutorials/pages/guided-walkthroughs.adoc @@ -5,17 +5,11 @@ [.non-link] ++++ -
-
-
-
-
-
-
-
-
-
-
+
+

Quickstarts

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/fonts/BB-Roller-Mono-Regular.otf b/src/assets/fonts/BB-Roller-Mono-Regular.otf new file mode 100755 index 000000000..f9d13b234 Binary files /dev/null and b/src/assets/fonts/BB-Roller-Mono-Regular.otf differ diff --git a/src/assets/styles/index.scss b/src/assets/styles/index.scss index 215d72805..1495365e1 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); @@ -1171,94 +1229,6 @@ a.anchor { mask-position: -136px -68px; } } -// ── Radiance blob gradient — confined to the header banner only ──────────── -.radiance-bg { - position: absolute; - inset: 0; - z-index: 0; - overflow: hidden; - pointer-events: none; - background-color: var(--header-banner-base); - &::after { - content: ''; - position: absolute; - inset: 0; - z-index: 1; - background: linear-gradient( - to bottom, - transparent 0%, - transparent 40%, - color-mix(in srgb, var(--header-banner-base) 20%, transparent) 55%, - color-mix(in srgb, var(--header-banner-base) 45%, transparent) 68%, - color-mix(in srgb, var(--header-banner-base) 75%, transparent) 82%, - var(--header-banner-base) 96%, - var(--header-banner-base) 100% - ); - } - - .blob { - position: absolute; - border-radius: 50%; - filter: blur(60px); - } - - // Light mode blobs - .blob-pink { - width: 72%; - height: 85vh; - left: -14%; - top: -18vh; - // Toned down from 0.44 — read as too vivid/rose against the reference. - background: radial-gradient(ellipse at center, rgba(255, 93, 142, 0.32) 0%, transparent 68%); - animation: drift-a 6s ease-in-out infinite alternate; - } - - .blob-purple { - width: 55%; - height: 70vh; - left: 4%; - top: 35vh; - background: radial-gradient(ellipse at center, rgba(138, 93, 255, 0.46) 0%, transparent 66%); - animation: drift-b 7s ease-in-out infinite alternate; - } - - .blob-blue { - width: 70%; - height: 80vh; - right: -14%; - top: -15vh; - background: radial-gradient(ellipse at center, rgba(39, 140, 255, 0.42) 0%, transparent 68%); - animation: drift-c 5s ease-in-out infinite alternate; - } - - .blob-wash-pink { - width: 90%; - height: 100vh; - left: -5%; - top: 0; - background: radial-gradient(ellipse at 35% 50%, rgba(255, 170, 210, 0.09) 0%, transparent 70%); - animation: drift-d 10s ease-in-out infinite alternate; - } - - .blob-wash-blue { - width: 90%; - height: 100vh; - right: -5%; - top: 0; - background: radial-gradient(ellipse at 65% 50%, rgba(160, 196, 255, 0.12) 0%, transparent 70%); - animation: drift-d 11s ease-in-out infinite alternate; - animation-delay: -4s; - } - - // Dark-only blobs — hidden in light mode - .blob-orange, - .blob-yellow, - .blob-purple-r, - .blob-teal { - display: none; - } -} - .header-banner { position: relative; min-height: 240px; @@ -1301,56 +1271,6 @@ a.anchor { padding-top: 32px; } - .radiance-bg { - position: absolute; - inset: -20px -60px 0 -60px; - - @media (max-width: $mobile-resolution-max) { - // .doc-wrapper-detail's padding (including its 20px top) drops - // to 0 at this breakpoint, so there's no padding left to bleed - // under — inset:0 here, not the desktop offsets above. - inset: 0; - } - - &::after { - background: linear-gradient( - to bottom, - transparent 0px, - transparent 76px, - color-mix(in srgb, var(--header-banner-base) 20%, transparent) 104px, - color-mix(in srgb, var(--header-banner-base) 45%, transparent) 129px, - color-mix(in srgb, var(--header-banner-base) 75%, transparent) 156px, - var(--header-banner-base) 182px, - var(--header-banner-base) 100% - ); - } - - // pink/purple/blue are home's values (tuned for its taller banner — - // h1 + search bar — where their cores sit ~200px down). - .blob-pink { - top: -31vh; - background: radial-gradient(ellipse at center, rgba(255, 93, 142, 0.16) 0%, transparent 68%); - } - - .blob-blue { - top: -29vh; - background: radial-gradient(ellipse at center, rgba(39, 140, 255, 0.20) 0%, transparent 68%); - } - - .blob-purple { - top: -24vh; - background: radial-gradient(ellipse at center, rgba(138, 93, 255, 0.22) 0%, transparent 66%); - } - - .blob-wash-pink { - background: radial-gradient(ellipse at 35% 50%, rgba(255, 170, 210, 0.06) 0%, transparent 70%); - } - - .blob-wash-blue { - background: radial-gradient(ellipse at 65% 50%, rgba(160, 196, 255, 0.08) 0%, transparent 70%); - } - } - // Guarantees the card grid paints above the gradient layer regardless // of stacking order — harmless once the fade completes well above it, // but avoids relying on that timing. @@ -1360,6 +1280,20 @@ a.anchor { } } +// Hero box for the Radiance "home" pattern (radiance-home.css), the same gradient as the home +// hero and the 404. 336px at the top of the page; the recipe bleeds its field past this box and +// fades it out, and the card grid below stays above it (see .document-body-wrapper above). +.walkthroughs-radiance { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 336px; + z-index: 1; + isolation: isolate; + pointer-events: none; +} + // Total gap from the top of the page to the first card row is now just // this padding-bottom + .tutorial-cards-row's own margin-top (24px, // index.scss) — no more artificial min-height holding them apart, which is @@ -1385,175 +1319,6 @@ a.anchor { } } -#wrapper[data-theme='dark'] .radiance-bg, -#docsModal[data-theme='dark'] .radiance-bg { - // Light-only blobs — hidden in dark mode - .blob-pink, - .blob-purple { - display: none; - } - - .blob-orange { - display: block; - width: 65%; - height: 80vh; - // Shifted in from the corner (-10%) toward center-left so the warm - // glow reads as centered on the left half, not hugging the edge. - left: -2%; - top: 8vh; - // Toned down slightly (0.28 -> 0.22) so it stays a subtle accent - // and doesn't compete with the purple emphasis at center. - background: radial-gradient(ellipse at center, rgba(255, 180, 90, 0.22) 0%, transparent 68%); - animation: drift-a 6s ease-in-out infinite alternate; - } - - .blob-yellow { - display: block; - width: 48%; - height: 58vh; - left: 8%; - top: -8vh; - background: radial-gradient(ellipse at center, rgba(255, 232, 150, 0.08) 0%, transparent 65%); - animation: drift-d 8s ease-in-out infinite alternate; - } - - // Same size as light — different position + animation delay - .blob-blue { - width: 55%; - height: 65vh; - // Pulled back in (right: -4% -> 6%) so its center sits closer to - // purple-r's — with two separate radial falloffs, centers too far - // apart leave a dim/desaturated "valley" between them instead of a - // smooth hand-off (that's what didn't match the reference). The - // wider falloff (62% -> 70%) also softens/extends its own tail so - // it carries color further into the middle. - right: 6%; - top: -10vh; - background: radial-gradient(ellipse at center, rgba(39, 140, 255, 0.34) 0%, transparent 70%); - animation-delay: -3s; - } - - .blob-purple-r { - display: block; - // Widened further (62% -> 68%) and pulled toward true center - // (right: 14% -> 26%) so purple is the highlighted center-of-banner - // tone, with strengthened opacity (0.52 -> 0.58) to make it read as - // the focal glow while everything else stays subtle around it. - width: 68%; - height: 68vh; - // Pulled in a bit further (26% -> 18%) to close the gap toward blue - // — see blob-blue above. - right: 18%; - top: 6vh; - background: radial-gradient(ellipse at center, rgba(138, 93, 255, 0.58) 0%, transparent 72%); - animation: drift-b 7s ease-in-out infinite alternate; - } - - .blob-teal { - display: block; - width: 40%; - height: 50vh; - right: 14%; - top: 8vh; - background: radial-gradient(ellipse at center, rgba(45, 220, 177, 0.18) 0%, transparent 65%); - animation: drift-d 9s ease-in-out infinite alternate; - } -} - -// Same fix as the light-mode blobs in .walkthroughs-page above, for dark -// mode's distinct orange/yellow/purple-r/teal set — their positions above -// are tuned for home's taller banner, putting every core below this page's -// much shorter (~190px) visible band. -#wrapper[data-theme='dark'] .walkthroughs-page .radiance-bg, -#docsModal[data-theme='dark'] .walkthroughs-page .radiance-bg { - // Same reasoning as the light-mode alpha overrides above: home never - // shows these blobs' full-strength cores, this page does (that's the - // point of the repositioning), so the same alpha reads more saturated - // here than on home/the reference. - .blob-orange { - top: -29vh; - background: radial-gradient(ellipse at center, rgba(255, 180, 90, 0.11) 0%, transparent 68%); - } - - .blob-yellow { - top: -18vh; - background: radial-gradient(ellipse at center, rgba(255, 232, 150, 0.05) 0%, transparent 65%); - } - - .blob-blue { - top: -21vh; - background: radial-gradient(ellipse at center, rgba(39, 140, 255, 0.16) 0%, transparent 70%); - } - - .blob-purple-r { - top: -23vh; - background: radial-gradient(ellipse at center, rgba(138, 93, 255, 0.24) 0%, transparent 72%); - } - - .blob-teal { - top: -14vh; - background: radial-gradient(ellipse at center, rgba(45, 220, 177, 0.09) 0%, transparent 65%); - } -} - -// Mostly-horizontal drift: large vertical swings pushed blobs down into the -// `::after` fade-to-flat zone for part of each cycle, dimming the glow right -// when it should stay visible. Horizontal movement stays large enough to -// read clearly as motion, without carrying the glow out of the visible band; -// the smaller scale swing (vs. an earlier, more dramatic pass) keeps the -// pulse subtle rather than a noticeable "breathing" effect. -@keyframes drift-a { - from { transform: translate(0px, 0px) scale(1.00); } - to { transform: translate(200px, -60px) scale(1.15); } -} - -@keyframes drift-b { - from { transform: translate(0px, 0px) scale(1.00); } - to { transform: translate(-160px, 70px) scale(1.18); } -} - -@keyframes drift-c { - from { transform: translate(0px, 0px) scale(1.00); } - to { transform: translate(-180px, -50px) scale(1.14); } -} - -@keyframes drift-d { - from { transform: translate(0px, 0px) scale(1.00); } - to { transform: translate(140px, 60px) scale(1.16); } -} - -@media (prefers-reduced-motion: reduce) { - .radiance-bg .blob { - animation: none; - } -} - -// In-product presentation only: override banner color to match product sidebar (#232F43), -// and suppress the page-wide gradient entirely. -.embedded-mode { - .radiance-bg { - display: none; - } - - .header-banner { - background: #232F43; - border-bottom: none; - box-shadow: none; - - .header-banner-text { - color: #fff; - } - } - - .walkthroughs-page { - background: #232F43; - - .walkthroughs-hero-text { - color: #fff; - } - } -} - .introCard, .boxHalfWidth { flex: 1 1 260px; diff --git a/src/assets/styles/radiance-home.css b/src/assets/styles/radiance-home.css new file mode 100644 index 000000000..1c8c3947b --- /dev/null +++ b/src/assets/styles/radiance-home.css @@ -0,0 +1,142 @@ +/* + * Radiance product pattern "home" - the Home page hero recipe (FIXED, admin-owned). + * + * The finished Home page (Spotter home) draws its hero from two layers: + * .radiance-home the Radiance surface's wrapper - bleeds past the hero, + * fades toward the content, and carries the fine noise + * and tone lift at the language's values (GRAIN-2) + * .radiance-home-stage the hero's container: a faint glow layer (::before) + * The grain is the language's one grain per theme (GRAIN-6): the renderer draws + * it, over Home's colour at the hero's strength (PRODUCT_PATTERNS.home). + * Markup (see README.md): + *
+ * + * ...hero content... + *
+ * The renderer draws the top wash itself (subtle, fast; locked). Nothing here is + * tunable - these values are the product pattern (RULES.md PRODUCT-1..5). The + * glow layer follows the clean-blend order (COLOR-5): pink - violet - blue - mint. + * Theme follows the surface's own data-radiance-appearance (read with :has()). + */ + +.radiance-home-stage { + --radiance-home-base: #ffffff; + position: relative; +} + +.radiance-home-stage:has(.radiance-local[data-radiance-appearance="dark"]) { + --radiance-home-base: #131416; +} + +/* The glow layer under the hero. */ +.radiance-home-stage::before { + content: ""; + position: absolute; + z-index: 0; + top: 0; + left: -16vw; + right: -16vw; + height: 640px; + pointer-events: none; + background: + radial-gradient(ellipse 58% 36% at 22% -4%, rgba(255, 118, 154, 0.072), rgba(255, 118, 154, 0.028) 44%, rgba(255, 118, 154, 0.006) 64%, transparent 88%), + radial-gradient(ellipse 66% 38% at 38% -2%, rgba(165, 102, 255, 0.066), rgba(165, 102, 255, 0.026) 38%, rgba(165, 102, 255, 0.006) 64%, transparent 88%), + radial-gradient(ellipse 72% 42% at 62% 2%, rgba(88, 150, 255, 0.076), rgba(88, 150, 255, 0.032) 42%, rgba(88, 150, 255, 0.006) 66%, transparent 90%), + radial-gradient(ellipse 70% 44% at 84% 4%, rgba(80, 220, 180, 0.048), rgba(80, 220, 180, 0.017) 40%, transparent 78%), + linear-gradient(to bottom, rgba(255, 255, 255, 0.02) 0%, rgba(255, 255, 255, 0.16) 42%, rgba(255, 255, 255, 0.56) 78%, var(--radiance-home-base) 100%); + -webkit-mask-image: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.94) 26%, rgba(0, 0, 0, 0.54) 58%, rgba(0, 0, 0, 0.16) 84%, transparent 100%); + mask-image: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.94) 26%, rgba(0, 0, 0, 0.54) 58%, rgba(0, 0, 0, 0.16) 84%, transparent 100%); +} + +.radiance-home-stage:has(.radiance-local[data-radiance-appearance="dark"])::before { + height: 560px; + background: + radial-gradient(ellipse 58% 34% at 20% -4%, rgba(255, 93, 142, 0.06), rgba(255, 93, 142, 0.024) 42%, rgba(255, 93, 142, 0.006) 62%, transparent 86%), + radial-gradient(ellipse 66% 38% at 38% -2%, rgba(138, 93, 255, 0.068), rgba(138, 93, 255, 0.028) 36%, rgba(138, 93, 255, 0.006) 64%, transparent 88%), + radial-gradient(ellipse 72% 42% at 62% 2%, rgba(39, 140, 255, 0.078), rgba(39, 140, 255, 0.032) 38%, rgba(39, 140, 255, 0.007) 66%, transparent 90%), + radial-gradient(ellipse 70% 44% at 84% 4%, rgba(45, 220, 177, 0.026), rgba(45, 220, 177, 0.01) 40%, transparent 78%), + linear-gradient(to bottom, rgba(26, 27, 30, 0.08) 0%, rgba(26, 27, 30, 0.24) 38%, rgba(26, 27, 30, 0.62) 70%, var(--radiance-home-base) 100%); + -webkit-mask-image: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.96) 24%, rgba(0, 0, 0, 0.5) 54%, rgba(0, 0, 0, 0.14) 78%, transparent 100%); + mask-image: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.96) 24%, rgba(0, 0, 0, 0.5) 54%, rgba(0, 0, 0, 0.14) 78%, transparent 100%); +} + +/* The surface's wrapper: bleeds past the hero, fades toward the content, and + carries the tone lift and the 56px fine noise at the language's values. The + renderer draws Home's colour at the hero's strength and the grain on top. */ +.radiance-home { + position: absolute; + z-index: 0; + inset: -96px -12vw -220px; + overflow: hidden; + pointer-events: none; + filter: saturate(1.16) contrast(1.06); + -webkit-mask-image: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.92) 22%, rgba(0, 0, 0, 0.52) 54%, rgba(0, 0, 0, 0.18) 78%, transparent 100%); + mask-image: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.92) 22%, rgba(0, 0, 0, 0.52) 54%, rgba(0, 0, 0, 0.18) 78%, transparent 100%); +} + +.radiance-home:has(> .radiance-local[data-radiance-appearance="dark"]) { + inset: -88px -12vw -150px; + filter: saturate(1.2) contrast(1.08); + -webkit-mask-image: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.86) 20%, rgba(0, 0, 0, 0.42) 48%, rgba(0, 0, 0, 0.12) 70%, transparent 90%); + mask-image: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.86) 20%, rgba(0, 0, 0, 0.42) 48%, rgba(0, 0, 0, 0.12) 70%, transparent 90%); +} + +.radiance-home::before { + content: ""; + position: absolute; + z-index: 3; + inset: 0; + pointer-events: none; + opacity: 0.22; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='56' height='56' viewBox='0 0 56 56'%3E%3Cfilter id='n' x='0' y='0' width='100%25' height='100%25'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.48' numOctaves='3' seed='21' stitchTiles='stitch'/%3E%3CfeComponentTransfer%3E%3CfeFuncR type='linear' slope='1.58' intercept='-0.18'/%3E%3CfeFuncG type='linear' slope='1.22' intercept='-0.12'/%3E%3CfeFuncB type='linear' slope='1.5' intercept='-0.16'/%3E%3CfeFuncA type='linear' slope='0.64'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='56' height='56' filter='url(%23n)'/%3E%3C/svg%3E"); + background-size: 56px 56px; + mix-blend-mode: multiply; +} + +.radiance-home:has(> .radiance-local[data-radiance-appearance="dark"])::before { + opacity: 0.27; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='56' height='56' viewBox='0 0 56 56'%3E%3Cfilter id='n' x='0' y='0' width='100%25' height='100%25'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.48' numOctaves='3' seed='21' stitchTiles='stitch'/%3E%3CfeComponentTransfer%3E%3CfeFuncR type='linear' slope='1.64' intercept='-0.2'/%3E%3CfeFuncG type='linear' slope='1.14' intercept='-0.1'/%3E%3CfeFuncB type='linear' slope='1.58' intercept='-0.18'/%3E%3CfeFuncA type='linear' slope='0.7'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='56' height='56' filter='url(%23n)'/%3E%3C/svg%3E"); + mix-blend-mode: screen; +} + +.radiance-home::after { + content: ""; + position: absolute; + z-index: 2; + inset: 0; + background: + radial-gradient(ellipse 72% 34% at 50% 8%, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.034) 48%, rgba(255, 255, 255, 0) 78%), + linear-gradient(to bottom, rgba(255, 255, 255, 0.02) 0%, rgba(255, 255, 255, 0.16) 38%, rgba(255, 255, 255, 0.48) 70%, #ffffff 100%); +} + +.radiance-home:has(> .radiance-local[data-radiance-appearance="dark"])::after { + background: + radial-gradient(ellipse 72% 34% at 50% 8%, rgba(26, 27, 30, 0.12), rgba(26, 27, 30, 0.052) 46%, rgba(26, 27, 30, 0) 76%), + linear-gradient(to bottom, rgba(26, 27, 30, 0.06) 0%, rgba(26, 27, 30, 0.22) 34%, rgba(26, 27, 30, 0.56) 62%, #131416 100%); +} + +.radiance-home > .radiance-local { + position: absolute; + inset: 0; + overflow: hidden; +} + +.radiance-home > .radiance-local canvas { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +@media (max-width: 780px) { + .radiance-home { inset: -82px -18vw -176px; } + .radiance-home:has(> .radiance-local[data-radiance-appearance="dark"]) { inset: -76px -18vw -132px; } +} + +@media (max-width: 520px) { + .radiance-home { inset: -72px -28vw -162px; } + .radiance-home:has(> .radiance-local[data-radiance-appearance="dark"]) { inset: -68px -28vw -126px; } +} diff --git a/src/assets/svg/not-found-illustration-dark.svg b/src/assets/svg/not-found-illustration-dark.svg new file mode 100644 index 000000000..793456da4 --- /dev/null +++ b/src/assets/svg/not-found-illustration-dark.svg @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/svg/not-found-illustration.svg b/src/assets/svg/not-found-illustration.svg new file mode 100644 index 000000000..7340bc549 --- /dev/null +++ b/src/assets/svg/not-found-illustration.svg @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/DevDocTemplate/index.tsx b/src/components/DevDocTemplate/index.tsx index e3760503f..bfb8357ff 100644 --- a/src/components/DevDocTemplate/index.tsx +++ b/src/components/DevDocTemplate/index.tsx @@ -12,6 +12,7 @@ import { BiSearch } from '@react-icons/all-files/bi/BiSearch'; import { Analytics } from '@vercel/analytics/react'; import { Seo } from '../Seo'; import { queryStringParser, isPublicSite } from '../../utils/app-utils'; +import { useRadianceTheme } from '../../utils/radiance'; import { passThroughHandler, fetchChild, getPageIdFromHref } from '../../utils/doc-utils'; import Header from '../Header'; import SecondaryHeader, { DocCategory, CATEGORY_PAGEIDS, CATEGORY_NAV_ID } from '../SecondaryHeader'; @@ -20,6 +21,7 @@ import Docmap from '../Docmap'; import Document from '../Document'; import Search from '../Search'; import '../../assets/styles/index.scss'; +import '../../assets/styles/radiance-home.css'; import { getAlgoliaIndex } from '../../configs/algolia-search-config'; import RenderPlayGround from './playGround/RESTAPI'; import GraphQLPlayGround from './playGround/GraphQL'; @@ -56,6 +58,27 @@ 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'; + +// Session key remembering which sidebar category the visitor was last in. Pages like the +// changelogs are listed in several category navs, and every in-site navigation is a full page +// reload, so this is how the next page knows which sidebar the link was clicked from. +const ACTIVE_CATEGORY_STORAGE_KEY = 'docsActiveCategory'; + +/* The sidebar category the visitor was in on the page they just came from, or null when the + * page was opened directly (typed URL, bookmark, search engine, other site). Only trusted for a + * same-origin referrer, so a direct visit keeps the default category for the page. */ +const readPreviousCategory = (): DocCategory | null => { + if (typeof window === 'undefined') return null; + try { + if (!document.referrer) return null; + if (new URL(document.referrer).origin !== window.location.origin) return null; + return window.sessionStorage.getItem(ACTIVE_CATEGORY_STORAGE_KEY) as DocCategory | null; + } catch (e) { + return null; + } +}; const DevDocTemplate: FC = (props) => { const { @@ -97,6 +120,15 @@ const DevDocTemplate: FC = (props) => { const [navContent, setNavContent] = useState(initialNavContentData || ''); // breadcrumsData is derived after processedNavMap is built (see useMemo below) const [activeCategory, setActiveCategory] = useState('guides'); + // Read once on mount, before the persistence effect below overwrites the stored value. + const [previousCategory] = useState(readPreviousCategory); + useEffect(() => { + try { + window.sessionStorage.setItem(ACTIVE_CATEGORY_STORAGE_KEY, activeCategory); + } catch (e) { + // storage unavailable (private mode etc.): the default category is used next time + } + }, [activeCategory]); const [showSearch, setShowSearch] = useState(false); const [leftNavOpen, setLeftNavOpen] = useState(false); const [keyword, updateKeyword] = useState(''); @@ -130,6 +162,9 @@ const DevDocTemplate: FC = (props) => { }); const [key, setKey] = useState(''); + // Keeps the Radiance hero surface (home.adoc) in the site's current theme. + useRadianceTheme(isDarkMode); + // Pre-process all category nav HTMLs once ({{navprefix}} substitution applied to each) const processedNavMap = React.useMemo(() => Object.fromEntries( @@ -200,24 +235,39 @@ const isVersionedIframe = VERSION_DROPDOWN.some( const isAskDocsPage = params[TS_PAGE_ID_PARAM] === CUSTOM_PAGE_ID.ASK_DOCS; - /* Build pageId → category map by parsing hrefs from each category's nav HTML. + /* Build pageId → categories map by parsing hrefs from each category's nav HTML. * This means writers only need to update nav-*.adoc — no TypeScript changes needed. - * Skips any merged nav key that isn't a real tab/category (e.g. the in-product nav, - * or a content-only nav file like nav-release-notes.adoc that a category points to - * via CATEGORY_NAV_ID rather than being a category itself) — otherwise `cat` ends up - * as a bogus DocCategory and CATEGORY_NAV_ID[activeCategory] later resolves to - * undefined, crashing on the `.startsWith` call in activeNavContent below. */ - const pageIdToCategoryMap = React.useMemo(() => { + * A page can be listed in more than one nav (e.g. the changelogs are in both + * nav-release-notes.adoc and their product's nav), so each page maps to a list. + * Nav keys are resolved to categories through CATEGORY_NAV_ID (nav-release-notes.adoc + * belongs to 'whats-new'). Anything that isn't a real tab/category (e.g. the in-product + * nav) is skipped — otherwise `cat` ends up as a bogus DocCategory and + * CATEGORY_NAV_ID[activeCategory] later resolves to undefined, crashing on the + * `.startsWith` call in activeNavContent above. */ + const pageIdToCategories = React.useMemo(() => { if (typeof window === 'undefined') return {}; - const knownCategories = new Set(Object.keys(CATEGORY_NAV_ID)); - const map: Record = {}; - Object.entries(processedNavMap).forEach(([cat, html]) => { - if (cat === IN_PRODUCT_NAV_KEY || !knownCategories.has(cat)) return; + // nav-*.adoc key (pageid minus 'nav-') -> category, e.g. 'release-notes' -> 'whats-new' + const navKeyToCategory: Record = {}; + (Object.entries(CATEGORY_NAV_ID) as [DocCategory, string][]).forEach(([cat, navId]) => { + if (cat !== 'all' && navId.startsWith('nav-')) { + navKeyToCategory[navId.slice(4)] = cat; + } + }); + const map: Record = {}; + // The navs are applied in order, 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(([navKey, html]) => { + const cat = navKeyToCategory[navKey]; + if (navKey === IN_PRODUCT_NAV_KEY || !cat) return; const doc = new DOMParser().parseFromString(html as string, 'text/html'); doc.querySelectorAll('a[href]').forEach((a) => { const pageId = getPageIdFromHref(a.getAttribute('href')); if (!pageId) return; - map[pageId] = cat as DocCategory; + if (!map[pageId]) map[pageId] = []; + if (!map[pageId].includes(cat)) map[pageId].push(cat); }); }); return map; @@ -240,8 +290,23 @@ const isVersionedIframe = VERSION_DROPDOWN.some( return; } // First try the auto-derived map from nav files - if (pageIdToCategoryMap[currentPageId]) { - setActiveCategory(pageIdToCategoryMap[currentPageId]); + const categories = pageIdToCategories[currentPageId]; + if (categories?.length) { + // The SDK reference nav always claims its pages, wherever the visitor came from. + if (categories.includes(SDK_REFERENCE_NAV_KEY)) { + setActiveCategory(SDK_REFERENCE_NAV_KEY); + return; + } + // A page listed in several category navs (e.g. the changelogs, which sit in both + // "What's new" and their product's nav) stays in the sidebar the visitor came from. + // A direct visit has no previous category and gets the product nav, not "What's new". + const cameFrom = previousCategory && categories.includes(previousCategory) + ? previousCategory + : undefined; + const productNav = categories.filter((cat) => cat !== 'whats-new'); + setActiveCategory( + cameFrom || productNav[productNav.length - 1] || categories[0], + ); return; } // Fall back to the static CATEGORY_PAGEIDS for any pages not yet in a nav file @@ -249,7 +314,7 @@ const isVersionedIframe = VERSION_DROPDOWN.some( ([, pageIds]) => pageIds.includes(currentPageId), ); if (found) setActiveCategory(found[0]); - }, [curPageNode.pageAttributes.pageid, pageIdToCategoryMap, isVersionedIframe, location.search]); + }, [curPageNode.pageAttributes.pageid, pageIdToCategories, previousCategory, isVersionedIframe, location.search]); useEffect(() => { // based on query params set if public site is open or not @@ -409,7 +474,15 @@ const isVersionedIframe = VERSION_DROPDOWN.some( const found = (Object.entries(CATEGORY_PAGEIDS) as [DocCategory, string[]][]).find( ([, pageIds]) => pageIds.includes(e.data.params.pageid), ); - if (found) setActiveCategory(found[0]); + // Keep the current tab when it already lists the page (e.g. a changelog opened + // from "What's new"), instead of jumping to the first category that does. + if (found) { + setActiveCategory((current) => + CATEGORY_PAGEIDS[current]?.includes(e.data.params.pageid) + ? current + : found[0], + ); + } } } }; 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..7cf099a4a 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( @@ -101,17 +108,27 @@ const LeftSideBar = (props: { // marking this navigation as handled. const isAsideVisible = isMaxMobileResolution || props.leftNavOpen; if (lastScrolledPageIdRef.current !== props.curPageid && isAsideVisible) { - const activeLink = (ref.current as HTMLDivElement)?.querySelector( - 'a.active', - ); - if (activeLink) { + const aside = ref.current as HTMLDivElement; + const activeLink = aside?.querySelector('a.active'); + if (aside && activeLink) { lastScrolledPageIdRef.current = props.curPageid; - // 'nearest' is a no-op once the link is already visible. 'center' isn't — - // it nudges toward exact-center even when already in view, and since this - // sidebar has no scroll room to spare on most pages (content fits exactly), - // that nudge has nowhere to go but the window itself, which is what was - // sliding the fixed header out of view on some walkthrough pages. - activeLink.scrollIntoView({ block: 'nearest' }); + // Scroll only the sidebar itself, never the window: scrollIntoView() also + // scrolls the window, which slid the fixed header out of view on some + // walkthrough pages. 'nearest' avoided that but parks a link that starts + // below the fold flush against the bottom edge, so the user lands on a nav + // that looks jumped. Instead, leave the nav alone when the link is already + // comfortably in the upper part of it, and otherwise bring it to about a + // third of the way down (scrollTop clamps at the ends). + const asideRect = aside.getBoundingClientRect(); + const linkRect = activeLink.getBoundingClientRect(); + const linkTop = linkRect.top - asideRect.top; + const isComfortablyVisible = + linkTop >= 0 && + linkRect.bottom <= asideRect.bottom && + linkTop <= aside.clientHeight * 0.6; + if (!isComfortablyVisible) { + aside.scrollTop += linkTop - aside.clientHeight * 0.3; + } } } }, [props.curPageid, isMaxMobileResolution, navContent, props.leftNavOpen]); diff --git a/src/components/Page404/index.scss b/src/components/Page404/index.scss index 299fcbe01..d597d727c 100644 --- a/src/components/Page404/index.scss +++ b/src/components/Page404/index.scss @@ -1,6 +1,31 @@ @import '../../assets/styles/variables.scss'; +// BB Roller Mono is the large "404" numerals in the design. +@font-face { + font-family: 'BB-Roller-Mono'; + src: url('../../assets/fonts/BB-Roller-Mono-Regular.otf') format('opentype'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +// The heading is Optimo Plain Medium at weight 500 in the design. index.scss registers +// Plain-Medium.otf as weight 600 ($font-weight-bold), so the same file is declared here at 500 +// under its own family name. A second 'Optimo-Plain' face at 500 would also pull the page's +// 400-weight text, which now resolves to Plain Light, up to Medium. +@font-face { + font-family: 'Optimo-Plain-Medium'; + src: url('../../assets/fonts/Plain-Medium.otf') format('opentype'); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +$not-found-text: var(--rd-sys-color-content-primary, #1d232f); +$not-found-brand: #2770ef; + .pageStyles { + position: relative; display: flex; flex-direction: column; align-items: center; @@ -8,22 +33,158 @@ text-align: center; height: auto; min-height: 100vh; - padding: 96px; - color: var(--rd-sys-color-content-primary); - background-color: var(--rd-sys-color-background-base); + padding: 96px 24px; + overflow: hidden; + color: $not-found-text; + background-color: var(--rd-sys-color-background-base, #fff); font-family: 'Optimo-Plain', sans-serif; } +// Hero box for the Radiance "home" pattern (radiance-home.css): 336px at the top of the page. +// The recipe bleeds its field past this box and fades it into the page, and the page clips the +// bleed (overflow: hidden above). The page background is the area the field fades into. +.radianceHero { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 336px; + z-index: 1; + isolation: isolate; + pointer-events: none; +} + +.pageMissing, +.docMissing { + position: relative; + z-index: 1; +} + +.pageMissing { + display: flex; + flex-direction: column; + align-items: center; +} + +.codeStyles { + margin: 0 0 40px; + font-family: 'BB-Roller-Mono', 'Roboto Mono', monospace; + font-size: 96px; + font-weight: 400; + line-height: 1; + letter-spacing: 0; +} + .headingStyles { - margin-top: 0; - margin-bottom: 64px; - font-size: 28px; + margin: 0 0 8px; + font-family: 'Optimo-Plain-Medium', 'Optimo-Plain', sans-serif; + font-size: 24px; + font-weight: 500; + line-height: 32px; } .paragraphStyles { - margin: 0 0 16px; + margin: 0; + font-size: 16px; + line-height: 24px; +} + +.homeButton { + display: inline-flex; + align-items: center; + justify-content: center; + margin-top: 24px; + padding: 4px 12px; + border-radius: 999px; + background: #eaedf2; + color: $not-found-text; + font-size: 13px; + line-height: 16px; + text-decoration: none; + transition: background-color 0.15s ease; + + &:hover { + background: #dde2ea; + text-decoration: none; + } +} + +// In-product presentation: illustration beside the text, primary button. +.pageStyles--embedded { + .docMissing { + display: flex; + align-items: center; + gap: 48px; + text-align: left; + } + + .docMissingIllustration { + flex: none; + width: 140px; + height: 118px; + } + + .docMissingText { + max-width: 440px; + } + + .paragraphStyles { + max-width: 420px; + } + + .homeButton { + padding: 8px 16px; + background: $not-found-brand; + color: #fff; + font-size: 14px; + line-height: 16px; + + &:hover { + background: #1f5fd6; + } + } +} + +// Dark presentation. The colours are the docs' own dark tokens (body #1F2024, border #34363C, +// accent #71A1F4), which match the dark designs. The Radiance home pattern fades into the page +// background, as it does on the docs home hero. +.pageStyles--dark { + color: #dfe0e2; + background-color: #1f2024; + + .homeButton { + background: #34363c; + color: #dfe0e2; + + &:hover { + background: #41444b; + } + } + + &.pageStyles--embedded .homeButton { + background: #71a1f4; + color: #1f2024; + + &:hover { + background: #86b0f6; + } + } +} + +@media (max-width: 560px) { + .codeStyles { + font-size: 72px; + } + + .pageStyles--embedded .docMissing { + flex-direction: column; + gap: 24px; + text-align: center; - a { - color: var(--rd-sys-color-content-brand); + .docMissingText { + display: flex; + flex-direction: column; + align-items: center; + } } } diff --git a/src/components/Page404/index.tsx b/src/components/Page404/index.tsx index 40e563eba..208545366 100644 --- a/src/components/Page404/index.tsx +++ b/src/components/Page404/index.tsx @@ -2,22 +2,79 @@ import * as React from 'react'; import { Link } from 'gatsby'; import { NOT_FOUND_GO_HOME_PAGE_ID } from '../../configs/doc-configs'; import t from '../../utils/lang-utils'; +import { isPublicSite, getInitialDarkMode } from '../../utils/app-utils'; +import { useRadianceTheme } from '../../utils/radiance'; +import notFoundIllustration from '../../assets/svg/not-found-illustration.svg'; +import notFoundIllustrationDark from '../../assets/svg/not-found-illustration-dark.svg'; +import '../../assets/styles/radiance-home.css'; import './index.scss'; const Page404 = () => { + // The page is prerendered once (404.html), so it always starts with the public light layout + // and switches to the in-product and/or dark presentation on mount. + const [isEmbedded, setIsEmbedded] = React.useState(false); + const [isDark, setIsDark] = React.useState(false); + React.useEffect(() => { + setIsEmbedded(!isPublicSite(window.location.search)); + setIsDark(getInitialDarkMode(window.location.search)); + }, []); + + // Keeps the Radiance gradient in the same theme as the page. + useRadianceTheme(isDark); + + const homeLink = ( + + {t('404_GO_HOME_LINK_TEXT')} + + ); + return ( -
+
{t('404_PAGE_TITLE')} -

{t('404_PAGE_HEADING')}

-

- {t('404_PAGE_MSG_PRETEXT')} {t('404_PAGE_MSG')} -

-

- - {t('404_GO_HOME_LINK_TEXT')} - - . -

+ {/* Radiance "home" product pattern: the same gradient as the product home page and the + docs home hero. Markup follows products/home/README.md; the recipe CSS owns the look. */} +
); }; 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: {