Skip to content

Commit 57eb456

Browse files
authored
journey tracks content linter rules (#57909)
1 parent 8ca941e commit 57eb456

File tree

12 files changed

+494
-0
lines changed

12 files changed

+494
-0
lines changed

data/reusables/contributing/content-linter-rules.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@
7373
| GHD055 | frontmatter-validation | Frontmatter properties must meet character limits and required property requirements | warning | frontmatter, character-limits, required-properties |
7474
| GHD056 | frontmatter-landing-recommended | Only landing pages can have recommended articles, there should be no duplicate recommended articles, and all recommended articles must exist | error | frontmatter, landing, recommended |
7575
| GHD057 | ctas-schema | CTA URLs must conform to the schema | error | ctas, schema, urls |
76+
| GHD058 | journey-tracks-liquid | Journey track properties must use valid Liquid syntax | error | frontmatter, journey-tracks, liquid |
77+
| GHD059 | journey-tracks-guide-path-exists | Journey track guide paths must reference existing content files | error | frontmatter, journey-tracks |
78+
| GHD060 | journey-tracks-unique-ids | Journey track IDs must be unique within a page | error | frontmatter, journey-tracks, unique-ids |
7679
| [search-replace](https://github.com/OnkarRuikar/markdownlint-rule-search-replace) | deprecated liquid syntax: octicon-<icon-name> | The octicon liquid syntax used is deprecated. Use this format instead `octicon "<octicon-name>" aria-label="<Octicon aria label>"` | error | |
7780
| [search-replace](https://github.com/OnkarRuikar/markdownlint-rule-search-replace) | deprecated liquid syntax: site.data | Catch occurrences of deprecated liquid data syntax. | error | |
7881
| [search-replace](https://github.com/OnkarRuikar/markdownlint-rule-search-replace) | developer-domain | Catch occurrences of developer.github.com domain. | error | |

src/content-linter/lib/linting-rules/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ import { headerContentRequirement } from '@/content-linter/lib/linting-rules/hea
5858
import { thirdPartyActionsReusable } from '@/content-linter/lib/linting-rules/third-party-actions-reusable'
5959
import { frontmatterLandingRecommended } from '@/content-linter/lib/linting-rules/frontmatter-landing-recommended'
6060
import { ctasSchema } from '@/content-linter/lib/linting-rules/ctas-schema'
61+
import { journeyTracksLiquid } from './journey-tracks-liquid'
62+
import { journeyTracksGuidePathExists } from './journey-tracks-guide-path-exists'
63+
import { journeyTracksUniqueIds } from './journey-tracks-unique-ids'
6164

6265
// Using any type because @github/markdownlint-github doesn't provide TypeScript declarations
6366
// The elements in the array have a 'names' property that contains rule identifiers
@@ -124,6 +127,9 @@ export const gitHubDocsMarkdownlint = {
124127
frontmatterValidation, // GHD055
125128
frontmatterLandingRecommended, // GHD056
126129
ctasSchema, // GHD057
130+
journeyTracksLiquid, // GHD058
131+
journeyTracksGuidePathExists, // GHD059
132+
journeyTracksUniqueIds, // GHD060
127133

128134
// Search-replace rules
129135
searchReplace, // Open-source plugin
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import fs from 'fs'
2+
import path from 'path'
3+
// @ts-ignore - markdownlint-rule-helpers doesn't have TypeScript declarations
4+
import { addError } from 'markdownlint-rule-helpers'
5+
6+
import { getFrontmatter } from '../helpers/utils'
7+
import type { RuleParams, RuleErrorCallback } from '@/content-linter/types'
8+
9+
// Yoink path validation approach from frontmatter-landing-recommended
10+
function isValidGuidePath(guidePath: string, currentFilePath: string): boolean {
11+
const ROOT = process.env.ROOT || '.'
12+
13+
// Strategy 1: Always try as an absolute path from content root first
14+
const contentDir = path.join(ROOT, 'content')
15+
const normalizedPath = guidePath.startsWith('/') ? guidePath.substring(1) : guidePath
16+
const absolutePath = path.join(contentDir, `${normalizedPath}.md`)
17+
18+
if (fs.existsSync(absolutePath) && fs.statSync(absolutePath).isFile()) {
19+
return true
20+
}
21+
22+
// Strategy 2: Fall back to relative path from current file's directory
23+
const currentDir = path.dirname(currentFilePath)
24+
const relativePath = path.join(currentDir, `${normalizedPath}.md`)
25+
26+
try {
27+
return fs.existsSync(relativePath) && fs.statSync(relativePath).isFile()
28+
} catch {
29+
return false
30+
}
31+
}
32+
33+
export const journeyTracksGuidePathExists = {
34+
names: ['GHD059', 'journey-tracks-guide-path-exists'],
35+
description: 'Journey track guide paths must reference existing content files',
36+
tags: ['frontmatter', 'journey-tracks'],
37+
function: (params: RuleParams, onError: RuleErrorCallback) => {
38+
// Using any for frontmatter as it's a dynamic YAML object with varying properties
39+
const fm: any = getFrontmatter(params.lines)
40+
if (!fm || !fm.journeyTracks || !Array.isArray(fm.journeyTracks)) return
41+
if (!fm.layout || fm.layout !== 'journey-landing') return
42+
43+
const journeyTracksLine = params.lines.find((line: string) => line.startsWith('journeyTracks:'))
44+
45+
if (!journeyTracksLine) return
46+
47+
const journeyTracksLineNumber = params.lines.indexOf(journeyTracksLine) + 1
48+
49+
fm.journeyTracks.forEach((track: any, trackIndex: number) => {
50+
if (track.guides && Array.isArray(track.guides)) {
51+
track.guides.forEach((guide: string, guideIndex: number) => {
52+
if (typeof guide === 'string') {
53+
if (!isValidGuidePath(guide, params.name)) {
54+
addError(
55+
onError,
56+
journeyTracksLineNumber,
57+
`Journey track guide path does not exist: ${guide} (track ${trackIndex + 1}, guide ${guideIndex + 1})`,
58+
guide,
59+
)
60+
}
61+
}
62+
})
63+
}
64+
})
65+
},
66+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// @ts-ignore - markdownlint-rule-helpers doesn't have TypeScript declarations
2+
import { addError } from 'markdownlint-rule-helpers'
3+
4+
import { getFrontmatter } from '../helpers/utils'
5+
import { liquid } from '@/content-render/index'
6+
import type { RuleParams, RuleErrorCallback } from '@/content-linter/types'
7+
8+
export const journeyTracksLiquid = {
9+
names: ['GHD058', 'journey-tracks-liquid'],
10+
description: 'Journey track properties must use valid Liquid syntax',
11+
tags: ['frontmatter', 'journey-tracks', 'liquid'],
12+
function: (params: RuleParams, onError: RuleErrorCallback) => {
13+
// Using any for frontmatter as it's a dynamic YAML object with varying properties
14+
const fm: any = getFrontmatter(params.lines)
15+
if (!fm || !fm.journeyTracks || !Array.isArray(fm.journeyTracks)) return
16+
if (!fm.layout || fm.layout !== 'journey-landing') return
17+
18+
// Find the base journeyTracks line
19+
const journeyTracksLine: string | undefined = params.lines.find((line: string) =>
20+
line.trim().startsWith('journeyTracks:'),
21+
)
22+
const baseLineNumber: number = journeyTracksLine
23+
? params.lines.indexOf(journeyTracksLine) + 1
24+
: 1
25+
26+
fm.journeyTracks.forEach((track: any, trackIndex: number) => {
27+
// Try to find the line number for this specific journey track so we can use that for the error
28+
// line number. Getting the exact line number is probably more work than it's worth for this
29+
// particular rule.
30+
31+
// Look for the track by finding the nth occurrence of track-like patterns after journeyTracks
32+
let trackLineNumber: number = baseLineNumber
33+
if (journeyTracksLine) {
34+
let trackCount: number = 0
35+
for (let i = params.lines.indexOf(journeyTracksLine) + 1; i < params.lines.length; i++) {
36+
const line: string = params.lines[i].trim()
37+
// Look for track indicators (array item with id, title, or description)
38+
if (
39+
line.startsWith('- id:') ||
40+
line.startsWith('- title:') ||
41+
(line === '-' &&
42+
i + 1 < params.lines.length &&
43+
(params.lines[i + 1].trim().startsWith('id:') ||
44+
params.lines[i + 1].trim().startsWith('title:')))
45+
) {
46+
if (trackCount === trackIndex) {
47+
trackLineNumber = i + 1
48+
break
49+
}
50+
trackCount++
51+
}
52+
}
53+
}
54+
55+
// Simple validation - just check if liquid can parse each string property
56+
const properties = [
57+
{ name: 'title', value: track.title },
58+
{ name: 'description', value: track.description },
59+
]
60+
61+
properties.forEach((prop) => {
62+
if (prop.value && typeof prop.value === 'string') {
63+
try {
64+
liquid.parse(prop.value)
65+
} catch (error: any) {
66+
addError(
67+
onError,
68+
trackLineNumber,
69+
`Invalid Liquid syntax in journey track ${prop.name} (track ${trackIndex + 1}): ${error.message}`,
70+
prop.value,
71+
)
72+
}
73+
}
74+
})
75+
76+
if (track.guides && Array.isArray(track.guides)) {
77+
track.guides.forEach((guide: string, guideIndex: number) => {
78+
if (typeof guide === 'string') {
79+
try {
80+
liquid.parse(guide)
81+
} catch (error: any) {
82+
addError(
83+
onError,
84+
trackLineNumber,
85+
`Invalid Liquid syntax in journey track guide (track ${trackIndex + 1}, guide ${guideIndex + 1}): ${error.message}`,
86+
guide,
87+
)
88+
}
89+
}
90+
})
91+
}
92+
})
93+
},
94+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// @ts-ignore - markdownlint-rule-helpers doesn't have TypeScript declarations
2+
import { addError } from 'markdownlint-rule-helpers'
3+
4+
import { getFrontmatter } from '../helpers/utils'
5+
import type { RuleParams, RuleErrorCallback } from '@/content-linter/types'
6+
7+
// GHD060
8+
export const journeyTracksUniqueIds = {
9+
names: ['GHD060', 'journey-tracks-unique-ids'],
10+
description: 'Journey track IDs must be unique within a page',
11+
tags: ['frontmatter', 'journey-tracks', 'unique-ids'],
12+
function: function GHD060(params: RuleParams, onError: RuleErrorCallback) {
13+
// Using any for frontmatter as it's a dynamic YAML object with varying properties
14+
const fm: any = getFrontmatter(params.lines)
15+
if (!fm || !fm.journeyTracks || !Array.isArray(fm.journeyTracks)) return
16+
if (!fm.layout || fm.layout !== 'journey-landing') return
17+
18+
// Find the base journeyTracks line
19+
const journeyTracksLine: string | undefined = params.lines.find((line: string) =>
20+
line.trim().startsWith('journeyTracks:'),
21+
)
22+
const baseLineNumber: number = journeyTracksLine
23+
? params.lines.indexOf(journeyTracksLine) + 1
24+
: 1
25+
26+
// Helper function to find line number for a specific track by index
27+
function getTrackLineNumber(trackIndex: number): number {
28+
if (!journeyTracksLine) return baseLineNumber
29+
30+
let trackCount = 0
31+
for (let i = params.lines.indexOf(journeyTracksLine) + 1; i < params.lines.length; i++) {
32+
const line = params.lines[i].trim()
33+
// Look for any "- id:" line (journey track indicator)
34+
if (line.startsWith('- id:')) {
35+
if (trackCount === trackIndex) {
36+
return i + 1
37+
}
38+
trackCount++
39+
40+
// Stop once we've found all the tracks we know exist
41+
if (fm && fm.journeyTracks && trackCount >= fm.journeyTracks.length) {
42+
break
43+
}
44+
}
45+
}
46+
return baseLineNumber
47+
}
48+
49+
// Track seen journey track IDs and line number for error reporting
50+
const seenIds = new Map<string, number>()
51+
52+
fm.journeyTracks.forEach((track: any, index: number) => {
53+
if (!track || typeof track !== 'object') return
54+
55+
const trackId = track.id
56+
if (!trackId || typeof trackId !== 'string') return
57+
58+
const currentLineNumber = getTrackLineNumber(index)
59+
60+
if (seenIds.has(trackId)) {
61+
const firstLineNumber = seenIds.get(trackId)
62+
addError(
63+
onError,
64+
currentLineNumber,
65+
`Journey track ID "${trackId}" is duplicated (first seen at line ${firstLineNumber}, duplicate at line ${currentLineNumber})`,
66+
)
67+
} else {
68+
seenIds.set(trackId, currentLineNumber)
69+
}
70+
})
71+
},
72+
}

src/content-linter/style/github-docs.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,24 @@ export const githubDocsFrontmatterConfig = {
330330
'partial-markdown-files': true,
331331
'yml-files': true,
332332
},
333+
'journey-tracks-liquid': {
334+
// GHD058
335+
severity: 'error',
336+
'partial-markdown-files': false,
337+
'yml-files': false,
338+
},
339+
'journey-tracks-guide-path-exists': {
340+
// GHD059
341+
severity: 'error',
342+
'partial-markdown-files': false,
343+
'yml-files': false,
344+
},
345+
'journey-tracks-unique-ids': {
346+
// GHD060
347+
severity: 'error',
348+
'partial-markdown-files': false,
349+
'yml-files': false,
350+
},
333351
}
334352

335353
// Configures rules from the `github/markdownlint-github` repo
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
title: Journey with Duplicate IDs
3+
layout: journey-landing
4+
versions:
5+
fpt: '*'
6+
ghec: '*'
7+
ghes: '*'
8+
topics:
9+
- Testing
10+
journeyTracks:
11+
- id: duplicate-id
12+
title: "First Track"
13+
guides:
14+
- /article-one
15+
- id: unique-id
16+
title: "Unique Track"
17+
guides:
18+
- /article-two
19+
- id: duplicate-id
20+
title: "Second Track with Same ID"
21+
guides:
22+
- /subdir/article-three
23+
---
24+
25+
# Journey with Duplicate IDs
26+
27+
This journey landing page has duplicate track IDs.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
title: Journey with Invalid Paths
3+
layout: journey-landing
4+
versions:
5+
fpt: '*'
6+
ghec: '*'
7+
ghes: '*'
8+
topics:
9+
- Testing
10+
journeyTracks:
11+
- id: track-1
12+
title: "Track with Invalid Guides"
13+
guides:
14+
- /article-one
15+
- /nonexistent/guide
16+
- /another/invalid/path
17+
---
18+
19+
# Journey with Invalid Paths
20+
21+
This journey landing page has some invalid guide paths.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
title: Journey without Journey Tracks
3+
layout: journey-landing
4+
versions:
5+
fpt: '*'
6+
ghec: '*'
7+
ghes: '*'
8+
topics:
9+
- Testing
10+
---
11+
12+
# Journey without Journey Tracks
13+
14+
This journey landing page has no journeyTracks property.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
title: Non-Journey Page
3+
layout: default
4+
versions:
5+
fpt: '*'
6+
ghec: '*'
7+
ghes: '*'
8+
topics:
9+
- Testing
10+
journeyTracks:
11+
- id: track-1
12+
title: "Should be ignored"
13+
guides:
14+
- /nonexistent/path
15+
---
16+
17+
# Non-Journey Page
18+
19+
This is not a journey-landing layout, so journeyTracks should be ignored.

0 commit comments

Comments
 (0)