1
0
mirror of synced 2025-12-19 18:10:59 -05:00

journey tracks content linter rules (#57909)

This commit is contained in:
Robert Sese
2025-10-14 13:00:56 -05:00
committed by GitHub
parent 8ca941e908
commit 57eb456b39
12 changed files with 494 additions and 0 deletions

View File

@@ -73,6 +73,9 @@
| GHD055 | frontmatter-validation | Frontmatter properties must meet character limits and required property requirements | warning | frontmatter, character-limits, required-properties |
| 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 |
| GHD057 | ctas-schema | CTA URLs must conform to the schema | error | ctas, schema, urls |
| GHD058 | journey-tracks-liquid | Journey track properties must use valid Liquid syntax | error | frontmatter, journey-tracks, liquid |
| GHD059 | journey-tracks-guide-path-exists | Journey track guide paths must reference existing content files | error | frontmatter, journey-tracks |
| GHD060 | journey-tracks-unique-ids | Journey track IDs must be unique within a page | error | frontmatter, journey-tracks, unique-ids |
| [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 | |
| [search-replace](https://github.com/OnkarRuikar/markdownlint-rule-search-replace) | deprecated liquid syntax: site.data | Catch occurrences of deprecated liquid data syntax. | error | |
| [search-replace](https://github.com/OnkarRuikar/markdownlint-rule-search-replace) | developer-domain | Catch occurrences of developer.github.com domain. | error | |

View File

@@ -58,6 +58,9 @@ import { headerContentRequirement } from '@/content-linter/lib/linting-rules/hea
import { thirdPartyActionsReusable } from '@/content-linter/lib/linting-rules/third-party-actions-reusable'
import { frontmatterLandingRecommended } from '@/content-linter/lib/linting-rules/frontmatter-landing-recommended'
import { ctasSchema } from '@/content-linter/lib/linting-rules/ctas-schema'
import { journeyTracksLiquid } from './journey-tracks-liquid'
import { journeyTracksGuidePathExists } from './journey-tracks-guide-path-exists'
import { journeyTracksUniqueIds } from './journey-tracks-unique-ids'
// Using any type because @github/markdownlint-github doesn't provide TypeScript declarations
// The elements in the array have a 'names' property that contains rule identifiers
@@ -124,6 +127,9 @@ export const gitHubDocsMarkdownlint = {
frontmatterValidation, // GHD055
frontmatterLandingRecommended, // GHD056
ctasSchema, // GHD057
journeyTracksLiquid, // GHD058
journeyTracksGuidePathExists, // GHD059
journeyTracksUniqueIds, // GHD060
// Search-replace rules
searchReplace, // Open-source plugin

View File

@@ -0,0 +1,66 @@
import fs from 'fs'
import path from 'path'
// @ts-ignore - markdownlint-rule-helpers doesn't have TypeScript declarations
import { addError } from 'markdownlint-rule-helpers'
import { getFrontmatter } from '../helpers/utils'
import type { RuleParams, RuleErrorCallback } from '@/content-linter/types'
// Yoink path validation approach from frontmatter-landing-recommended
function isValidGuidePath(guidePath: string, currentFilePath: string): boolean {
const ROOT = process.env.ROOT || '.'
// Strategy 1: Always try as an absolute path from content root first
const contentDir = path.join(ROOT, 'content')
const normalizedPath = guidePath.startsWith('/') ? guidePath.substring(1) : guidePath
const absolutePath = path.join(contentDir, `${normalizedPath}.md`)
if (fs.existsSync(absolutePath) && fs.statSync(absolutePath).isFile()) {
return true
}
// Strategy 2: Fall back to relative path from current file's directory
const currentDir = path.dirname(currentFilePath)
const relativePath = path.join(currentDir, `${normalizedPath}.md`)
try {
return fs.existsSync(relativePath) && fs.statSync(relativePath).isFile()
} catch {
return false
}
}
export const journeyTracksGuidePathExists = {
names: ['GHD059', 'journey-tracks-guide-path-exists'],
description: 'Journey track guide paths must reference existing content files',
tags: ['frontmatter', 'journey-tracks'],
function: (params: RuleParams, onError: RuleErrorCallback) => {
// Using any for frontmatter as it's a dynamic YAML object with varying properties
const fm: any = getFrontmatter(params.lines)
if (!fm || !fm.journeyTracks || !Array.isArray(fm.journeyTracks)) return
if (!fm.layout || fm.layout !== 'journey-landing') return
const journeyTracksLine = params.lines.find((line: string) => line.startsWith('journeyTracks:'))
if (!journeyTracksLine) return
const journeyTracksLineNumber = params.lines.indexOf(journeyTracksLine) + 1
fm.journeyTracks.forEach((track: any, trackIndex: number) => {
if (track.guides && Array.isArray(track.guides)) {
track.guides.forEach((guide: string, guideIndex: number) => {
if (typeof guide === 'string') {
if (!isValidGuidePath(guide, params.name)) {
addError(
onError,
journeyTracksLineNumber,
`Journey track guide path does not exist: ${guide} (track ${trackIndex + 1}, guide ${guideIndex + 1})`,
guide,
)
}
}
})
}
})
},
}

View File

@@ -0,0 +1,94 @@
// @ts-ignore - markdownlint-rule-helpers doesn't have TypeScript declarations
import { addError } from 'markdownlint-rule-helpers'
import { getFrontmatter } from '../helpers/utils'
import { liquid } from '@/content-render/index'
import type { RuleParams, RuleErrorCallback } from '@/content-linter/types'
export const journeyTracksLiquid = {
names: ['GHD058', 'journey-tracks-liquid'],
description: 'Journey track properties must use valid Liquid syntax',
tags: ['frontmatter', 'journey-tracks', 'liquid'],
function: (params: RuleParams, onError: RuleErrorCallback) => {
// Using any for frontmatter as it's a dynamic YAML object with varying properties
const fm: any = getFrontmatter(params.lines)
if (!fm || !fm.journeyTracks || !Array.isArray(fm.journeyTracks)) return
if (!fm.layout || fm.layout !== 'journey-landing') return
// Find the base journeyTracks line
const journeyTracksLine: string | undefined = params.lines.find((line: string) =>
line.trim().startsWith('journeyTracks:'),
)
const baseLineNumber: number = journeyTracksLine
? params.lines.indexOf(journeyTracksLine) + 1
: 1
fm.journeyTracks.forEach((track: any, trackIndex: number) => {
// Try to find the line number for this specific journey track so we can use that for the error
// line number. Getting the exact line number is probably more work than it's worth for this
// particular rule.
// Look for the track by finding the nth occurrence of track-like patterns after journeyTracks
let trackLineNumber: number = baseLineNumber
if (journeyTracksLine) {
let trackCount: number = 0
for (let i = params.lines.indexOf(journeyTracksLine) + 1; i < params.lines.length; i++) {
const line: string = params.lines[i].trim()
// Look for track indicators (array item with id, title, or description)
if (
line.startsWith('- id:') ||
line.startsWith('- title:') ||
(line === '-' &&
i + 1 < params.lines.length &&
(params.lines[i + 1].trim().startsWith('id:') ||
params.lines[i + 1].trim().startsWith('title:')))
) {
if (trackCount === trackIndex) {
trackLineNumber = i + 1
break
}
trackCount++
}
}
}
// Simple validation - just check if liquid can parse each string property
const properties = [
{ name: 'title', value: track.title },
{ name: 'description', value: track.description },
]
properties.forEach((prop) => {
if (prop.value && typeof prop.value === 'string') {
try {
liquid.parse(prop.value)
} catch (error: any) {
addError(
onError,
trackLineNumber,
`Invalid Liquid syntax in journey track ${prop.name} (track ${trackIndex + 1}): ${error.message}`,
prop.value,
)
}
}
})
if (track.guides && Array.isArray(track.guides)) {
track.guides.forEach((guide: string, guideIndex: number) => {
if (typeof guide === 'string') {
try {
liquid.parse(guide)
} catch (error: any) {
addError(
onError,
trackLineNumber,
`Invalid Liquid syntax in journey track guide (track ${trackIndex + 1}, guide ${guideIndex + 1}): ${error.message}`,
guide,
)
}
}
})
}
})
},
}

View File

@@ -0,0 +1,72 @@
// @ts-ignore - markdownlint-rule-helpers doesn't have TypeScript declarations
import { addError } from 'markdownlint-rule-helpers'
import { getFrontmatter } from '../helpers/utils'
import type { RuleParams, RuleErrorCallback } from '@/content-linter/types'
// GHD060
export const journeyTracksUniqueIds = {
names: ['GHD060', 'journey-tracks-unique-ids'],
description: 'Journey track IDs must be unique within a page',
tags: ['frontmatter', 'journey-tracks', 'unique-ids'],
function: function GHD060(params: RuleParams, onError: RuleErrorCallback) {
// Using any for frontmatter as it's a dynamic YAML object with varying properties
const fm: any = getFrontmatter(params.lines)
if (!fm || !fm.journeyTracks || !Array.isArray(fm.journeyTracks)) return
if (!fm.layout || fm.layout !== 'journey-landing') return
// Find the base journeyTracks line
const journeyTracksLine: string | undefined = params.lines.find((line: string) =>
line.trim().startsWith('journeyTracks:'),
)
const baseLineNumber: number = journeyTracksLine
? params.lines.indexOf(journeyTracksLine) + 1
: 1
// Helper function to find line number for a specific track by index
function getTrackLineNumber(trackIndex: number): number {
if (!journeyTracksLine) return baseLineNumber
let trackCount = 0
for (let i = params.lines.indexOf(journeyTracksLine) + 1; i < params.lines.length; i++) {
const line = params.lines[i].trim()
// Look for any "- id:" line (journey track indicator)
if (line.startsWith('- id:')) {
if (trackCount === trackIndex) {
return i + 1
}
trackCount++
// Stop once we've found all the tracks we know exist
if (fm && fm.journeyTracks && trackCount >= fm.journeyTracks.length) {
break
}
}
}
return baseLineNumber
}
// Track seen journey track IDs and line number for error reporting
const seenIds = new Map<string, number>()
fm.journeyTracks.forEach((track: any, index: number) => {
if (!track || typeof track !== 'object') return
const trackId = track.id
if (!trackId || typeof trackId !== 'string') return
const currentLineNumber = getTrackLineNumber(index)
if (seenIds.has(trackId)) {
const firstLineNumber = seenIds.get(trackId)
addError(
onError,
currentLineNumber,
`Journey track ID "${trackId}" is duplicated (first seen at line ${firstLineNumber}, duplicate at line ${currentLineNumber})`,
)
} else {
seenIds.set(trackId, currentLineNumber)
}
})
},
}

View File

@@ -330,6 +330,24 @@ export const githubDocsFrontmatterConfig = {
'partial-markdown-files': true,
'yml-files': true,
},
'journey-tracks-liquid': {
// GHD058
severity: 'error',
'partial-markdown-files': false,
'yml-files': false,
},
'journey-tracks-guide-path-exists': {
// GHD059
severity: 'error',
'partial-markdown-files': false,
'yml-files': false,
},
'journey-tracks-unique-ids': {
// GHD060
severity: 'error',
'partial-markdown-files': false,
'yml-files': false,
},
}
// Configures rules from the `github/markdownlint-github` repo

View File

@@ -0,0 +1,27 @@
---
title: Journey with Duplicate IDs
layout: journey-landing
versions:
fpt: '*'
ghec: '*'
ghes: '*'
topics:
- Testing
journeyTracks:
- id: duplicate-id
title: "First Track"
guides:
- /article-one
- id: unique-id
title: "Unique Track"
guides:
- /article-two
- id: duplicate-id
title: "Second Track with Same ID"
guides:
- /subdir/article-three
---
# Journey with Duplicate IDs
This journey landing page has duplicate track IDs.

View File

@@ -0,0 +1,21 @@
---
title: Journey with Invalid Paths
layout: journey-landing
versions:
fpt: '*'
ghec: '*'
ghes: '*'
topics:
- Testing
journeyTracks:
- id: track-1
title: "Track with Invalid Guides"
guides:
- /article-one
- /nonexistent/guide
- /another/invalid/path
---
# Journey with Invalid Paths
This journey landing page has some invalid guide paths.

View File

@@ -0,0 +1,14 @@
---
title: Journey without Journey Tracks
layout: journey-landing
versions:
fpt: '*'
ghec: '*'
ghes: '*'
topics:
- Testing
---
# Journey without Journey Tracks
This journey landing page has no journeyTracks property.

View File

@@ -0,0 +1,19 @@
---
title: Non-Journey Page
layout: default
versions:
fpt: '*'
ghec: '*'
ghes: '*'
topics:
- Testing
journeyTracks:
- id: track-1
title: "Should be ignored"
guides:
- /nonexistent/path
---
# Non-Journey Page
This is not a journey-landing layout, so journeyTracks should be ignored.

View File

@@ -0,0 +1,24 @@
---
title: Valid Journey Landing
layout: journey-landing
versions:
fpt: '*'
ghec: '*'
ghes: '*'
topics:
- Testing
journeyTracks:
- id: track-1
title: "Getting Started Track"
guides:
- /article-one
- /article-two
- id: track-2
title: "Advanced Track"
guides:
- /subdir/article-three
---
# Valid Journey Landing
This is a valid journey landing page with valid guide paths.

View File

@@ -0,0 +1,130 @@
import { describe, expect, test, beforeAll, afterAll } from 'vitest'
import { runRule } from '../../lib/init-test'
import { journeyTracksLiquid } from '../../lib/linting-rules/journey-tracks-liquid'
import { journeyTracksGuidePathExists } from '../../lib/linting-rules/journey-tracks-guide-path-exists'
import { journeyTracksUniqueIds } from '../../lib/linting-rules/journey-tracks-unique-ids'
const VALID_JOURNEY = 'src/content-linter/tests/fixtures/journey-tracks/valid-journey.md'
const INVALID_PATHS = 'src/content-linter/tests/fixtures/journey-tracks/invalid-paths.md'
const NON_JOURNEY_LAYOUT = 'src/content-linter/tests/fixtures/journey-tracks/non-journey-layout.md'
const DUPLICATE_IDS = 'src/content-linter/tests/fixtures/journey-tracks/duplicate-ids.md'
const NO_JOURNEY_TRACKS = 'src/content-linter/tests/fixtures/journey-tracks/no-journey-tracks.md'
const fmOptions = { markdownlintOptions: { frontMatter: null } }
describe('journey-tracks-liquid', () => {
test('valid liquid syntax passes', async () => {
const result = await runRule(journeyTracksLiquid, {
files: [VALID_JOURNEY],
...fmOptions,
})
expect(result[VALID_JOURNEY]).toEqual([])
})
test('invalid liquid syntax fails', async () => {
// Using inline content instead of a fixture file to avoid CI conflicts.
// Malformed Liquid syntax in fixture files causes other rules (like liquid-versioning)
// to crash when they try to parse the same file during content linting.
const invalidLiquidContent = `---
title: Journey with Liquid Syntax
layout: journey-landing
versions:
fpt: '*'
ghec: '*'
ghes: '*'
topics:
- Testing
journeyTracks:
- id: track-1
title: "Track with {% invalid liquid"
description: "Description with {{ unclosed liquid"
guides:
- /article-one
---
# Journey with Liquid Issues
This journey landing page has invalid liquid syntax in journeyTracks.
`
const result = await runRule(journeyTracksLiquid, {
strings: { 'test-invalid-liquid.md': invalidLiquidContent },
...fmOptions,
})
expect(result['test-invalid-liquid.md']).toHaveLength(2) // title and description both have invalid liquid
expect(result['test-invalid-liquid.md'][0].ruleDescription).toMatch(/liquid syntax/i)
expect(result['test-invalid-liquid.md'][1].ruleDescription).toMatch(/liquid syntax/i)
})
})
describe('journey-tracks-guide-path-exists', () => {
const envVarValueBefore = process.env.ROOT
beforeAll(() => {
process.env.ROOT = 'src/fixtures/fixtures'
})
afterAll(() => {
process.env.ROOT = envVarValueBefore
})
test('ignores non-journey-landing layouts', async () => {
const result = await runRule(journeyTracksGuidePathExists, {
files: [NON_JOURNEY_LAYOUT],
...fmOptions,
})
expect(result[NON_JOURNEY_LAYOUT]).toEqual([])
})
test('valid guide paths pass', async () => {
const result = await runRule(journeyTracksGuidePathExists, {
files: [VALID_JOURNEY],
...fmOptions,
})
expect(result[VALID_JOURNEY]).toEqual([])
})
test('invalid guide paths fail', async () => {
const result = await runRule(journeyTracksGuidePathExists, {
files: [INVALID_PATHS],
...fmOptions,
})
expect(result[INVALID_PATHS].length).toBeGreaterThan(0)
expect(result[INVALID_PATHS][0].errorDetail).toContain('does not exist')
})
test('pages without journey tracks pass', async () => {
const result = await runRule(journeyTracksGuidePathExists, {
files: [NO_JOURNEY_TRACKS],
...fmOptions,
})
expect(result[NO_JOURNEY_TRACKS]).toEqual([])
})
})
describe('journey-tracks-unique-ids', () => {
test('unique IDs pass', async () => {
const result = await runRule(journeyTracksUniqueIds, {
files: [VALID_JOURNEY],
...fmOptions,
})
expect(result[VALID_JOURNEY]).toEqual([])
})
test('duplicate IDs fail', async () => {
const result = await runRule(journeyTracksUniqueIds, {
files: [DUPLICATE_IDS],
...fmOptions,
})
expect(result[DUPLICATE_IDS]).toHaveLength(1)
expect(result[DUPLICATE_IDS][0].errorDetail).toContain('duplicate-id')
})
test('ignores non-journey-landing layouts', async () => {
const result = await runRule(journeyTracksUniqueIds, {
files: [NON_JOURNEY_LAYOUT],
...fmOptions,
})
expect(result[NON_JOURNEY_LAYOUT]).toEqual([])
})
})