@salesforcedevs/docs-components 1.34.3 → 1.34.4-final-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,29 +1,29 @@
1
1
  {
2
- "name": "@salesforcedevs/docs-components",
3
- "version": "1.34.3",
4
- "description": "Docs Lightning web components for DSC",
5
- "license": "MIT",
6
- "main": "index.js",
7
- "engines": {
8
- "node": "22.x"
9
- },
10
- "publishConfig": {
11
- "access": "public"
12
- },
13
- "dependencies": {
14
- "@api-components/amf-helper-mixin": "4.5.29",
15
- "classnames": "2.5.1",
16
- "dompurify": "3.2.4",
17
- "kagekiri": "1.4.2",
18
- "lodash.orderby": "4.6.0",
19
- "lodash.uniqby": "4.7.0",
20
- "query-string": "7.1.3",
21
- "sentence-case": "3.0.4"
22
- },
23
- "devDependencies": {
24
- "@types/classnames": "2.3.1",
25
- "@types/lodash.orderby": "4.6.9",
26
- "@types/lodash.uniqby": "4.7.9"
27
- },
28
- "gitHead": "c8c3bb1003d40a55fdd5733c7891dcca3931fb24"
29
- }
2
+ "name": "@salesforcedevs/docs-components",
3
+ "version": "1.34.4-final-alpha",
4
+ "description": "Docs Lightning web components for DSC",
5
+ "license": "MIT",
6
+ "main": "index.js",
7
+ "engines": {
8
+ "node": "22.x"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "dependencies": {
14
+ "@api-components/amf-helper-mixin": "4.5.29",
15
+ "classnames": "2.5.1",
16
+ "dompurify": "3.2.4",
17
+ "kagekiri": "1.4.2",
18
+ "lodash.orderby": "4.6.0",
19
+ "lodash.uniqby": "4.7.0",
20
+ "query-string": "7.1.3",
21
+ "sentence-case": "3.0.4"
22
+ },
23
+ "devDependencies": {
24
+ "@types/classnames": "2.3.1",
25
+ "@types/lodash.orderby": "4.6.9",
26
+ "@types/lodash.uniqby": "4.7.9"
27
+ },
28
+ "gitHead": "4629fdd9ca18a13480044ad43515b91945d16aad"
29
+ }
@@ -16,8 +16,6 @@
16
16
  }
17
17
 
18
18
  .icon {
19
- --dx-c-icon-size: var(--dx-g-icon-size-lg);
20
-
21
19
  flex-shrink: 0;
22
20
  margin-top: var(--dx-g-spacing-smd);
23
21
  margin-right: var(--dx-g-spacing-sm);
@@ -73,6 +71,47 @@
73
71
  cursor: pointer;
74
72
  }
75
73
 
74
+ /* Centered variant: the message is centered in the banner and the icon hangs
75
+ to its left. The close button is taken out of the flow so it doesn't offset
76
+ the centering. */
77
+ .container_centered {
78
+ position: relative;
79
+ display: grid;
80
+ grid-template-columns: 1fr auto 1fr;
81
+ align-items: center;
82
+
83
+ /* Reserve the close button's width plus its right inset on both sides */
84
+ padding: 0
85
+ calc(
86
+ var(--dx-g-spacing-3xl) + var(--dx-g-spacing-xs) +
87
+ var(--doc-banner-padding-right)
88
+ );
89
+ }
90
+
91
+ .container_centered .icon {
92
+ grid-column: 1;
93
+ justify-self: end;
94
+ margin-top: 0;
95
+ }
96
+
97
+ .container_centered .main {
98
+ grid-column: 2;
99
+ align-items: center;
100
+ justify-content: center;
101
+ }
102
+
103
+ .container_centered .message {
104
+ text-align: center;
105
+ }
106
+
107
+ .container_centered .close {
108
+ position: absolute;
109
+ top: 50%;
110
+ right: var(--doc-banner-padding-right);
111
+ margin-left: 0;
112
+ transform: translateY(-50%);
113
+ }
114
+
76
115
  @media (max-width: 1279px) {
77
116
  :host {
78
117
  --doc-banner-padding-left: var(--dx-g-spacing-xl);
@@ -1,16 +1,17 @@
1
1
  <template>
2
2
  <template lwc:if={showBanner}>
3
- <div class="container" part="container">
3
+ <div class={containerClass} part="container">
4
4
  <dx-icon
5
5
  class="icon"
6
- symbol="info"
7
- size="override"
8
- color="gray-50"
6
+ sprite={iconSprite}
7
+ symbol={iconSymbol}
8
+ size={iconSize}
9
+ color={iconColor}
9
10
  part="icon"
10
11
  ></dx-icon>
11
12
  <div class="main">
12
13
  <div class="message" part="message" lwc:dom="manual"></div>
13
- <div class="actions" part="actions">
14
+ <div lwc:if={hasActions} class="actions" part="actions">
14
15
  <template lwc:if={hasPrimaryButton}>
15
16
  <dx-button
16
17
  href={buttonHref}
@@ -1,4 +1,6 @@
1
1
  import { LightningElement, api } from "lwc";
2
+ import { IconSize, IconSprite, IconSymbol } from "typings/custom";
3
+ import { normalizeBoolean } from "dxUtils/normalizers";
2
4
 
3
5
  const BANNER_STORAGE_PREFIX = "doc-banner-";
4
6
 
@@ -13,6 +15,24 @@ export default class Banner extends LightningElement {
13
15
 
14
16
  @api dismissStorageKey = "";
15
17
 
18
+ @api
19
+ get centered(): boolean {
20
+ return this._centered;
21
+ }
22
+ set centered(value: boolean | string) {
23
+ this._centered = normalizeBoolean(value);
24
+ }
25
+
26
+ @api iconSprite: IconSprite = "utility";
27
+
28
+ @api iconSymbol: IconSymbol = "info";
29
+
30
+ @api iconColor = "gray-50";
31
+
32
+ @api iconSize: IconSize = "large";
33
+
34
+ private _centered = false;
35
+
16
36
  private _dismissed = false;
17
37
 
18
38
  connectedCallback() {
@@ -33,10 +53,18 @@ export default class Banner extends LightningElement {
33
53
  }
34
54
  }
35
55
 
56
+ get containerClass(): string {
57
+ return this._centered ? "container container_centered" : "container";
58
+ }
59
+
36
60
  get showBanner(): boolean {
37
61
  return !this._dismissed;
38
62
  }
39
63
 
64
+ get hasActions(): boolean {
65
+ return this.hasPrimaryButton || this.hasSecondaryAction;
66
+ }
67
+
40
68
  get hasPrimaryButton(): boolean {
41
69
  return !!(this.buttonLabel && this.buttonHref);
42
70
  }
@@ -3,4 +3,5 @@
3
3
  --doc-c-redoc-sidebar-top: calc(
4
4
  var(--dx-g-global-header-height) + var(--dx-g-doc-header-height)
5
5
  );
6
+ --doc-c-redoc-sidebar-bg: white;
6
7
  }
@@ -4,11 +4,13 @@ import DocPhase from "doc/phase";
4
4
  import DxFooter from "dx/footer";
5
5
  import DxIcon from "dx/icon";
6
6
  import SidebarFooterNav from "dx/sidebarFooterNav";
7
+ import VersionPicker from "doc/versionPicker";
7
8
  import SprigSurvey from "doc/sprigSurvey";
8
9
  import { throttle } from "throttle-debounce";
9
10
  import { pollUntil } from "dxUtils/async";
10
11
  import { toJson } from "dxUtils/normalizers";
11
- import type { OptionWithLink } from "typings/custom";
12
+ import { oldVersionDocInfo } from "docUtils/utils";
13
+ import type { DocPhaseInfo, OptionWithLink } from "typings/custom";
12
14
 
13
15
  declare global {
14
16
  interface Window {
@@ -33,8 +35,18 @@ type ReferenceItem = {
33
35
  topic?: ReferenceTopic;
34
36
  };
35
37
 
38
+ /** A selectable spec version. Mirrors `ReferenceVersion` in `doc/amfReference`. */
39
+ type ReferenceVersion = {
40
+ id: string;
41
+ label: string;
42
+ deprecated?: boolean;
43
+ selected?: boolean;
44
+ link: { href: string };
45
+ };
46
+
36
47
  type ReferenceConfig = {
37
48
  refList: ReferenceItem[];
49
+ versions?: ReferenceVersion[];
38
50
  };
39
51
 
40
52
  const SCROLL_THROTTLE_DELAY = 50;
@@ -42,6 +54,7 @@ const ELEMENT_TIMEOUT = 10000;
42
54
  const ELEMENT_CHECK_INTERVAL = 100;
43
55
  const DEFAULT_PROJECT_TITLE = "All Reference";
44
56
  const BACK_TARGET_STORAGE_KEY = "redoc-back-target";
57
+ const SELECTED_VIEW_STORAGE_KEY = "redoc-selected-view";
45
58
  const DOCS_PATH_SEGMENT = "docs";
46
59
  const DEFAULT_LOCALE = "en-us";
47
60
 
@@ -152,6 +165,40 @@ export default class RedocReference extends LightningElement {
152
165
  return refCount > 1;
153
166
  }
154
167
 
168
+ get versions(): ReferenceVersion[] {
169
+ return this._referenceConfig?.versions ?? [];
170
+ }
171
+
172
+ /** The version flagged `selected`, else the first (the latest/GA version). */
173
+ get selectedVersion(): ReferenceVersion | null {
174
+ const versions = this.versions;
175
+ return versions.find((v) => v.selected) ?? versions[0] ?? null;
176
+ }
177
+
178
+ /** True when the selected version is the latest/GA one (index 0). */
179
+ get latestVersion(): boolean {
180
+ return this.versions.length
181
+ ? this.selectedVersion?.id === this.versions[0].id
182
+ : false;
183
+ }
184
+
185
+ get hasVersionPicker(): boolean {
186
+ return this.versions.length > 0;
187
+ }
188
+
189
+ /** "Newer version available" banner info when on a non-latest version, else null. */
190
+ private get oldVersionInfo(): DocPhaseInfo | null {
191
+ if (this.versions.length > 1 && !this.latestVersion) {
192
+ return oldVersionDocInfo(this.versions[0].link.href);
193
+ }
194
+ return null;
195
+ }
196
+
197
+ /** A single version has nothing to choose, so it renders read-only. */
198
+ get isVersionReadOnly(): boolean {
199
+ return this.versions.length === 1;
200
+ }
201
+
155
202
  // Reads stored back target
156
203
  private getBackTargetFromSession(): string | null {
157
204
  return sessionStorage.getItem(BACK_TARGET_STORAGE_KEY);
@@ -202,7 +249,9 @@ export default class RedocReference extends LightningElement {
202
249
  }
203
250
 
204
251
  const referrer = this.getSameOriginReferrerHref();
205
- return referrer && !this.isLocaleHref(new URL(referrer).pathname)
252
+ return referrer &&
253
+ !this.isLocaleHref(new URL(referrer).pathname) &&
254
+ !this.isVersionHref(new URL(referrer).pathname)
206
255
  ? referrer
207
256
  : null;
208
257
  }
@@ -241,7 +290,10 @@ export default class RedocReference extends LightningElement {
241
290
  }
242
291
 
243
292
  const referrerUrl = new URL(referrerHref);
244
- if (this.isLocaleHref(referrerUrl.pathname)) {
293
+ if (
294
+ this.isLocaleHref(referrerUrl.pathname) ||
295
+ this.isVersionHref(referrerUrl.pathname)
296
+ ) {
245
297
  return;
246
298
  }
247
299
 
@@ -282,6 +334,19 @@ export default class RedocReference extends LightningElement {
282
334
  });
283
335
  }
284
336
 
337
+ /**
338
+ * switching version on this same page.
339
+ */
340
+ private isVersionHref(pathname: string): boolean {
341
+ return this.versions.some((version) => {
342
+ const href = version?.link?.href;
343
+ return (
344
+ !!href &&
345
+ new URL(href, window.location.origin).pathname === pathname
346
+ );
347
+ });
348
+ }
349
+
285
350
  /** When origin is provided, pass it to the footer; otherwise use dx-footer's default. */
286
351
  get effectiveFooterOrigin(): string {
287
352
  return (
@@ -354,6 +419,14 @@ export default class RedocReference extends LightningElement {
354
419
  return parseInt(value, 10) || 0;
355
420
  }
356
421
 
422
+ /** Redoc's LNB background color, themeable via `--doc-c-redoc-sidebar-bg`. */
423
+ private get sidebarBackgroundColor(): string {
424
+ const value = getComputedStyle(this.template.host).getPropertyValue(
425
+ "--doc-c-redoc-sidebar-bg"
426
+ );
427
+ return value.trim() || "white";
428
+ }
429
+
357
430
  /*
358
431
  ** Since we could not use --dx-g-global-header-height as getPropertyValue returns a calc expression,
359
432
  ** we are using the respective CSS variables to calculate the height.
@@ -469,7 +542,14 @@ export default class RedocReference extends LightningElement {
469
542
  specUrl,
470
543
  {
471
544
  // Dynamic scroll offset to account for headers
472
- scrollYOffset: this.calculateScrollYOffset
545
+ scrollYOffset: this.calculateScrollYOffset,
546
+ // Redoc's own styled background outranks injected
547
+ // CSS, so the LNB background must be set via theme.
548
+ theme: {
549
+ sidebar: {
550
+ backgroundColor: this.sidebarBackgroundColor
551
+ }
552
+ }
473
553
  },
474
554
  redocContainer,
475
555
  (error: any) => {
@@ -515,10 +595,7 @@ export default class RedocReference extends LightningElement {
515
595
  const apiContentDiv = await this.waitForApiContent(redocContainer);
516
596
  apiContentDiv.setAttribute("lwc:dom", "manual");
517
597
 
518
- const docPhaseInfo = this.getDocPhaseInfo();
519
- if (docPhaseInfo) {
520
- this.insertDocPhase(apiContentDiv, docPhaseInfo);
521
- }
598
+ this.insertStatusItems(apiContentDiv);
522
599
 
523
600
  this.appendFooterItems(apiContentDiv);
524
601
 
@@ -527,10 +604,22 @@ export default class RedocReference extends LightningElement {
527
604
 
528
605
  // Wait for footer to be rendered before updating styles
529
606
  requestAnimationFrame(() => {
530
- this.updateRedocThirdColumnStyle(redocContainer);
531
-
532
- // Fix initial hash scroll after doc phase insertion
533
- this.handleInitialHashScrollFix();
607
+ try {
608
+ this.updateRedocThirdColumnStyle(redocContainer);
609
+
610
+ // Restore the view selected before a version switch only
611
+ // after all layout mutations (footer, header) are
612
+ // complete, so the scroll lands on the correct element.
613
+ this.restoreSelectedView();
614
+
615
+ // Fix initial hash scroll after doc phase insertion
616
+ this.handleInitialHashScrollFix();
617
+ } catch (error) {
618
+ this.showErrorUI(
619
+ "Failed to integrate custom components:",
620
+ error
621
+ );
622
+ }
534
623
  });
535
624
  } catch (error) {
536
625
  this.showErrorUI("Failed to integrate custom components:", error);
@@ -560,6 +649,53 @@ export default class RedocReference extends LightningElement {
560
649
  }
561
650
  }
562
651
 
652
+ /** Builds the version picker DOM by reusing `doc-version-picker`. */
653
+ private buildVersionPickerDom(): HTMLElement {
654
+ const wrapper = document.createElement("div");
655
+ wrapper.className = "redoc-version-picker";
656
+
657
+ const picker = createElement("doc-version-picker", {
658
+ is: VersionPicker
659
+ });
660
+
661
+ Object.assign(picker, {
662
+ versions: this.versions,
663
+ selectedVersion: this.selectedVersion,
664
+ latestVersion: this.latestVersion,
665
+ readOnly: this.isVersionReadOnly
666
+ });
667
+ picker.addEventListener("change", this.onVersionChange);
668
+ wrapper.appendChild(picker);
669
+
670
+ return wrapper;
671
+ }
672
+
673
+ /** Stashes the current view (URL hash) before the version link navigates. */
674
+ private onVersionChange = (): void => {
675
+ sessionStorage.setItem(
676
+ SELECTED_VIEW_STORAGE_KEY,
677
+ window.location.hash || ""
678
+ );
679
+ };
680
+
681
+ /**
682
+ * Re-applies the view stashed before a version switch, if its anchor exists
683
+ * in the new spec; otherwise a no-op (lands on the spec root).
684
+ */
685
+ private restoreSelectedView(): void {
686
+ const storedHash = sessionStorage.getItem(SELECTED_VIEW_STORAGE_KEY);
687
+ sessionStorage.removeItem(SELECTED_VIEW_STORAGE_KEY);
688
+
689
+ if (!storedHash || window.location.hash) {
690
+ return;
691
+ }
692
+
693
+ const targetId = storedHash.replace(/^#/, "");
694
+ if (targetId && document.getElementById(targetId)) {
695
+ window.location.hash = storedHash;
696
+ }
697
+ }
698
+
563
699
  /**
564
700
  * Builds the locale picker DOM by reusing `dx-sidebar-footer-nav`
565
701
  */
@@ -581,12 +717,17 @@ export default class RedocReference extends LightningElement {
581
717
  }
582
718
 
583
719
  /**
584
- * Builds a fresh project-title/spec-title header DOM node.
720
+ * Builds the doc header: a title group (back link + spec title) and, when
721
+ * versions are available, the version picker as a sibling row. Layout/gaps
722
+ * are styled by the developer-website's redoc CSS.
585
723
  */
586
724
  private buildProjectHeaderDom(): HTMLElement {
587
725
  const wrapper = document.createElement("div");
588
726
  wrapper.className = "redoc-project-header";
589
727
 
728
+ const main = document.createElement("div");
729
+ main.className = "redoc-project-header-main";
730
+
590
731
  if (this.projectTitle) {
591
732
  const backLink = document.createElement("a");
592
733
  backLink.className = "redoc-project-back";
@@ -607,14 +748,20 @@ export default class RedocReference extends LightningElement {
607
748
 
608
749
  backLink.appendChild(icon);
609
750
  backLink.appendChild(label);
610
- wrapper.appendChild(backLink);
751
+ main.appendChild(backLink);
611
752
  }
612
753
 
613
754
  if (this.specTitle) {
614
755
  const specEl = document.createElement("h2");
615
756
  specEl.className = "redoc-spec-title dx-text-display-7";
616
757
  specEl.textContent = this.specTitle;
617
- wrapper.appendChild(specEl);
758
+ main.appendChild(specEl);
759
+ }
760
+
761
+ wrapper.appendChild(main);
762
+
763
+ if (this.hasVersionPicker) {
764
+ wrapper.appendChild(this.buildVersionPickerDom());
618
765
  }
619
766
 
620
767
  return wrapper;
@@ -639,15 +786,40 @@ export default class RedocReference extends LightningElement {
639
786
  return container.querySelector<HTMLElement>(".api-content")!;
640
787
  }
641
788
 
642
- // Creates and inserts doc phase component at container start
643
- private insertDocPhase(container: HTMLElement, docPhaseInfo: string): void {
789
+ // Inserts the doc phase and, on a non-latest version, a dismissible
790
+ // "newer version" banner into a shared doc-phase-wrapper at container start
791
+ private insertStatusItems(container: HTMLElement): void {
792
+ const docPhaseInfo = this.getDocPhaseInfo();
793
+ const oldVersionInfo = this.oldVersionInfo;
794
+ if (!docPhaseInfo && !oldVersionInfo) {
795
+ return;
796
+ }
797
+
644
798
  const wrapper = document.createElement("div");
645
799
  wrapper.className = "doc-phase-wrapper";
646
800
  container.insertBefore(wrapper, container.firstChild);
647
801
 
648
- const docPhaseElement = createElement("doc-phase", { is: DocPhase });
649
- Object.assign(docPhaseElement, { docPhaseInfo });
650
- wrapper.appendChild(docPhaseElement);
802
+ if (docPhaseInfo) {
803
+ const docPhaseElement = createElement("doc-phase", {
804
+ is: DocPhase
805
+ });
806
+ Object.assign(docPhaseElement, { docPhaseInfo });
807
+ wrapper.appendChild(docPhaseElement);
808
+ }
809
+
810
+ if (oldVersionInfo) {
811
+ const versionBanner = createElement("doc-phase", { is: DocPhase });
812
+ Object.assign(versionBanner, {
813
+ docPhaseInfo: oldVersionInfo,
814
+ dismissible: true,
815
+ iconName: "warning"
816
+ });
817
+ versionBanner.addEventListener("dismissphase", () => {
818
+ versionBanner.remove();
819
+ this.updateSidebarPosition();
820
+ });
821
+ wrapper.appendChild(versionBanner);
822
+ }
651
823
  }
652
824
 
653
825
  // Appends footer component to container
@@ -24,6 +24,14 @@
24
24
  lwc:if={docPhaseInfo}
25
25
  doc-phase-info={docPhaseInfo}
26
26
  ></doc-phase>
27
+ <doc-phase
28
+ slot="version-banner"
29
+ lwc:if={showVersionBanner}
30
+ doc-phase-info={oldVersionInfo}
31
+ icon-name="warning"
32
+ dismissible="true"
33
+ ondismissphase={handleDismissVersionBanner}
34
+ ></doc-phase>
27
35
  <slot></slot>
28
36
  </doc-content-layout>
29
37
  </template>
@@ -1,6 +1,12 @@
1
- import { LightningElement, api } from "lwc";
1
+ import { LightningElement, api, track } from "lwc";
2
2
  import { toJson } from "dxUtils/normalizers";
3
- import type { OptionWithLink, TreeNode } from "typings/custom";
3
+ import type { VersionedNode } from "dxUtils/topicVersion";
4
+ import {
5
+ findTopicVersionForCurrentPage,
6
+ latestHrefForCurrentPage
7
+ } from "dxUtils/topicVersion";
8
+ import { oldVersionDocInfo } from "docUtils/utils";
9
+ import type { DocPhaseInfo, OptionWithLink, TreeNode } from "typings/custom";
4
10
 
5
11
  /**
6
12
  * Per-topic type emitted by the docs content-type parser
@@ -10,61 +16,38 @@ import type { OptionWithLink, TreeNode } from "typings/custom";
10
16
  */
11
17
  const TOPIC_TYPE_SPEC = "spec";
12
18
 
13
- /**
14
- * Walks down the first-child chain and returns the first markdown content
15
- * page found, descending through nested content-less parents. Returns
16
- * undefined if the chain breaks on a spec or external link before reaching a
17
- * content page.
18
- */
19
- function firstChildContentPage(node: TreeNode): TreeNode | undefined {
20
- const firstChild = node.children?.[0];
21
- if (!firstChild) {
22
- return undefined;
23
- }
24
-
25
- const isSpec =
26
- (firstChild as { topicType?: string }).topicType === TOPIC_TYPE_SPEC;
27
- const isExternal = firstChild.link?.target === "_blank";
28
- if (isSpec || isExternal) {
29
- return undefined;
30
- }
31
-
32
- if (firstChild.link?.href) {
33
- return firstChild;
34
- }
35
-
36
- return firstChildContentPage(firstChild);
37
- }
38
-
39
19
  /**
40
20
  * Translates per-topic `topicType` into the generic `showForwardArrow` flag
41
- * that `dx-tree-tile` reads (spec topics get the forward arrow icon for Redoc),
42
- * and redirects content-less parent topics whose first child is a content page
21
+ * that `dx-tree-tile` reads (spec topics get the forward arrow icon for Redoc).
22
+ *
23
+ * Content-less parents are flagged with redirectToChild and left without a
24
+ * link. The tree selects the first markdown descendant. Copying that child's
25
+ * href onto the parent makes the parent win selection.
43
26
  */
44
- function decorateTopics(
45
- topics: Array<TreeNode & { topicType?: string }> | undefined
27
+ function decorateTopicsWithForwardArrow(
28
+ topics:
29
+ | Array<TreeNode & { topicType?: string; versions?: unknown }>
30
+ | undefined
46
31
  ): TreeNode[] | undefined {
47
32
  return topics?.map((topic) => {
48
- const { topicType, ...decorated } = topic;
33
+ const { topicType, versions, ...decorated } = topic;
49
34
  if (topicType === TOPIC_TYPE_SPEC) {
50
35
  decorated.showForwardArrow = true;
36
+ // Nested and standalone specs never get a sidebar picker; only the
37
+ // parent MD topic (when versioned) shows one.
38
+ } else if (versions) {
39
+ decorated.versions = versions;
51
40
  }
52
41
  if (decorated.children) {
53
- decorated.children = decorateTopics(decorated.children);
42
+ decorated.children = decorateTopicsWithForwardArrow(
43
+ decorated.children
44
+ );
54
45
  }
55
46
 
56
- /** Redirect a parent that has no content of its own to its first child
57
- * but only when that first child is itself a content page
58
- */
59
- const isContentlessParent =
60
- !!decorated.children?.length && !decorated.link?.href;
61
-
62
- if (isContentlessParent) {
47
+ // Flag a parent with no page of its own. Leave its link empty so the
48
+ // child's URL stays indexed to the child.
49
+ if (!!decorated.children?.length && !decorated.link?.href) {
63
50
  decorated.redirectToChild = true;
64
- const firstChild = firstChildContentPage(topic);
65
- if (firstChild?.link) {
66
- decorated.link = { ...firstChild.link };
67
- }
68
51
  }
69
52
 
70
53
  return decorated;
@@ -101,6 +84,9 @@ export default class UnifiedContentLayout extends LightningElement {
101
84
 
102
85
  private _docPhaseInfo: string | null = null;
103
86
  private _sidebarContent: unknown = null;
87
+ private _oldVersionInfo: DocPhaseInfo | null = null;
88
+
89
+ @track showVersionBanner = false;
104
90
 
105
91
  @api
106
92
  get docPhaseInfo(): string | null {
@@ -117,7 +103,40 @@ export default class UnifiedContentLayout extends LightningElement {
117
103
  }
118
104
 
119
105
  set sidebarContent(value: string) {
120
- this._sidebarContent = decorateTopics(toJson(value)?.topics);
106
+ const topics = decorateTopicsWithForwardArrow(toJson(value)?.topics);
107
+ this._sidebarContent = topics;
108
+ this.updateTopicVersionBanner(topics as VersionedNode[] | undefined);
109
+ }
110
+
111
+ get oldVersionInfo(): DocPhaseInfo | null {
112
+ return this._oldVersionInfo;
113
+ }
114
+
115
+ handleDismissVersionBanner(): void {
116
+ this.showVersionBanner = false;
117
+ }
118
+
119
+ /**
120
+ * Same banner references use when the reader is on an older version.
121
+ * Topic versions are not a separate page property: the sidebar tree
122
+ * carries each version's switching URL, and the current pathname says
123
+ * which one is open.
124
+ */
125
+ private updateTopicVersionBanner(
126
+ topics: VersionedNode[] | undefined
127
+ ): void {
128
+ const match = findTopicVersionForCurrentPage(topics);
129
+ const latest = match?.node.versions?.[0];
130
+ const latestHref = latestHrefForCurrentPage(match?.node.versions);
131
+
132
+ if (match && latest && match.version.id !== latest.id && latestHref) {
133
+ this._oldVersionInfo = oldVersionDocInfo(latestHref);
134
+ this.showVersionBanner = true;
135
+ return;
136
+ }
137
+
138
+ this._oldVersionInfo = null;
139
+ this.showVersionBanner = false;
121
140
  }
122
141
 
123
142
  private get enableFooter(): boolean {
@@ -2,28 +2,69 @@
2
2
 
3
3
  /* NOTE: doc-version-picker-width width variable is used by both dx-button and dx-dropdown to maintain a consistent width. */
4
4
  :host {
5
+ /* Always positioned so the popover's containing block stays the picker.
6
+ Toggling position with aria-expanded lets the menu jump to the sidebar
7
+ (top of the ToC) for a frame before LWC hides it. */
8
+ position: relative;
9
+
5
10
  --dx-c-dropdown-option-font-weight: normal;
11
+ --dx-c-dropdown-option-font-size: var(--dx-g-text-base);
6
12
  --dx-c-dropdown-option-label-color: var(--dx-g-gray-10);
13
+ --dx-c-dropdown-option-active-label-color: var(--dx-g-gray-10);
14
+ --dx-c-dropdown-option-padding: var(--dx-g-spacing-sm)
15
+ var(--dx-g-spacing-lg);
16
+ --dx-c-popover-padding: var(--dx-g-spacing-sm) 0;
7
17
  --popover-container-open-transform: translateY(4px);
18
+ --dx-c-version-picker-focus-ring: 0 0 0 2px white,
19
+ 0 0 0 4px var(--dx-g-blue-vibrant-60);
20
+ }
21
+
22
+ /* Open menus must stack above later sibling pickers in the sidebar. */
23
+ :host(:has([aria-expanded="true"])) {
24
+ z-index: 2;
8
25
  }
9
26
 
10
27
  .version-picker-container {
11
- padding: 8px var(--dx-g-spacing-lg) 8px
12
- var(--dx-g-global-header-padding-horizontal);
13
- border-top: 1px solid var(--dx-g-gray-90);
14
- border-bottom: 1px solid var(--dx-g-gray-90);
28
+ /* Override --doc-version-picker-padding to change the inset (e.g. when the
29
+ picker sits inside an already-padded container). */
30
+ padding: var(
31
+ --doc-version-picker-padding,
32
+ var(--dx-g-spacing-sm) var(--dx-g-spacing-lg) var(--dx-g-spacing-sm)
33
+ var(--dx-g-global-header-padding-horizontal)
34
+ );
35
+
36
+ /* Override --doc-version-picker-divider (e.g. `none`) to restyle dividers. */
37
+ border-top: var(
38
+ --doc-version-picker-divider,
39
+ 1px solid var(--dx-g-gray-90)
40
+ );
41
+ border-bottom: var(
42
+ --doc-version-picker-divider,
43
+ 1px solid var(--dx-g-gray-90)
44
+ );
45
+ }
46
+
47
+ /* Inline sidebar variant: drop the header-slot chrome so the picker sits
48
+ beside a tree node without extra padding or divider lines. */
49
+ .version-picker-container-small {
50
+ display: flex;
51
+ align-items: center;
52
+ padding: 0;
53
+ border: none;
15
54
  }
16
55
 
17
56
  .version-picker-button {
18
57
  display: flex;
19
- width: var(--doc-version-picker-width, 296px);
58
+ width: 100%;
59
+ overflow: visible;
60
+
61
+ --dx-c-button-horizontal-spacing: var(--dx-g-spacing-sm);
62
+ --dx-g-button-icon-color: var(--dx-g-gray-50);
20
63
  }
21
64
 
22
65
  .version-picker-button:hover,
23
- .version-picker-button:active,
24
- .version-picker-button:focus {
25
- --dx-c-button-secondary-color-hover: var(--dx-g-cloud-blue-vibrant-95);
26
- --dx-c-button-primary-color: var(--dx-g-blue-vibrant-40);
66
+ .version-picker-button[aria-expanded="true"] {
67
+ --dx-g-button-icon-color: var(--dx-g-blue-vibrant-20);
27
68
  }
28
69
 
29
70
  /**
@@ -35,6 +76,57 @@ dx-button::part(content) {
35
76
  overflow: hidden;
36
77
  }
37
78
 
79
+ /* Border width stays 1px in every state. Hover, open, and focus paint the
80
+ 2px blue stroke with an inset shadow so the box does not grow.
81
+ Tertiary's hover border is overridden. */
82
+ .version-picker-button::part(container) {
83
+ box-sizing: border-box;
84
+ background: white;
85
+ border: 1px solid var(--dx-g-gray-50);
86
+ border-radius: var(--dx-g-spacing-xs);
87
+ box-shadow: none;
88
+ color: var(--dx-g-gray-10);
89
+ outline: none;
90
+ transition: var(--dx-g-transition-hue-1x), box-shadow 0.15s linear;
91
+ }
92
+
93
+ /* Hover only (not open): 2px #032D60 on #EEF4FF, no ring.
94
+ Border color matches the fill so the 1px border does not add a third
95
+ pixel around the inset stroke.
96
+ :focus-visible is included so click-open + Escape (which upgrades the
97
+ trigger to keyboard focus) still looks like hover while the pointer is on
98
+ the picker. */
99
+ .version-picker-button:hover:not([aria-expanded="true"])::part(container),
100
+ .version-picker-button:hover:not([aria-expanded="true"])::part(
101
+ container
102
+ ):focus-visible {
103
+ background: var(--dx-g-blue-vibrant-95);
104
+ border: 1px solid var(--dx-g-blue-vibrant-95);
105
+ box-shadow: inset 0 0 0 2px var(--dx-g-blue-vibrant-20);
106
+ }
107
+
108
+ /* Open keeps the fill and the 2px stroke. The focus ring is keyboard-only,
109
+ on the closed picker below. */
110
+ .version-picker-button[aria-expanded="true"]::part(container) {
111
+ background: var(--dx-g-blue-vibrant-95);
112
+ border: 1px solid var(--dx-g-blue-vibrant-95);
113
+ border-radius: var(--dx-g-spacing-xs);
114
+ box-shadow: inset 0 0 0 2px var(--dx-g-blue-vibrant-20);
115
+ }
116
+
117
+ /* Keyboard focus while closed. */
118
+ .version-picker-button:not([aria-expanded="true"])::part(
119
+ container
120
+ ):focus-visible {
121
+ --dx-g-button-icon-color: var(--dx-g-blue-vibrant-20);
122
+
123
+ background: var(--dx-g-blue-vibrant-95);
124
+ border: 1px solid var(--dx-g-blue-vibrant-95);
125
+ border-radius: var(--dx-g-spacing-xs);
126
+ box-shadow: inset 0 0 0 2px var(--dx-g-blue-vibrant-20),
127
+ var(--dx-c-version-picker-focus-ring);
128
+ }
129
+
38
130
  .selected-version {
39
131
  display: flex;
40
132
  flex-direction: row;
@@ -49,16 +141,111 @@ dx-button::part(content) {
49
141
  white-space: nowrap;
50
142
  }
51
143
 
52
- dx-type-badge.latest-badge {
53
- --dx-c-type-badge-color: var(--dx-g-green-vibrant-40);
54
- --dx-c-type-badge-background: var(--dx-g-green-vibrant-95);
55
-
144
+ dx-type-badge.latest-badge,
145
+ dx-type-badge.not-latest-badge {
56
146
  margin-left: var(--dx-g-spacing-sm);
57
147
  }
58
148
 
59
- dx-type-badge.not-latest-badge {
60
- --dx-c-type-badge-color: var(--dx-g-red-vibrant-40);
61
- --dx-c-type-badge-background: var(--dx-g-red-vibrant-95);
149
+ /* ------------------------------------------------------------------ *
150
+ * Small "dot" variant (opt-in via `small`). Additive
151
+ * ------------------------------------------------------------------ */
62
152
 
63
- margin-left: var(--dx-g-spacing-sm);
153
+ /* Menu is capped at the ToC width (296px), not the 104px trigger, so options
154
+ keep the same 16px / 8px 24px / gray-10 type as the default picker. */
155
+ .version-picker-dropdown-small {
156
+ --dx-c-dropdown-option-font-size: var(--dx-g-text-base);
157
+ --dx-c-dropdown-option-padding: var(--dx-g-spacing-sm)
158
+ var(--dx-g-spacing-lg);
159
+ --dx-c-dropdown-option-label-color: var(--dx-g-gray-10);
160
+ --dx-c-dropdown-option-border-radius: 0;
161
+ --dx-c-popover-border: none;
162
+
163
+ /* Hug the labels (down to the shortest) but never wider than the ToC
164
+ content: 296px on desktop, and on the smallest breakpoint the sidebar
165
+ variable grows with the viewport up to 720px. Long labels wrap inside
166
+ that cap. */
167
+ --dx-c-dropdown-menu-max-width: var(--doc-version-picker-width, 296px);
168
+ --dx-c-dropdown-fit-white-space: normal;
169
+ --dx-c-dropdown-fit-overflow: hidden;
170
+ --dx-c-dropdown-fit-text-overflow: clip;
171
+ --dx-c-dropdown-option-overflow-wrap: anywhere;
172
+ }
173
+
174
+ .version-picker-button-small {
175
+ width: fit-content;
176
+ max-width: 104px;
177
+
178
+ --dx-c-button-font-size: var(--dx-g-text-xs);
179
+ --dx-c-button-font-weight: var(--dx-g-font-normal);
180
+ --dx-c-button-line-height: var(--dx-g-spacing-lg);
181
+ --dx-c-button-icon-gap: var(--dx-g-spacing-2xs);
182
+ }
183
+
184
+ /* min-width: 0 on each flex ancestor lets the label truncate: a flex item's
185
+ default min-width: auto refuses to shrink below its content and overrides the
186
+ host's max-width, so without this the trigger overflows instead. */
187
+ .version-picker-button-small::part(content) {
188
+ display: flex;
189
+ flex: 1;
190
+ min-width: 0;
191
+ width: auto;
192
+ overflow: hidden;
193
+ }
194
+
195
+ /* width:100% re-ties to the 104px-capped host (container is width:inherit,
196
+ which copies width but not max-width). Border/bg/states are shared above. */
197
+ .version-picker-button-small::part(container) {
198
+ width: 100%;
199
+ height: var(--dx-g-spacing-lg);
200
+ padding: 0 var(--dx-g-spacing-xs);
201
+ }
202
+
203
+ .selected-version-small {
204
+ flex: 1;
205
+ min-width: 0;
206
+ }
207
+
208
+ .version-picker-dot {
209
+ flex: 0 0 auto;
210
+ width: var(--dx-g-spacing-sm);
211
+ height: var(--dx-g-spacing-sm);
212
+ margin-right: var(--dx-g-spacing-xs);
213
+ border-radius: 50%;
214
+ }
215
+
216
+ .version-picker-dot-latest {
217
+ background: var(--dx-g-green-vibrant-60);
218
+ }
219
+
220
+ .version-picker-dot-not-latest {
221
+ background: var(--dx-g-yellow-vibrant-80);
222
+ }
223
+
224
+ /* inline-block (not flex) so the label can truncate; line-height centers it. */
225
+ .version-picker-readonly {
226
+ box-sizing: border-box;
227
+ display: inline-block;
228
+ width: 100%;
229
+ height: var(--dx-g-spacing-xl);
230
+ margin: 0;
231
+ padding: 0 var(--dx-g-spacing-sm);
232
+ border: 1px solid var(--dx-g-gray-80);
233
+ border-radius: var(--dx-g-spacing-xs);
234
+ overflow: hidden;
235
+ color: var(--dx-g-gray-10);
236
+ text-overflow: ellipsis;
237
+ white-space: nowrap;
238
+ font: var(--dx-g-font-normal) var(--dx-g-text-sm) / var(--dx-g-spacing-xl)
239
+ var(--dx-g-font-sans),
240
+ sans-serif;
241
+ }
242
+
243
+ .version-picker-readonly-small {
244
+ width: fit-content;
245
+ max-width: 104px;
246
+ height: var(--dx-g-spacing-lg);
247
+ padding: 0 var(--dx-g-spacing-xs);
248
+ font: var(--dx-g-font-normal) var(--dx-g-text-xs) / var(--dx-g-spacing-lg)
249
+ var(--dx-g-font-sans),
250
+ sans-serif;
64
251
  }
@@ -1,37 +1,70 @@
1
1
  <template>
2
- <div lwc:if={showVersionPicker} class="version-picker-container">
2
+ <div lwc:if={showVersionPicker} class={containerClass}>
3
+ <!-- Small read-only: plain value, no dropdown -->
4
+ <p
5
+ lwc:if={readOnly}
6
+ class={readOnlyClass}
7
+ title={selectedVersion.label}
8
+ >
9
+ {selectedVersion.label}
10
+ </p>
11
+
3
12
  <dx-dropdown
13
+ lwc:else
14
+ class={dropdownClass}
4
15
  options={versions}
5
16
  analytics-event="custEv_docVersionSelect"
6
17
  analytics-payload={analyticsPayload}
7
18
  value={selectedVersion.id}
8
- width="var(--doc-version-picker-width)"
19
+ width-mode={dropdownWidthMode}
9
20
  onchange={onVersionChange}
10
21
  >
11
22
  <dx-button
12
- class="version-picker-button"
23
+ class={triggerClass}
13
24
  variant="tertiary"
14
25
  size="small"
26
+ font="sans"
15
27
  icon-symbol="chevrondown"
16
- icon-size="medium"
28
+ icon-size={triggerIconSize}
29
+ aria-label={triggerAriaLabel}
17
30
  >
18
- <div class="selected-version">
19
- <p class="selected-version-label">
20
- {selectedVersion.label}
21
- </p>
22
- <template lwc:if={showLatestTag}>
23
- <dx-type-badge
24
- class="latest-badge"
25
- lwc:if={latestVersion}
26
- value="Latest"
27
- size="small"
28
- ></dx-type-badge>
29
- <dx-type-badge
30
- class="not-latest-badge"
31
- lwc:else
32
- value="Not Latest"
33
- size="small"
34
- ></dx-type-badge>
31
+ <div class={selectedVersionClass}>
32
+ <!-- Small: leading colored dot -->
33
+ <template lwc:if={small}>
34
+ <template lwc:if={showLatestTag}>
35
+ <span class={dotClass} aria-hidden="true"></span>
36
+ </template>
37
+ <p
38
+ class="selected-version-label"
39
+ title={selectedVersion.label}
40
+ >
41
+ {selectedVersion.label}
42
+ </p>
43
+ </template>
44
+ <!-- Default: trailing Latest/Not-Latest badge -->
45
+ <template lwc:else>
46
+ <p
47
+ class="selected-version-label"
48
+ title={selectedVersion.label}
49
+ >
50
+ {selectedVersion.label}
51
+ </p>
52
+ <template lwc:if={showLatestTag}>
53
+ <dx-type-badge
54
+ class="latest-badge"
55
+ lwc:if={latestVersion}
56
+ variant="status-success"
57
+ value="Latest"
58
+ size="small"
59
+ ></dx-type-badge>
60
+ <dx-type-badge
61
+ class="not-latest-badge"
62
+ lwc:else
63
+ variant="status-warning"
64
+ value="Not Latest"
65
+ size="small"
66
+ ></dx-type-badge>
67
+ </template>
35
68
  </template>
36
69
  </div>
37
70
  </dx-button>
@@ -1,4 +1,5 @@
1
1
  import { LightningElement, api, track } from "lwc";
2
+ import cx from "classnames";
2
3
 
3
4
  import { AnalyticsPayload, OptionWithNested } from "typings/custom";
4
5
 
@@ -12,6 +13,8 @@ export default class VersionPicker extends LightningElement {
12
13
  private _selectedVersion?: OptionWithNested;
13
14
  private _latestVersion: boolean = false;
14
15
  private _hideBadge: boolean = false;
16
+ private _small: boolean = false;
17
+ private _readOnly: boolean = false;
15
18
 
16
19
  @api
17
20
  get versions() {
@@ -51,14 +54,103 @@ export default class VersionPicker extends LightningElement {
51
54
  this._hideBadge = normalizeBoolean(value);
52
55
  }
53
56
 
57
+ // Opt-in compact "dot" variant (24px, abbreviated label). Off by default so
58
+ // existing consumers render the default trigger unchanged.
59
+ @api
60
+ get small() {
61
+ return this._small;
62
+ }
63
+
64
+ set small(value) {
65
+ this._small = normalizeBoolean(value);
66
+ }
67
+
68
+ // Opt-in read-only state: plain value, no dropdown menu.
69
+ @api
70
+ get readOnly() {
71
+ return this._readOnly;
72
+ }
73
+
74
+ set readOnly(value) {
75
+ this._readOnly = normalizeBoolean(value);
76
+ }
77
+
54
78
  private get showVersionPicker() {
55
79
  return this._versions && this._versions.length !== 0;
56
80
  }
57
81
 
82
+ private get containerClass(): string {
83
+ return this.small
84
+ ? "version-picker-container-small"
85
+ : "version-picker-container";
86
+ }
87
+
58
88
  private get showLatestTag(): boolean {
59
89
  return !this.hideBadge;
60
90
  }
61
91
 
92
+ private get readOnlyClass(): string {
93
+ return cx(
94
+ "version-picker-readonly",
95
+ this.small && "version-picker-readonly-small"
96
+ );
97
+ }
98
+
99
+ private get dotClass(): string {
100
+ return `version-picker-dot ${
101
+ this.latestVersion
102
+ ? "version-picker-dot-latest"
103
+ : "version-picker-dot-not-latest"
104
+ }`;
105
+ }
106
+
107
+ // Accessible name for the small trigger: includes the latest state so it
108
+ // isn't conveyed by the dot's color alone (WCAG 1.4.1). Omits the state
109
+ // when the indicator is hidden.
110
+ private get smallTriggerAriaLabel(): string {
111
+ const label = this.selectedVersion?.label ?? "";
112
+ if (!this.showLatestTag) {
113
+ return label;
114
+ }
115
+ return `${label}, ${this.latestVersion ? "Latest" : "Not Latest"}`;
116
+ }
117
+
118
+ // --- Per-variant values for the shared dropdown/button markup below. The
119
+ // small variant opts into extra classes and a compact chevron.
120
+
121
+ // Not cx(): the default must have NO class attribute (cx returns "", which
122
+ // would render class="" and break the frozen consumer snapshots).
123
+ private get dropdownClass(): string | undefined {
124
+ return this.small ? "version-picker-dropdown-small" : undefined;
125
+ }
126
+
127
+ // "full" matches the trigger (ToC width). "fit-end" sizes to the labels,
128
+ // caps at the ToC width, and opens inward.
129
+ private get dropdownWidthMode(): "full" | "fit-end" {
130
+ return this.small ? "fit-end" : "full";
131
+ }
132
+
133
+ private get triggerClass(): string {
134
+ return cx(
135
+ "version-picker-button",
136
+ this.small && "version-picker-button-small"
137
+ );
138
+ }
139
+
140
+ private get triggerIconSize(): string {
141
+ return this.small ? "xsmall" : "medium";
142
+ }
143
+
144
+ // Default returns "" (dx-button's own default) so the trigger's rendered
145
+ // aria-label is unchanged for the default variant.
146
+ private get triggerAriaLabel(): string {
147
+ return this.small ? this.smallTriggerAriaLabel : "";
148
+ }
149
+
150
+ private get selectedVersionClass(): string {
151
+ return cx("selected-version", this.small && "selected-version-small");
152
+ }
153
+
62
154
  private onVersionChange(e: CustomEvent) {
63
155
  this.dispatchEvent(new CustomEvent("change", { detail: e.detail }));
64
156
  }
@@ -1,10 +1,28 @@
1
1
  import { CoveoAnalyticsClient } from "coveo.analytics/dist/browser.mjs"; // This fix is required for Node 20 upgrade, so that Coveo analytics is loaded properly by LWR.
2
2
 
3
+ // Resolve against the current origin (version links are usually relative) and
4
+ // keep only http/https. Blocks executable schemes like javascript:/data: that
5
+ // would run when the innerHTML-rendered link is clicked
6
+ // encodeURI then keeps the value from breaking out of the href attribute.
7
+ const toSafeHref = (link: string): string => {
8
+ try {
9
+ const { protocol } = new URL(link, window.location.origin);
10
+ if (protocol === "http:" || protocol === "https:") {
11
+ return encodeURI(link);
12
+ }
13
+ } catch {
14
+ // Fall through to the rejection below.
15
+ }
16
+ console.warn(`Blocked unsafe version link: ${link}`);
17
+ return "#";
18
+ };
19
+
3
20
  export const oldVersionDocInfo = (latestVersionLink: string) => {
21
+ const safeLink = toSafeHref(latestVersionLink);
4
22
  return {
5
23
  title: "Newer Version Available",
6
- body: `This content describes an older version of this product.
7
- <a style="font-weight: bold;" href="${latestVersionLink}">View Latest</a>`
24
+ body: `This content describes an older version of this product.
25
+ <a style="font-weight: bold;" href="${safeLink}">View Latest</a>`
8
26
  };
9
27
  };
10
28
 
package/LICENSE DELETED
@@ -1,12 +0,0 @@
1
- Copyright (c) 2020, Salesforce.com, Inc.
2
- All rights reserved.
3
-
4
- Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
5
-
6
- * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
7
-
8
- * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
9
-
10
- * Neither the name of Salesforce.com nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
11
-
12
- THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.