Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions config/moda/secrets/production/secrets.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ secrets:
kind: latest_at_deployment_start
key: COOKIE_SECRET
type: salt
owner: '@github/docs-engineering'
externally_usable: true
2 changes: 2 additions & 0 deletions config/moda/secrets/staging/secrets.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ secrets:
kind: latest_at_deployment_start
key: COOKIE_SECRET
type: salt
owner: '@github/docs-engineering'
externally_usable: true
1 change: 1 addition & 0 deletions data/reusables/contributing/content-linter-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
| GHD060 | journey-tracks-unique-ids | Journey track IDs must be unique within a page | error | frontmatter, journey-tracks, unique-ids |
| GHD061 | frontmatter-hero-image | Hero image paths must be absolute, extensionless, and point to valid images in /assets/images/banner-images/ | error | frontmatter, images |
| GHD062 | frontmatter-intro-links | introLinks keys must be valid keys defined in data/ui.yml under product_landing | error | frontmatter, single-source |
| GHD063 | frontmatter-children | Children frontmatter paths must exist. Supports relative paths and absolute /content/ paths for cross-product inclusion. | error | frontmatter, children |
| [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 | |
Expand Down
100 changes: 100 additions & 0 deletions src/content-linter/lib/linting-rules/frontmatter-children.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import fs from 'fs'
import path from 'path'
import { addError } from 'markdownlint-rule-helpers'

import { getFrontmatter } from '../helpers/utils'
import type { RuleParams, RuleErrorCallback } from '@/content-linter/types'

interface Frontmatter {
children?: string[]
[key: string]: unknown
}

/**
* Check if a child path is valid.
* Supports both:
* - Relative paths (e.g., /local-child) resolved from current directory
* - Absolute /content/ paths (e.g., /content/actions/workflows) resolved from content root
*/
function isValidChildPath(childPath: string, currentFilePath: string): boolean {
const ROOT = process.env.ROOT || '.'
const contentDir = path.resolve(ROOT, 'content')

let resolvedPath: string

if (childPath.startsWith('/content/')) {
// Absolute path from content root - strip /content/ prefix
const absoluteChildPath = childPath.slice('/content/'.length)
resolvedPath = path.resolve(contentDir, absoluteChildPath)
} else {
// Relative path from current file's directory
const currentDir: string = path.dirname(currentFilePath)
const normalizedPath = childPath.startsWith('/') ? childPath.substring(1) : childPath
resolvedPath = path.resolve(currentDir, normalizedPath)
}

// Security check: ensure resolved path stays within content directory
// This prevents path traversal attacks using sequences like '../'
if (!resolvedPath.startsWith(contentDir + path.sep) && resolvedPath !== contentDir) {
return false
}

// Check for direct .md file
const mdPath = `${resolvedPath}.md`
if (fs.existsSync(mdPath) && fs.statSync(mdPath).isFile()) {
return true
}

// Check for index.md file in directory
const indexPath = path.join(resolvedPath, 'index.md')
if (fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) {
return true
}

// Check if the path exists as a directory (may have children)
if (fs.existsSync(resolvedPath) && fs.statSync(resolvedPath).isDirectory()) {
return true
}

return false
}

export const frontmatterChildren = {
names: ['GHD063', 'frontmatter-children'],
description:
'Children frontmatter paths must exist. Supports relative paths and absolute /content/ paths for cross-product inclusion.',
tags: ['frontmatter', 'children'],
function: (params: RuleParams, onError: RuleErrorCallback) => {
const fm = getFrontmatter(params.lines) as Frontmatter | null
if (!fm || !fm.children) return

const childrenLine: string | undefined = params.lines.find((line) =>
line.startsWith('children:'),
)

if (!childrenLine) return

const lineNumber: number = params.lines.indexOf(childrenLine) + 1

if (Array.isArray(fm.children)) {
const invalidPaths: string[] = []

for (const child of fm.children) {
if (!isValidChildPath(child, params.name)) {
invalidPaths.push(child)
}
}

if (invalidPaths.length > 0) {
addError(
onError,
lineNumber,
`Found invalid children paths: ${invalidPaths.join(', ')}. For cross-product paths, use /content/ prefix (e.g., /content/actions/workflows).`,
childrenLine,
[1, childrenLine.length],
null,
)
}
}
},
}
2 changes: 2 additions & 0 deletions src/content-linter/lib/linting-rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { journeyTracksGuidePathExists } from './journey-tracks-guide-path-exists
import { journeyTracksUniqueIds } from './journey-tracks-unique-ids'
import { frontmatterHeroImage } from './frontmatter-hero-image'
import { frontmatterIntroLinks } from './frontmatter-intro-links'
import { frontmatterChildren } from './frontmatter-children'

// 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
Expand Down Expand Up @@ -117,6 +118,7 @@ export const gitHubDocsMarkdownlint = {
journeyTracksUniqueIds, // GHD060
frontmatterHeroImage, // GHD061
frontmatterIntroLinks, // GHD062
frontmatterChildren, // GHD063

// Search-replace rules
searchReplace, // Open-source plugin
Expand Down
6 changes: 6 additions & 0 deletions src/content-linter/style/github-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,12 @@ export const githubDocsFrontmatterConfig = {
'partial-markdown-files': false,
'yml-files': false,
},
'frontmatter-children': {
// GHD063
severity: 'error',
'partial-markdown-files': false,
'yml-files': false,
},
}

// Configures rules from the `github/markdownlint-github` repo
Expand Down
9 changes: 9 additions & 0 deletions src/content-linter/tests/category-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,15 @@ describe.skip('category pages', () => {
})

function getPath(productDir: string, link: string, filename: string) {
// Handle absolute /content/ paths for cross-product children
// The link parameter contains the child path from frontmatter
if (link.startsWith('/content/')) {
const absolutePath = link.slice('/content/'.length)
if (filename === 'index') {
return path.join(contentDir, absolutePath, 'index.md')
}
return path.join(contentDir, absolutePath, `${filename}.md`)
}
return path.join(productDir, link, `${filename}.md`)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
title: Invalid children paths
children:
- /content/nonexistent/product
- /another/invalid/path
---

This page has invalid children paths.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
title: No children
---

This page has no children frontmatter.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
title: Valid children with content prefix
children:
- /content/get-started/foo
- /content/get-started/learning-about-github
---

This page has valid /content/ prefixed children paths.
53 changes: 53 additions & 0 deletions src/content-linter/tests/unit/frontmatter-children.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, test, beforeAll, afterAll } from 'vitest'

import { runRule } from '@/content-linter/lib/init-test'
import { frontmatterChildren } from '@/content-linter/lib/linting-rules/frontmatter-children'

const VALID_CONTENT_PREFIX =
'src/content-linter/tests/fixtures/frontmatter-children/valid-content-prefix.md'
const INVALID_PATHS = 'src/content-linter/tests/fixtures/frontmatter-children/invalid-paths.md'
const NO_CHILDREN = 'src/content-linter/tests/fixtures/frontmatter-children/no-children.md'

const ruleName = frontmatterChildren.names[1]

// Configure the test fixture to not split frontmatter and content
const fmOptions = { markdownlintOptions: { frontMatter: null } }

describe(ruleName, () => {
const envVarValueBefore = process.env.ROOT

beforeAll(() => {
process.env.ROOT = 'src/fixtures/fixtures'
})

afterAll(() => {
process.env.ROOT = envVarValueBefore
})

test('page with valid /content/ prefixed children paths passes', async () => {
const result = await runRule(frontmatterChildren, {
files: [VALID_CONTENT_PREFIX],
...fmOptions,
})
expect(result[VALID_CONTENT_PREFIX]).toEqual([])
})

test('page without children property passes', async () => {
const result = await runRule(frontmatterChildren, {
files: [NO_CHILDREN],
...fmOptions,
})
expect(result[NO_CHILDREN]).toEqual([])
})

test('page with invalid children paths fails', async () => {
const result = await runRule(frontmatterChildren, {
files: [INVALID_PATHS],
...fmOptions,
})
expect(result[INVALID_PATHS]).toHaveLength(1)
expect(result[INVALID_PATHS][0].errorDetail).toContain('Found invalid children paths:')
expect(result[INVALID_PATHS][0].errorDetail).toContain('/content/nonexistent/product')
expect(result[INVALID_PATHS][0].errorDetail).toContain('/another/invalid/path')
})
})
1 change: 1 addition & 0 deletions src/fixtures/fixtures/content/get-started/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ children:
- /carousel
- /article-grid-discovery
- /article-grid-bespoke
- /non-child-resolution
communityRedirect:
name: Provide HubGit Feedback
href: 'https://hubgit.com/orgs/community/discussions/categories/get-started'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
title: Cross-product children test
intro: Testing cross-product children resolution using /content/ prefix
versions:
fpt: '*'
children:
- /content/actions/using-workflows/storing-workflow-data-as-artifacts
---

This category uses /content/ prefix for cross-product article inclusion.
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
title: Children only test
intro: Testing children-only page resolution
versions:
fpt: '*'
children:
- /content/actions/using-workflows/storing-workflow-data-as-artifacts
---

This category uses traditional children-only approach but with a cross-product path.
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
title: Non-child resolution test
intro: Testing non-child page resolution from frontmatter
versions:
fpt: '*'
ghec: '*'
ghes: '*'
children:
- /children-only
- /articles-only
- /local-category
- /standalone-article
- /versioned-cross-product
- /content/actions/using-workflows/storing-workflow-data-as-artifacts
---

This is the non-child resolution test page.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
title: Local category test
intro: Testing local children combined with cross-product children
versions:
fpt: '*'
children:
- /local-article-one
- /local-article-two
- /content/actions/using-workflows/storing-workflow-data-as-artifacts
---

This category has both local children and cross-product children.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
title: Local article one
intro: A local article in the local category
versions:
fpt: '*'
---

This is local article one.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
title: Local article two
intro: Another local article in the local category
versions:
fpt: '*'
---

This is local article two.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
title: Standalone article
intro: A standalone article for testing
versions:
fpt: '*'
---

This is a standalone article.
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
title: Versioned cross-product test
intro: Testing cross-product children with version constraints
versions:
fpt: '*'
ghec: '*'
ghes: '*'
children:
- /content/get-started/versioning/only-fpt
- /content/get-started/versioning/only-ghec
- /content/get-started/versioning/only-ghec-and-ghes
---

This category includes cross-product children from articles with different version constraints.
The children should only appear in versions where the referenced article is available.
Loading
Loading