@salesforcedevs/dx-components 1.40.11 → 1.42.0-final-test
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/lwc.config.json +4 -2
- package/package.json +46 -47
- package/src/modules/dx/button/button.css +2 -2
- package/src/modules/dx/dropdown/dropdown.css +29 -1
- package/src/modules/dx/dropdown/dropdown.html +1 -1
- package/src/modules/dx/dropdown/dropdown.ts +16 -1
- package/src/modules/dx/dropdownOption/dropdownOption.css +30 -6
- package/src/modules/dx/filterMenu/filterMenu.css +19 -0
- package/src/modules/dx/filterMenu/filterMenu.html +17 -2
- package/src/modules/dx/filterMenu/filterMenu.ts +43 -0
- package/src/modules/dx/popover/popover.ts +119 -7
- package/src/modules/dx/searchHybrid/searchHybrid.css +56 -61
- package/src/modules/dx/searchHybrid/searchHybrid.html +133 -112
- package/src/modules/dx/searchHybrid/searchHybrid.mock.ts +244 -0
- package/src/modules/dx/searchHybrid/searchHybrid.ts +81 -26
- package/src/modules/dx/searchResults/searchResults.css +119 -6
- package/src/modules/dx/searchResults/searchResults.html +89 -91
- package/src/modules/dx/searchResults/searchResults.ts +200 -473
- package/src/modules/dx/sidebar/sidebar.css +1 -0
- package/src/modules/dx/sidebar/sidebar.html +2 -5
- package/src/modules/dx/sidebar/sidebar.ts +44 -29
- package/src/modules/dx/sidebarOld/sidebarOld.html +1 -0
- package/src/modules/dx/sidebarSearch/sidebarSearch.ts +163 -342
- package/src/modules/dx/sidebarSearchResult/sidebarSearchResult.css +61 -23
- package/src/modules/dx/sidebarSearchResult/sidebarSearchResult.ts +12 -4
- package/src/modules/dx/tree/tree.html +1 -0
- package/src/modules/dx/tree/tree.ts +5 -1
- package/src/modules/dx/tree/treeHandler.ts +92 -13
- package/src/modules/dx/treeItem/treeItem.css +41 -0
- package/src/modules/dx/treeItem/treeItem.html +17 -2
- package/src/modules/dx/treeItem/treeItem.ts +109 -11
- package/src/modules/dx/treeTile/treeTile.css +57 -0
- package/src/modules/dx/treeTile/treeTile.ts +12 -3
- package/src/modules/dx/typeBadge/typeBadge.ts +23 -2
- package/src/modules/dx/versionPicker/versionPicker.css +248 -0
- package/src/modules/dx/versionPicker/versionPicker.html +73 -0
- package/src/modules/dx/versionPicker/versionPicker.ts +157 -0
- package/src/modules/dxHelpers/commonSidebar/commonSidebar.css +3 -3
- package/src/modules/dxHelpers/commonTreeItem/commonTreeItem.css +4 -0
- package/src/modules/dxHelpers/typeBadge/typeBadge.css +22 -0
- package/src/modules/dxUtils/openSearch/openSearch.ts +343 -0
- package/src/modules/dxUtils/topicVersion/topicVersion.ts +758 -0
- package/LICENSE +0 -12
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
export const TOPIC_VERSION_SESSION_KEY = "sfdocs-topic-version-selections";
|
|
2
|
+
export const DOCS_PATH_SEGMENT = "docs";
|
|
3
|
+
// Locale position in the path segments: `/docs/<locale>` for Docs,
|
|
4
|
+
// `/docs/<brand>/<locale>` for Guides/References.
|
|
5
|
+
export const DOCS_LOCALE_SEGMENT_INDEX = 1;
|
|
6
|
+
export const REFERENCES_LOCALE_SEGMENT_INDEX = 2;
|
|
7
|
+
|
|
8
|
+
export type TopicVersionOption = {
|
|
9
|
+
id: string;
|
|
10
|
+
label: string;
|
|
11
|
+
deprecated?: boolean;
|
|
12
|
+
selected?: boolean;
|
|
13
|
+
link?: {
|
|
14
|
+
href?: string;
|
|
15
|
+
target?: string;
|
|
16
|
+
};
|
|
17
|
+
/** Topics authored under this version. Absent when the client only rewrites hrefs. */
|
|
18
|
+
// VersionedNode also points back here through `versions`.
|
|
19
|
+
// eslint-disable-next-line no-use-before-define
|
|
20
|
+
children?: VersionedNode[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type VersionedNode = {
|
|
24
|
+
name?: string;
|
|
25
|
+
label?: string;
|
|
26
|
+
key?: string;
|
|
27
|
+
link?: {
|
|
28
|
+
href?: string;
|
|
29
|
+
target?: string;
|
|
30
|
+
};
|
|
31
|
+
children?: VersionedNode[];
|
|
32
|
+
versions?: TopicVersionOption[];
|
|
33
|
+
/** Set on Redoc spec tiles after the docs layout decorates the TOC. */
|
|
34
|
+
showForwardArrow?: boolean;
|
|
35
|
+
/** Docs TOC topic type (`markdown` | `spec`). May be stripped after decoration. */
|
|
36
|
+
topicType?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type VersionPathDelta = {
|
|
40
|
+
prefix: string;
|
|
41
|
+
inserted: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function isBrowser(): boolean {
|
|
45
|
+
return typeof window !== "undefined";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function normalizePath(href?: string): string {
|
|
49
|
+
if (!href) {
|
|
50
|
+
return "";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const url = href.startsWith("http")
|
|
55
|
+
? new URL(href)
|
|
56
|
+
: new URL(
|
|
57
|
+
href,
|
|
58
|
+
isBrowser() ? window.location.origin : "http://localhost"
|
|
59
|
+
);
|
|
60
|
+
return url.pathname.replace(/\/$/, "") || "/";
|
|
61
|
+
} catch {
|
|
62
|
+
return href.replace(/\/$/, "");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function hrefMatchesCurrentPage(href?: string): boolean {
|
|
67
|
+
if (!isBrowser()) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
return normalizePath(href) === normalizePath(window.location.pathname);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function readTopicVersionSelections(): Record<string, string> {
|
|
74
|
+
if (!isBrowser() || !window.sessionStorage) {
|
|
75
|
+
return {};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const raw = window.sessionStorage.getItem(TOPIC_VERSION_SESSION_KEY);
|
|
80
|
+
return raw ? JSON.parse(raw) : {};
|
|
81
|
+
} catch {
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function persistTopicVersion(
|
|
87
|
+
topicName: string,
|
|
88
|
+
versionId: string
|
|
89
|
+
): void {
|
|
90
|
+
if (!topicName || !versionId || !isBrowser() || !window.sessionStorage) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const stored = readTopicVersionSelections();
|
|
95
|
+
stored[topicName] = versionId;
|
|
96
|
+
window.sessionStorage.setItem(
|
|
97
|
+
TOPIC_VERSION_SESSION_KEY,
|
|
98
|
+
JSON.stringify(stored)
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Normalizes the path, then removes `locale` when it sits in the docs or
|
|
104
|
+
* references locale position, so every locale shares one session key.
|
|
105
|
+
*/
|
|
106
|
+
function stripLocaleFromPath(path: string, locale?: string): string {
|
|
107
|
+
const normalized = normalizePath(path);
|
|
108
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
109
|
+
const normalizedLocale = locale?.toLowerCase();
|
|
110
|
+
if (!normalizedLocale || segments[0] !== DOCS_PATH_SEGMENT) {
|
|
111
|
+
return normalized;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const localeIndex = [
|
|
115
|
+
DOCS_LOCALE_SEGMENT_INDEX,
|
|
116
|
+
REFERENCES_LOCALE_SEGMENT_INDEX
|
|
117
|
+
].find((index) => segments[index]?.toLowerCase() === normalizedLocale);
|
|
118
|
+
if (localeIndex === undefined) {
|
|
119
|
+
return normalized;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
segments.splice(localeIndex, 1);
|
|
123
|
+
return `/${segments.join("/")}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Stable session key for a versioned topic. Parent-only sections have an empty
|
|
128
|
+
* `name`/`href`, so fall back to the latest switching URL (then the label).
|
|
129
|
+
* The locale is stripped so a selection carries across locales.
|
|
130
|
+
*/
|
|
131
|
+
export function getTopicVersionKey(
|
|
132
|
+
node: VersionedNode | undefined,
|
|
133
|
+
locale?: string
|
|
134
|
+
): string {
|
|
135
|
+
if (!node) {
|
|
136
|
+
return "";
|
|
137
|
+
}
|
|
138
|
+
if (node.name) {
|
|
139
|
+
return stripLocaleFromPath(node.name, locale);
|
|
140
|
+
}
|
|
141
|
+
const latestHref = node.versions?.[0]?.link?.href;
|
|
142
|
+
if (latestHref) {
|
|
143
|
+
return stripLocaleFromPath(latestHref, locale);
|
|
144
|
+
}
|
|
145
|
+
return node.label || "";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Section header with children but no page of its own. */
|
|
149
|
+
export function isParentOnlyTopic(node: VersionedNode | undefined): boolean {
|
|
150
|
+
if (!node) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
const ownHref = node.link?.href || node.name;
|
|
154
|
+
return !ownHref && !!node.children?.length;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isSpecNode(node: VersionedNode): boolean {
|
|
158
|
+
return node.showForwardArrow === true || node.topicType === "spec";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* First descendant that has a markdown page (own href/name). Specs (Redoc)
|
|
163
|
+
* and external links are skipped so parent-only sections land on the first
|
|
164
|
+
* markdown topic even when a spec is authored first in the TOC.
|
|
165
|
+
*/
|
|
166
|
+
export function firstContentNode(
|
|
167
|
+
node: VersionedNode | undefined
|
|
168
|
+
): VersionedNode | undefined {
|
|
169
|
+
if (!node || node.link?.target === "_blank" || isSpecNode(node)) {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
if (node.link?.href || node.name) {
|
|
173
|
+
return node;
|
|
174
|
+
}
|
|
175
|
+
for (const child of node.children || []) {
|
|
176
|
+
const found = firstContentNode(child);
|
|
177
|
+
if (found) {
|
|
178
|
+
return found;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function shouldShowTopicVersionPicker(
|
|
185
|
+
versions?: TopicVersionOption[],
|
|
186
|
+
showForwardArrow?: boolean
|
|
187
|
+
): boolean {
|
|
188
|
+
return !showForwardArrow && Array.isArray(versions) && versions.length > 1;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Difference between a picker's latest switching URL and an older one.
|
|
193
|
+
*
|
|
194
|
+
* latest: /docs/project/errors.html
|
|
195
|
+
* v1: /docs/project/v1/errors.html
|
|
196
|
+
* → prefix `/docs/project/`, inserted `v1/`
|
|
197
|
+
*/
|
|
198
|
+
function getVersionPathDelta(
|
|
199
|
+
latestPath: string,
|
|
200
|
+
selectedPath: string
|
|
201
|
+
): VersionPathDelta | undefined {
|
|
202
|
+
const latestDirEnd = latestPath.lastIndexOf("/") + 1;
|
|
203
|
+
if (latestDirEnd <= 0) {
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const prefix = latestPath.slice(0, latestDirEnd);
|
|
208
|
+
const latestSlug = latestPath.slice(latestDirEnd);
|
|
209
|
+
if (!selectedPath.startsWith(prefix) || !latestSlug) {
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const selectedAfterPrefix = selectedPath.slice(prefix.length);
|
|
214
|
+
if (!selectedAfterPrefix.endsWith(latestSlug)) {
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const inserted = selectedAfterPrefix.slice(
|
|
219
|
+
0,
|
|
220
|
+
selectedAfterPrefix.length - latestSlug.length
|
|
221
|
+
);
|
|
222
|
+
if (!inserted) {
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { prefix, inserted };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function applyVersionPathDelta(
|
|
230
|
+
hrefPath: string,
|
|
231
|
+
delta: VersionPathDelta
|
|
232
|
+
): string | undefined {
|
|
233
|
+
if (!hrefPath.startsWith(delta.prefix)) {
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const rest = hrefPath.slice(delta.prefix.length);
|
|
238
|
+
if (rest.startsWith(delta.inserted)) {
|
|
239
|
+
return hrefPath;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return delta.prefix + delta.inserted + rest;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function stripVersionPathDelta(
|
|
246
|
+
hrefPath: string,
|
|
247
|
+
delta: VersionPathDelta
|
|
248
|
+
): string | undefined {
|
|
249
|
+
if (!hrefPath.startsWith(delta.prefix)) {
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const rest = hrefPath.slice(delta.prefix.length);
|
|
254
|
+
if (!rest.startsWith(delta.inserted)) {
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return delta.prefix + rest.slice(delta.inserted.length);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Maps a canonical (latest) href onto another version using the picker's
|
|
263
|
+
* latest vs selected switching URLs as the template.
|
|
264
|
+
*
|
|
265
|
+
* latest parent: /docs/project/errors.html
|
|
266
|
+
* v1 parent: /docs/project/v1/errors.html
|
|
267
|
+
* latest child: /docs/project/extra-child-a.html
|
|
268
|
+
* → v1 child: /docs/project/v1/extra-child-a.html
|
|
269
|
+
*/
|
|
270
|
+
export function applyVersionToHref(
|
|
271
|
+
canonicalHref: string | undefined,
|
|
272
|
+
selectedVersion: TopicVersionOption | undefined,
|
|
273
|
+
versions?: TopicVersionOption[]
|
|
274
|
+
): string | undefined {
|
|
275
|
+
if (!canonicalHref) {
|
|
276
|
+
return canonicalHref;
|
|
277
|
+
}
|
|
278
|
+
if (!selectedVersion || !versions?.length) {
|
|
279
|
+
return canonicalHref;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const latest = versions[0];
|
|
283
|
+
if (!latest || selectedVersion.id === latest.id) {
|
|
284
|
+
return canonicalHref;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const latestPath = normalizePath(latest.link?.href);
|
|
288
|
+
const selectedPath = normalizePath(selectedVersion.link?.href);
|
|
289
|
+
const hrefPath = normalizePath(canonicalHref);
|
|
290
|
+
|
|
291
|
+
if (!latestPath || !selectedPath || latestPath === selectedPath) {
|
|
292
|
+
return insertVersionBeforeSlug(canonicalHref, selectedVersion.id);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const delta = getVersionPathDelta(latestPath, selectedPath);
|
|
296
|
+
return (
|
|
297
|
+
(delta && applyVersionPathDelta(hrefPath, delta)) ||
|
|
298
|
+
insertVersionBeforeSlug(canonicalHref, selectedVersion.id)
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function insertVersionBeforeSlug(href: string, versionId: string): string {
|
|
303
|
+
const path = normalizePath(href);
|
|
304
|
+
const lastSlash = path.lastIndexOf("/");
|
|
305
|
+
if (lastSlash <= 0 || !versionId) {
|
|
306
|
+
return href;
|
|
307
|
+
}
|
|
308
|
+
const dir = path.slice(0, lastSlash);
|
|
309
|
+
const slug = path.slice(lastSlash);
|
|
310
|
+
if (dir.endsWith(`/${versionId}`)) {
|
|
311
|
+
return path;
|
|
312
|
+
}
|
|
313
|
+
return `${dir}/${versionId}${slug}`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function toCanonicalHref(
|
|
317
|
+
href: string | undefined,
|
|
318
|
+
versions?: TopicVersionOption[]
|
|
319
|
+
): string {
|
|
320
|
+
if (!href) {
|
|
321
|
+
return "";
|
|
322
|
+
}
|
|
323
|
+
if (!versions?.length) {
|
|
324
|
+
return normalizePath(href);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const latestPath = normalizePath(versions[0].link?.href);
|
|
328
|
+
const hrefPath = normalizePath(href);
|
|
329
|
+
if (!latestPath) {
|
|
330
|
+
return hrefPath;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
for (const version of versions) {
|
|
334
|
+
// The latest is also served under `/<latestId>/`, so that form is
|
|
335
|
+
// stripped too.
|
|
336
|
+
const versionedPath =
|
|
337
|
+
version.id === versions[0].id
|
|
338
|
+
? insertVersionBeforeSlug(latestPath, version.id)
|
|
339
|
+
: normalizePath(version.link?.href);
|
|
340
|
+
const delta = getVersionPathDelta(latestPath, versionedPath);
|
|
341
|
+
const stripped = delta && stripVersionPathDelta(hrefPath, delta);
|
|
342
|
+
if (stripped) {
|
|
343
|
+
return stripped;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return hrefPath;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function hrefMatchesVersion(
|
|
351
|
+
href: string | undefined,
|
|
352
|
+
version: TopicVersionOption,
|
|
353
|
+
versions: TopicVersionOption[]
|
|
354
|
+
): boolean {
|
|
355
|
+
if (hrefMatchesCurrentPage(applyVersionToHref(href, version, versions))) {
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
358
|
+
// The server serves the latest under its canonical URL and also under
|
|
359
|
+
// `/<latestId>/`, so the versioned form must select the latest too.
|
|
360
|
+
return (
|
|
361
|
+
!!href &&
|
|
362
|
+
version.id === versions[0]?.id &&
|
|
363
|
+
hrefMatchesCurrentPage(insertVersionBeforeSlug(href, version.id))
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function collectSubtreeCanonicalHrefs(
|
|
368
|
+
node: VersionedNode,
|
|
369
|
+
includeSelf = true
|
|
370
|
+
): string[] {
|
|
371
|
+
const hrefs: string[] = [];
|
|
372
|
+
const own = node.link?.href || node.name;
|
|
373
|
+
if (includeSelf && own) {
|
|
374
|
+
hrefs.push(own);
|
|
375
|
+
}
|
|
376
|
+
const collectChildHrefs = (children?: VersionedNode[]) => {
|
|
377
|
+
for (const child of children || []) {
|
|
378
|
+
if (child.versions?.length || child.link?.target === "_blank") {
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
hrefs.push(...collectSubtreeCanonicalHrefs(child));
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
collectChildHrefs(node.children);
|
|
385
|
+
// Latest topics already live on `children`, so skip versions[0]. Older
|
|
386
|
+
// versions can add topics that are not in that list. Those hrefs are
|
|
387
|
+
// version-specific (`/docs/project/v1/only-on-v1.html`), not canonical.
|
|
388
|
+
for (const version of (node.versions || []).slice(1)) {
|
|
389
|
+
collectChildHrefs(version.children);
|
|
390
|
+
}
|
|
391
|
+
return hrefs;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function isCurrentPageInSubtree(
|
|
395
|
+
node: VersionedNode,
|
|
396
|
+
versions?: TopicVersionOption[]
|
|
397
|
+
): boolean {
|
|
398
|
+
if (!isBrowser() || !versions?.length) {
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return collectSubtreeCanonicalHrefs(node).some((href) =>
|
|
403
|
+
versions.some((version) => hrefMatchesVersion(href, version, versions))
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* True when the open URL is a latest-version path, or an older path whose
|
|
409
|
+
* switching URL shares the latest slug so the version segment can be removed.
|
|
410
|
+
* Parent-only topics whose versions open on different slugs leave the older
|
|
411
|
+
* URL unchanged, and the version directory is still in the path.
|
|
412
|
+
*
|
|
413
|
+
* `hrefPath` and `canonical` are the open page normalized once by the caller.
|
|
414
|
+
*/
|
|
415
|
+
function canMapCurrentPageToLatest(
|
|
416
|
+
hrefPath: string,
|
|
417
|
+
canonical: string,
|
|
418
|
+
versions: TopicVersionOption[]
|
|
419
|
+
): boolean {
|
|
420
|
+
if (canonical !== hrefPath) {
|
|
421
|
+
return true;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const latest = versions[0];
|
|
425
|
+
if (!latest) {
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return !versions.some((version) => {
|
|
430
|
+
if (version.id === latest.id) {
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
const selectedPath = normalizePath(version.link?.href);
|
|
434
|
+
return (
|
|
435
|
+
(!!selectedPath && hrefPath === selectedPath) ||
|
|
436
|
+
(!!version.id &&
|
|
437
|
+
(hrefPath.includes(`/${version.id}/`) ||
|
|
438
|
+
hrefPath.endsWith(`/${version.id}`)))
|
|
439
|
+
);
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function pageSlug(href: string | undefined): string {
|
|
444
|
+
const path = normalizePath(href);
|
|
445
|
+
const slash = path.lastIndexOf("/");
|
|
446
|
+
return slash >= 0 ? path.slice(slash + 1) : path;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* The open page inside one version. Each version already carries its entry
|
|
451
|
+
* URL and its own child hrefs. Match the slug, then use the entry URL when
|
|
452
|
+
* that page is absent from the version.
|
|
453
|
+
*/
|
|
454
|
+
function hrefInVersion(
|
|
455
|
+
version: TopicVersionOption,
|
|
456
|
+
slug: string
|
|
457
|
+
): string | undefined {
|
|
458
|
+
if (!slug) {
|
|
459
|
+
return undefined;
|
|
460
|
+
}
|
|
461
|
+
const entry = version.link?.href;
|
|
462
|
+
if (entry && pageSlug(entry) === slug) {
|
|
463
|
+
return normalizePath(entry);
|
|
464
|
+
}
|
|
465
|
+
return findChildHref(version.children, slug);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function findChildHref(
|
|
469
|
+
children: VersionedNode[] | undefined,
|
|
470
|
+
slug: string
|
|
471
|
+
): string | undefined {
|
|
472
|
+
for (const child of children || []) {
|
|
473
|
+
if (child.versions?.length || child.link?.target === "_blank") {
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
const href = child.link?.href || child.name;
|
|
477
|
+
if (href && pageSlug(href) === slug) {
|
|
478
|
+
return normalizePath(href);
|
|
479
|
+
}
|
|
480
|
+
const nested = findChildHref(child.children, slug);
|
|
481
|
+
if (nested) {
|
|
482
|
+
return nested;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return undefined;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function walkVersionChildren(
|
|
489
|
+
children: VersionedNode[] | undefined,
|
|
490
|
+
visit: (node: VersionedNode) => boolean
|
|
491
|
+
): boolean {
|
|
492
|
+
for (const child of children || []) {
|
|
493
|
+
if (child.versions?.length || child.link?.target === "_blank") {
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
if (visit(child) || walkVersionChildren(child.children, visit)) {
|
|
497
|
+
return true;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function findNodeBySlug(
|
|
504
|
+
children: VersionedNode[] | undefined,
|
|
505
|
+
slug: string
|
|
506
|
+
): VersionedNode | undefined {
|
|
507
|
+
let found: VersionedNode | undefined;
|
|
508
|
+
walkVersionChildren(children, (node) => {
|
|
509
|
+
const href = node.link?.href || node.name;
|
|
510
|
+
if (href && pageSlug(href) === slug) {
|
|
511
|
+
found = node;
|
|
512
|
+
return true;
|
|
513
|
+
}
|
|
514
|
+
return false;
|
|
515
|
+
});
|
|
516
|
+
return found;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function specNodes(children: VersionedNode[] | undefined): VersionedNode[] {
|
|
520
|
+
const specs: VersionedNode[] = [];
|
|
521
|
+
walkVersionChildren(children, (node) => {
|
|
522
|
+
if (isSpecNode(node)) {
|
|
523
|
+
specs.push(node);
|
|
524
|
+
}
|
|
525
|
+
return false;
|
|
526
|
+
});
|
|
527
|
+
return specs;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function nodeHref(node: VersionedNode | undefined): string | undefined {
|
|
531
|
+
const href = node?.link?.href || node?.name;
|
|
532
|
+
return href ? normalizePath(href) : undefined;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Spec in `version` that corresponds to the open spec.
|
|
537
|
+
* Same title first. When each version has exactly one spec, that spec is
|
|
538
|
+
* the counterpart even if the title changed. Returns undefined for a
|
|
539
|
+
* markdown page so the existing slug rewrite stays in place.
|
|
540
|
+
*/
|
|
541
|
+
function specCounterpartHref(
|
|
542
|
+
version: TopicVersionOption,
|
|
543
|
+
openNode: VersionedNode | undefined,
|
|
544
|
+
openVersion: TopicVersionOption | undefined
|
|
545
|
+
): string | undefined {
|
|
546
|
+
if (!openNode || !isSpecNode(openNode) || !version.children) {
|
|
547
|
+
return undefined;
|
|
548
|
+
}
|
|
549
|
+
const specs = specNodes(version.children);
|
|
550
|
+
const sameTitle = specs.find(
|
|
551
|
+
(spec) => spec.label && spec.label === openNode.label
|
|
552
|
+
);
|
|
553
|
+
if (sameTitle) {
|
|
554
|
+
return nodeHref(sameTitle);
|
|
555
|
+
}
|
|
556
|
+
const openSpecs = specNodes(openVersion?.children);
|
|
557
|
+
if (specs.length === 1 && openSpecs.length === 1) {
|
|
558
|
+
return nodeHref(specs[0]);
|
|
559
|
+
}
|
|
560
|
+
return undefined;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Href for one picker option, or for View Latest.
|
|
565
|
+
* Same-slug switching URLs still rewrite the open page onto that version.
|
|
566
|
+
* When those slugs differ, use the target version's own copy of this page.
|
|
567
|
+
* A spec whose file name changed uses that version's spec instead of a
|
|
568
|
+
* rewritten slug. A spec the target version does not have keeps its slug
|
|
569
|
+
* on that version, so the missing page 404s.
|
|
570
|
+
*
|
|
571
|
+
* The canonical path and slug depend only on the open page, so they are
|
|
572
|
+
* computed once and reused for every version.
|
|
573
|
+
*/
|
|
574
|
+
function hrefForVersionSwitch(
|
|
575
|
+
pathname: string,
|
|
576
|
+
versions: TopicVersionOption[]
|
|
577
|
+
): (version: TopicVersionOption) => string | undefined {
|
|
578
|
+
const hrefPath = normalizePath(pathname);
|
|
579
|
+
const canonical = toCanonicalHref(pathname, versions);
|
|
580
|
+
const canMap = canMapCurrentPageToLatest(hrefPath, canonical, versions);
|
|
581
|
+
const slug = pageSlug(pathname);
|
|
582
|
+
const openVersion = versions.find(
|
|
583
|
+
(version) => version.children && findNodeBySlug(version.children, slug)
|
|
584
|
+
);
|
|
585
|
+
const openNode = openVersion
|
|
586
|
+
? findNodeBySlug(openVersion.children, slug)
|
|
587
|
+
: undefined;
|
|
588
|
+
|
|
589
|
+
return (version) => {
|
|
590
|
+
const bySlug = hrefInVersion(version, slug);
|
|
591
|
+
if (bySlug) {
|
|
592
|
+
return bySlug;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// A renamed spec is a real page in this version. A spec with no
|
|
596
|
+
// counterpart keeps its slug under this version so the request 404s
|
|
597
|
+
// instead of opening the spec the reader is already on.
|
|
598
|
+
const counterpart = specCounterpartHref(version, openNode, openVersion);
|
|
599
|
+
if (counterpart) {
|
|
600
|
+
return counterpart;
|
|
601
|
+
}
|
|
602
|
+
if (openNode && isSpecNode(openNode)) {
|
|
603
|
+
return applyVersionToHref(canonical, version, versions);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const latest = versions[0];
|
|
607
|
+
const onLatest = !latest || version.id === latest.id;
|
|
608
|
+
if (canMap && (onLatest || sharesSlugDelta(latest, version))) {
|
|
609
|
+
const rewritten =
|
|
610
|
+
applyVersionToHref(canonical, version, versions) ||
|
|
611
|
+
version.link?.href;
|
|
612
|
+
if (rewritten) {
|
|
613
|
+
return rewritten;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return version.link?.href;
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** True when this version's switching URL differs from latest by a version segment. */
|
|
621
|
+
function sharesSlugDelta(
|
|
622
|
+
latest: TopicVersionOption | undefined,
|
|
623
|
+
version: TopicVersionOption
|
|
624
|
+
): boolean {
|
|
625
|
+
const latestPath = normalizePath(latest?.link?.href);
|
|
626
|
+
const selectedPath = normalizePath(version.link?.href);
|
|
627
|
+
return !!(
|
|
628
|
+
latestPath &&
|
|
629
|
+
selectedPath &&
|
|
630
|
+
getVersionPathDelta(latestPath, selectedPath)
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Picker option hrefs. When the current page is this topic or a descendant,
|
|
636
|
+
* each option points at that same topic in the chosen version so the child
|
|
637
|
+
* stays selected. Otherwise keep the backend switching URL (parent page, or
|
|
638
|
+
* first content child for parent-only sections).
|
|
639
|
+
*/
|
|
640
|
+
export function getVersionPickerOptions(
|
|
641
|
+
node: VersionedNode | undefined
|
|
642
|
+
): TopicVersionOption[] | undefined {
|
|
643
|
+
const versions = node?.versions;
|
|
644
|
+
if (!node || !versions?.length) {
|
|
645
|
+
return versions;
|
|
646
|
+
}
|
|
647
|
+
if (!isCurrentPageInSubtree(node, versions)) {
|
|
648
|
+
return versions;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const hrefForVersion = hrefForVersionSwitch(
|
|
652
|
+
window.location.pathname,
|
|
653
|
+
versions
|
|
654
|
+
);
|
|
655
|
+
return versions.map((version) => ({
|
|
656
|
+
...version,
|
|
657
|
+
link: {
|
|
658
|
+
...version.link,
|
|
659
|
+
href: hrefForVersion(version)
|
|
660
|
+
}
|
|
661
|
+
}));
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Version whose page (or a descendant of it) is the current URL.
|
|
666
|
+
* Ignores the session-stored picker selection, which can remember an older
|
|
667
|
+
* version while the reader is still on the latest page.
|
|
668
|
+
*/
|
|
669
|
+
export function versionForCurrentPage(
|
|
670
|
+
node: VersionedNode | undefined
|
|
671
|
+
): TopicVersionOption | undefined {
|
|
672
|
+
const versions = node?.versions;
|
|
673
|
+
if (!node || !versions?.length || !isBrowser()) {
|
|
674
|
+
return undefined;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const hrefs = collectSubtreeCanonicalHrefs(node);
|
|
678
|
+
const matches = (version: TopicVersionOption) =>
|
|
679
|
+
hrefMatchesCurrentPage(version.link?.href) ||
|
|
680
|
+
hrefs.some((href) => hrefMatchesVersion(href, version, versions));
|
|
681
|
+
|
|
682
|
+
// Older versions first. A rewritten child URL, and a version-specific href
|
|
683
|
+
// from an older version's children, also match latest (that comparison
|
|
684
|
+
// leaves the path unchanged). Checking older versions first keeps those
|
|
685
|
+
// pages on the older version.
|
|
686
|
+
const older = versions.find(
|
|
687
|
+
(version) => version.id !== versions[0].id && matches(version)
|
|
688
|
+
);
|
|
689
|
+
if (older) {
|
|
690
|
+
return older;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
return matches(versions[0]) ? versions[0] : undefined;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Deepest versioned topic that contains the current page, with the version
|
|
698
|
+
* the URL selects. Nested versioned topics own their pages.
|
|
699
|
+
*/
|
|
700
|
+
export function findTopicVersionForCurrentPage(
|
|
701
|
+
nodes: VersionedNode[] | undefined | null
|
|
702
|
+
): { node: VersionedNode; version: TopicVersionOption } | undefined {
|
|
703
|
+
let match: { node: VersionedNode; version: TopicVersionOption } | undefined;
|
|
704
|
+
const traverseNodes = (list?: VersionedNode[] | null) => {
|
|
705
|
+
for (const node of list || []) {
|
|
706
|
+
const version = versionForCurrentPage(node);
|
|
707
|
+
if (version) {
|
|
708
|
+
match = { node, version };
|
|
709
|
+
}
|
|
710
|
+
traverseNodes(node.children);
|
|
711
|
+
// Latest children are already on node.children.
|
|
712
|
+
for (const versionOption of (node.versions || []).slice(1)) {
|
|
713
|
+
traverseNodes(versionOption.children);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
};
|
|
717
|
+
traverseNodes(nodes);
|
|
718
|
+
return match;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Current page in the latest topic version. Uses the latest copy of this
|
|
723
|
+
* page when the server sent it, and the latest entry URL otherwise.
|
|
724
|
+
*/
|
|
725
|
+
export function latestHrefForCurrentPage(
|
|
726
|
+
versions: TopicVersionOption[] | undefined
|
|
727
|
+
): string | undefined {
|
|
728
|
+
const latest = versions?.[0];
|
|
729
|
+
if (!latest) {
|
|
730
|
+
return undefined;
|
|
731
|
+
}
|
|
732
|
+
if (!isBrowser()) {
|
|
733
|
+
return latest.link?.href;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
return hrefForVersionSwitch(window.location.pathname, versions)(latest);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
export function resolveSelectedVersion(
|
|
740
|
+
node: VersionedNode | undefined,
|
|
741
|
+
locale?: string
|
|
742
|
+
): TopicVersionOption | undefined {
|
|
743
|
+
const versions = node?.versions;
|
|
744
|
+
if (!node || !versions?.length) {
|
|
745
|
+
return undefined;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// The open page wins, including a canonical child opened from "View Latest".
|
|
749
|
+
// Session is only for a picker whose topic is not the current page.
|
|
750
|
+
const fromUrl = versionForCurrentPage(node);
|
|
751
|
+
if (fromUrl) {
|
|
752
|
+
return fromUrl;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const storedId =
|
|
756
|
+
readTopicVersionSelections()[getTopicVersionKey(node, locale)];
|
|
757
|
+
return versions.find((version) => version.id === storedId) || versions[0];
|
|
758
|
+
}
|