-
Notifications
You must be signed in to change notification settings - Fork 67k
Expand file tree
/
Copy pathjourney-path-resolver.ts
More file actions
355 lines (313 loc) · 11.3 KB
/
journey-path-resolver.ts
File metadata and controls
355 lines (313 loc) · 11.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
import { getPathWithoutLanguage, getPathWithoutVersion } from '@/frame/lib/path-utils'
import { renderContent } from '@/content-render/index'
import { executeWithFallback } from '@/languages/lib/render-with-fallback'
import getApplicableVersions from '@/versions/lib/get-applicable-versions'
import Permalink from '@/frame/lib/permalink'
import getLinkData from './get-link-data'
import type { Context, Page } from '@/types'
export interface JourneyContext {
trackId: string
trackName: string
trackTitle: string
journeyTitle: string
journeyPath: string
currentGuideIndex: number
numberOfGuides: number
nextTrackFirstGuide?: {
href: string
title: string
trackTitle: string
}
nextGuide?: {
href: string
title: string
}
prevGuide?: {
href: string
title: string
}
alternativeNextStep?: string
}
export interface JourneyTrack {
id: string
title: string
description?: string
guides: Array<{
href: string
title: string
}>
}
type JourneyPage = {
layout?: string
title?: string
permalink?: string
relativePath?: string
versions?: Record<string, string>
journeyTracks?: Array<{
id: string
title: string
description?: string
guides: Array<{
href: string
alternativeNextStep?: string
}>
}>
}
// Cache for journey pages so we only filter all pages once
let cachedJourneyPages: JourneyPage[] | null = null
// Cache for guide paths to quickly check if a page is part of any journey
let cachedGuidePaths: Set<string> | null = null
let hasDynamicGuides = false
function needsRendering(str: string): boolean {
return str.includes('{{') || str.includes('{%') || str.includes('[') || str.includes('<')
}
function getJourneyPages(pages: Record<string, Page>): JourneyPage[] {
if (!cachedJourneyPages) {
cachedJourneyPages = (Object.values(pages) as JourneyPage[]).filter(
(page) => page.journeyTracks && page.journeyTracks.length > 0,
)
}
return cachedJourneyPages
}
function getGuidePaths(pages: Record<string, Page>): Set<string> {
if (!cachedGuidePaths) {
cachedGuidePaths = new Set()
const journeyPages = getJourneyPages(pages)
for (const page of journeyPages) {
if (!page.journeyTracks) continue
for (const track of page.journeyTracks) {
if (!track.guides) continue
for (const guide of track.guides) {
if (needsRendering(guide.href)) {
hasDynamicGuides = true
} else {
cachedGuidePaths.add(normalizeGuidePath(guide.href))
}
}
}
}
}
return cachedGuidePaths
}
function normalizeGuidePath(path: string): string {
// First ensure we have a leading slash for consistent processing
const pathWithSlash = path.startsWith('/') ? path : `/${path}`
// Use the same normalization pattern as other middleware
const withoutVersion = getPathWithoutVersion(pathWithSlash)
const withoutLanguage = getPathWithoutLanguage(withoutVersion)
// Ensure we always return a path with leading slash for consistent comparison
return withoutLanguage && withoutLanguage.startsWith('/')
? withoutLanguage
: `/${withoutLanguage || path}`
}
/**
* Helper function to fetch guide data (href and title) for a given path
*/
async function fetchGuideData(
guidePath: string,
context: Context,
): Promise<{ href: string; title: string } | null> {
try {
const resultData = await getLinkData(guidePath, context, {
title: true,
intro: false,
fullTitle: false,
})
if (resultData && resultData.length > 0) {
const linkResult = resultData[0]
return {
href: linkResult.href,
title: linkResult.title || '',
}
}
} catch (error) {
console.warn('Could not get link data for guide:', guidePath, error)
}
return null
}
/**
* Resolves the journey context for a given article path.
*
* The journey context includes information about the journey track, the current
* guide's position within that track, and links to the previous and next
* guides if they exist.
*/
export async function resolveJourneyContext(
articlePath: string,
pages: Record<string, Page>,
context: Context,
currentJourneyPage?: JourneyPage,
): Promise<JourneyContext | null> {
const normalizedPath = normalizeGuidePath(articlePath)
// Optimization: Fast path check
// If we are not forcing a specific journey page, check our global cache
if (!currentJourneyPage) {
const guidePaths = getGuidePaths(pages)
// If we have no dynamic guides and this path isn't in our known guides, return null early.
if (!hasDynamicGuides && !guidePaths.has(normalizedPath)) {
return null
}
}
// Use the current journey page if provided, otherwise find all journey pages
const journeyPages = currentJourneyPage ? [currentJourneyPage] : getJourneyPages(pages)
let result: JourneyContext | null = null
// Search through all journey pages
for (const journeyPage of journeyPages) {
if (!journeyPage.journeyTracks) continue
// Check version compatibility - only show journey navigation if the current version
// is compatible with the journey landing page's versions (journey track articles
// currently inherit the journey landing page's versions)
if (journeyPage.versions) {
const journeyVersions = getApplicableVersions(journeyPage.versions)
if (!journeyVersions.includes(context.currentVersion || '')) {
continue // Skip this journey if current version is not supported
}
}
let trackIndex = 0
let foundTrackIndex = 0
for (const track of journeyPage.journeyTracks) {
if (!track.guides || !Array.isArray(track.guides)) continue
// Find if current article is in this track
let guideIndex = -1
for (let i = 0; i < track.guides.length; i++) {
const guidePath = track.guides[i].href
let renderedGuidePath = guidePath
// Handle Liquid conditionals in guide paths
if (needsRendering(guidePath)) {
try {
renderedGuidePath = await executeWithFallback(
context,
() => renderContent(guidePath, context, { textOnly: true }),
() => guidePath,
)
} catch {
// If rendering fails, use the original path rather than erroring
renderedGuidePath = guidePath
}
}
const normalizedGuidePath = normalizeGuidePath(renderedGuidePath)
if (normalizedGuidePath === normalizedPath) {
guideIndex = i
break
}
}
if (guideIndex >= 0) {
const alternativeNextStep = track.guides[guideIndex].alternativeNextStep || ''
let renderedAlternativeNextStep = alternativeNextStep
// Handle Liquid conditionals in branching text which likely has links
if (needsRendering(alternativeNextStep)) {
try {
renderedAlternativeNextStep = await executeWithFallback(
context,
() => renderContent(alternativeNextStep, context),
() => alternativeNextStep,
)
} catch {
// If rendering fails, use the original branching text rather than erroring
renderedAlternativeNextStep = alternativeNextStep
}
}
// Build the list of guides available for the current version.
// fetchGuideData returns null for guides that don't exist in the current version,
// so this filters out unavailable guides for correct counts and navigation.
const availableGuides = (
await Promise.all(
track.guides.map(async (guide, i) => {
const guideData = await fetchGuideData(guide.href, context)
return guideData ? { rawIndex: i, ...guideData } : null
}),
)
).filter((g): g is { rawIndex: number; href: string; title: string } => g !== null)
const filteredIndex = availableGuides.findIndex((g) => g.rawIndex === guideIndex)
const filteredCount = availableGuides.length
result = {
trackId: track.id,
trackName: track.id,
trackTitle: track.title,
journeyTitle: journeyPage.title || '',
journeyPath:
journeyPage.permalink || Permalink.relativePathToSuffix(journeyPage.relativePath || ''),
currentGuideIndex: filteredIndex >= 0 ? filteredIndex : guideIndex,
numberOfGuides: filteredCount,
alternativeNextStep: renderedAlternativeNextStep,
}
// Set up previous guide using the version-filtered list
if (filteredIndex > 0) {
const prev = availableGuides[filteredIndex - 1]
result.prevGuide = { href: prev.href, title: prev.title }
}
// Set up next guide using the version-filtered list
if (filteredIndex >= 0 && filteredIndex < filteredCount - 1) {
const next = availableGuides[filteredIndex + 1]
result.nextGuide = { href: next.href, title: next.title }
}
// Only populate nextTrackFirstGuide when on the last guide of the filtered track
if (filteredIndex === filteredCount - 1) {
foundTrackIndex = trackIndex
if (
journeyPage.journeyTracks[foundTrackIndex + 1] &&
journeyPage.journeyTracks[foundTrackIndex + 1].guides.length > 0
) {
const nextTrack = journeyPage.journeyTracks[foundTrackIndex + 1]
const nextTrackFirstGuidePath = nextTrack.guides[0].href
const guideData = await fetchGuideData(nextTrackFirstGuidePath, context)
if (guideData) {
result.nextTrackFirstGuide = {
...guideData,
trackTitle: nextTrack.title,
}
}
}
}
break // Found the track, stop searching
}
trackIndex++
}
if (result) break // Found the journey, stop searching
}
return result
}
/**
* Resolves journey tracks data from frontmatter, including rendering any Liquid.
*
* Returns an array of JourneyTrack objects with titles, descriptions, and guide links.
*/
export async function resolveJourneyTracks(
journeyTracks: JourneyPage['journeyTracks'],
context: Context,
): Promise<JourneyTrack[]> {
if (!journeyTracks || journeyTracks.length === 0) {
return []
}
const result = await Promise.all(
journeyTracks.map(async (track) => {
// Render Liquid templates in title and description
const renderedTitle = needsRendering(track.title)
? await renderContent(track.title, context, { textOnly: true })
: track.title
const renderedDescription =
track.description && needsRendering(track.description)
? await renderContent(track.description, context, { textOnly: true })
: track.description
const guides = (
await Promise.all(
track.guides.map(async (guide: { href: string; alternativeNextStep?: string }) => {
const linkData = await getLinkData(guide.href, context, { title: true })
if (!linkData?.[0]) return null
return {
href: linkData[0].href,
title: linkData[0].title || '',
}
}),
)
).filter((g): g is { href: string; title: string } => g !== null)
return {
id: track.id,
title: renderedTitle,
description: renderedDescription,
guides,
}
}),
)
return result
}