Skip to content

Commit 10eac91

Browse files
Linter and helper tool to standardize CTAs (#57826)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
1 parent 29ac867 commit 10eac91

File tree

8 files changed

+909
-0
lines changed

8 files changed

+909
-0
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
| GHD054 | third-party-actions-reusable | Code examples with third-party actions must include disclaimer reusable | warning | actions, reusable, third-party |
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 |
75+
| GHD057 | ctas-schema | CTA URLs must conform to the schema | error | ctas, schema, urls |
7576
| [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 | |
7677
| [search-replace](https://github.com/OnkarRuikar/markdownlint-rule-search-replace) | deprecated liquid syntax: site.data | Catch occurrences of deprecated liquid data syntax. | error | |
7778
| [search-replace](https://github.com/OnkarRuikar/markdownlint-rule-search-replace) | developer-domain | Catch occurrences of developer.github.com domain. | error | |

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"copy-fixture-data": "tsx src/tests/scripts/copy-fixture-data.ts",
2626
"count-translation-corruptions": "cross-env NODE_OPTIONS=--max-old-space-size=8192 tsx src/languages/scripts/count-translation-corruptions.ts",
2727
"create-enterprise-issue": "tsx src/ghes-releases/scripts/create-enterprise-issue.ts",
28+
"cta-builder": "tsx src/content-render/scripts/cta-builder.ts",
2829
"debug": "cross-env NODE_ENV=development ENABLED_LANGUAGES=en nodemon --inspect src/frame/server.ts",
2930
"delete-orphan-translation-files": "tsx src/workflows/delete-orphan-translation-files.ts",
3031
"docsaudit": "tsx src/metrics/scripts/docsaudit.ts",
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// @ts-ignore - markdownlint-rule-helpers doesn't have TypeScript declarations
2+
import { addError } from 'markdownlint-rule-helpers'
3+
import Ajv from 'ajv'
4+
5+
import { convertOldCTAUrl } from '@/content-render/scripts/cta-builder'
6+
import ctaSchemaDefinition from '@/data-directory/lib/data-schemas/ctas'
7+
import type { RuleParams, RuleErrorCallback, Rule } from '../../types'
8+
9+
const ajv = new Ajv({ strict: false, allErrors: true })
10+
const validateCTASchema = ajv.compile(ctaSchemaDefinition)
11+
12+
export const ctasSchema: Rule = {
13+
names: ['GHD057', 'ctas-schema'],
14+
description: 'CTA URLs must conform to the schema',
15+
tags: ['ctas', 'schema', 'urls'],
16+
function: (params: RuleParams, onError: RuleErrorCallback) => {
17+
// Find all URLs in the content that might be CTAs
18+
// Updated regex to properly handle URLs in quotes and other contexts
19+
const urlRegex = /https?:\/\/[^\s)\]{}'">]+/g
20+
const content = params.lines.join('\n')
21+
22+
let match
23+
while ((match = urlRegex.exec(content)) !== null) {
24+
const url = match[0]
25+
26+
// Check if this URL has ref_ parameters and is on a GitHub domain (indicating it's a CTA URL)
27+
if (!url.includes('ref_')) continue
28+
29+
// Only validate CTA URLs on GitHub domains
30+
let hostname: string
31+
try {
32+
hostname = new URL(url).hostname
33+
} catch {
34+
// Invalid URL, skip validation
35+
continue
36+
}
37+
const allowedHosts = ['github.com', 'desktop.github.com']
38+
if (!allowedHosts.includes(hostname)) continue
39+
40+
// Skip placeholder/documentation example URLs
41+
const isPlaceholderUrl =
42+
/[A-Z_]+/.test(url) &&
43+
(url.includes('DESTINATION') ||
44+
url.includes('CTA+NAME') ||
45+
url.includes('LOCATION') ||
46+
url.includes('PRODUCT'))
47+
48+
if (isPlaceholderUrl) continue
49+
50+
try {
51+
const urlObj = new URL(url)
52+
const searchParams = urlObj.searchParams
53+
54+
// Extract ref_ parameters
55+
const refParams: Record<string, string> = {}
56+
const hasRefParams = Array.from(searchParams.keys()).some((key) => key.startsWith('ref_'))
57+
58+
if (!hasRefParams) continue
59+
60+
// Collect all ref_ parameters
61+
for (const [key, value] of searchParams.entries()) {
62+
if (key.startsWith('ref_')) {
63+
refParams[key] = value
64+
}
65+
}
66+
67+
// Check if this has old CTA parameters that can be auto-fixed
68+
const hasOldParams =
69+
'ref_cta' in refParams || 'ref_loc' in refParams || 'ref_page' in refParams
70+
71+
if (hasOldParams) {
72+
const result = convertOldCTAUrl(url)
73+
if (result && result.newUrl !== url) {
74+
// Find the line and create fix info
75+
const lineIndex = params.lines.findIndex((line) => line.includes(url))
76+
const lineNumber = lineIndex >= 0 ? lineIndex + 1 : 1
77+
const line = lineIndex >= 0 ? params.lines[lineIndex] : ''
78+
const urlStartInLine = line.indexOf(url)
79+
80+
const fixInfo = {
81+
editColumn: urlStartInLine + 1,
82+
deleteCount: url.length,
83+
insertText: result.newUrl,
84+
}
85+
86+
addError(
87+
onError,
88+
lineNumber,
89+
'CTA URL uses old parameter format (ref_cta, ref_loc, ref_page). Use new schema format (ref_product, ref_type, ref_style, ref_plan).',
90+
line,
91+
[urlStartInLine + 1, url.length],
92+
fixInfo,
93+
)
94+
}
95+
} else {
96+
// Validate new format URLs against schema
97+
const isValid = validateCTASchema(refParams)
98+
99+
if (!isValid) {
100+
const lineIndex = params.lines.findIndex((line) => line.includes(url))
101+
const lineNumber = lineIndex >= 0 ? lineIndex + 1 : 1
102+
const line = lineIndex >= 0 ? params.lines[lineIndex] : ''
103+
104+
// Process AJV errors manually for CTA URLs
105+
const errors = validateCTASchema.errors || []
106+
for (const error of errors) {
107+
let message = ''
108+
if (error.keyword === 'required') {
109+
message = `Missing required parameter: ${(error.params as any)?.missingProperty}`
110+
} else if (error.keyword === 'enum') {
111+
const paramName = error.instancePath.substring(1)
112+
// Get the actual invalid value from refParams and allowed values from params
113+
const invalidValue = refParams[paramName]
114+
const allowedValues = (error.params as any)?.allowedValues || []
115+
message = `Invalid value for ${paramName}: "${invalidValue}". Valid values are: ${allowedValues.join(', ')}`
116+
} else if (error.keyword === 'additionalProperties') {
117+
message = `Unexpected parameter: ${(error.params as any)?.additionalProperty}`
118+
} else {
119+
message = `CTA URL validation error: ${error.message}`
120+
}
121+
122+
addError(
123+
onError,
124+
lineNumber,
125+
message,
126+
line,
127+
null,
128+
null, // No fix for these types of schema violations
129+
)
130+
}
131+
}
132+
}
133+
} catch {
134+
// Invalid URL, skip validation
135+
continue
136+
}
137+
}
138+
},
139+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import { frontmatterValidation } from '@/content-linter/lib/linting-rules/frontm
5555
import { headerContentRequirement } from '@/content-linter/lib/linting-rules/header-content-requirement'
5656
import { thirdPartyActionsReusable } from '@/content-linter/lib/linting-rules/third-party-actions-reusable'
5757
import { frontmatterLandingRecommended } from '@/content-linter/lib/linting-rules/frontmatter-landing-recommended'
58+
import { ctasSchema } from '@/content-linter/lib/linting-rules/ctas-schema'
5859

5960
const noDefaultAltText = markdownlintGitHub.find((elem) =>
6061
elem.names.includes('no-default-alt-text'),
@@ -117,6 +118,7 @@ export const gitHubDocsMarkdownlint = {
117118
thirdPartyActionsReusable, // GHD054
118119
frontmatterValidation, // GHD055
119120
frontmatterLandingRecommended, // GHD056
121+
ctasSchema, // GHD057
120122

121123
// Search-replace rules
122124
searchReplace, // Open-source plugin

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,12 @@ export const githubDocsFrontmatterConfig = {
316316
'partial-markdown-files': false,
317317
'yml-files': false,
318318
},
319+
'ctas-schema': {
320+
// GHD057
321+
severity: 'error',
322+
'partial-markdown-files': true,
323+
'yml-files': true,
324+
},
319325
}
320326

321327
// Configures rules from the `github/markdownlint-github` repo
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { describe, expect, test } from 'vitest'
2+
3+
import { runRule } from '../../lib/init-test'
4+
import { ctasSchema } from '../../lib/linting-rules/ctas-schema'
5+
6+
describe(ctasSchema.names.join(' - '), () => {
7+
test('valid CTA URL passes validation', async () => {
8+
const markdown = `
9+
[Try Copilot](https://github.com/github-copilot/signup?ref_product=copilot&ref_type=trial&ref_style=text&ref_plan=pro)
10+
`
11+
const result = await runRule(ctasSchema, { strings: { markdown } })
12+
const errors = result.markdown
13+
expect(errors.length).toBe(0)
14+
})
15+
16+
test('invalid ref_product value fails validation', async () => {
17+
const markdown = `
18+
[Try Copilot](https://github.com/github-copilot/signup?ref_product=invalid&ref_type=trial&ref_style=text)
19+
`
20+
const result = await runRule(ctasSchema, { strings: { markdown } })
21+
const errors = result.markdown
22+
expect(errors.length).toBe(1)
23+
expect(errors[0].errorDetail).toContain('Invalid value for ref_product')
24+
})
25+
26+
test('missing required parameter fails validation', async () => {
27+
const markdown = `
28+
[Try Copilot](https://github.com/github-copilot/signup?ref_product=copilot&ref_style=text)
29+
`
30+
const result = await runRule(ctasSchema, { strings: { markdown } })
31+
const errors = result.markdown
32+
expect(errors.length).toBe(1)
33+
expect(errors[0].errorDetail).toContain('Missing required parameter: ref_type')
34+
})
35+
36+
test('unexpected parameter fails validation', async () => {
37+
const markdown = `
38+
[Try Copilot](https://github.com/github-copilot/signup?ref_product=copilot&ref_type=trial&ref_style=text&ref_unknown=test)
39+
`
40+
const result = await runRule(ctasSchema, { strings: { markdown } })
41+
const errors = result.markdown
42+
expect(errors.length).toBe(1)
43+
expect(errors[0].errorDetail).toContain('Unexpected parameter: ref_unknown')
44+
})
45+
46+
test('non-CTA URLs are ignored', async () => {
47+
const markdown = `
48+
[Regular link](https://github.com/features)
49+
[External link](https://example.com?param=value)
50+
`
51+
const result = await runRule(ctasSchema, { strings: { markdown } })
52+
const errors = result.markdown
53+
expect(errors.length).toBe(0)
54+
})
55+
56+
test('case sensitive validation enforces lowercase values', async () => {
57+
const markdown = `
58+
[Try Copilot](https://github.com/github-copilot/signup?ref_product=copilot&ref_type=Trial&ref_style=Button)
59+
`
60+
const result = await runRule(ctasSchema, { strings: { markdown } })
61+
const errors = result.markdown
62+
expect(errors.length).toBe(2) // Should have errors for 'Trial' and 'Button'
63+
64+
// Check that both expected errors are present (order may vary)
65+
const errorMessages = errors.map((error) => error.errorDetail)
66+
expect(errorMessages.some((msg) => msg.includes('Invalid value for ref_type: "Trial"'))).toBe(
67+
true,
68+
)
69+
expect(errorMessages.some((msg) => msg.includes('Invalid value for ref_style: "Button"'))).toBe(
70+
true,
71+
)
72+
})
73+
74+
test('URL regex correctly stops at curly braces (not overgreedy)', async () => {
75+
const markdown = `
76+
---
77+
try_ghec_for_free: '{% ifversion ghec %}https://github.com/account/enterprises/new?ref_cta=GHEC+trial&ref_loc=enterprise+administrators+landing+page&ref_page=docs{% endif %}'
78+
---
79+
`
80+
const result = await runRule(ctasSchema, { strings: { markdown } })
81+
const errors = result.markdown
82+
expect(errors.length).toBe(1) // Should detect and try to convert the old CTA format
83+
expect(errors[0].fixInfo).toBeDefined()
84+
85+
// The extracted URL should not include the curly brace - verify by checking the fix
86+
const fixedUrl = errors[0].fixInfo?.insertText
87+
expect(fixedUrl).toBeDefined()
88+
expect(fixedUrl).not.toContain('{') // Should not include curly brace from Liquid syntax
89+
expect(fixedUrl).not.toContain('}') // Should not include curly brace from Liquid syntax
90+
expect(fixedUrl).toContain('ref_product=ghec') // Should have converted old format correctly
91+
})
92+
93+
test('old CTA format autofix preserves original URL structure', async () => {
94+
const markdown = `
95+
[Try Copilot](https://github.com?ref_cta=Copilot+trial&ref_loc=getting+started&ref_page=docs)
96+
`
97+
const result = await runRule(ctasSchema, { strings: { markdown } })
98+
const errors = result.markdown
99+
expect(errors.length).toBe(1)
100+
expect(errors[0].fixInfo).toBeDefined()
101+
102+
// The fixed URL should not introduce extra slashes
103+
const fixedUrl = errors[0].fixInfo?.insertText
104+
expect(fixedUrl).toBeDefined()
105+
expect(fixedUrl).toMatch(/^https:\/\/github\.com\?ref_product=/) // Should not have github.com/?
106+
expect(fixedUrl).not.toMatch(/github\.com\/\?/) // Should not contain extra slash before query
107+
})
108+
109+
test('mixed parameter scenarios - new format takes precedence over old', async () => {
110+
const markdown = `
111+
[Mixed Format](https://github.com/copilot?ref_product=copilot&ref_type=trial&ref_cta=Copilot+Enterprise+trial&ref_loc=enterprise+page)
112+
`
113+
const result = await runRule(ctasSchema, { strings: { markdown } })
114+
const errors = result.markdown
115+
expect(errors.length).toBe(1)
116+
expect(errors[0].fixInfo).toBeDefined()
117+
118+
// Should preserve existing new format parameters, only convert old ones not already covered
119+
const fixedUrl = errors[0].fixInfo?.insertText
120+
expect(fixedUrl).toBeDefined()
121+
expect(fixedUrl).toContain('ref_product=copilot') // Preserved from new format
122+
expect(fixedUrl).toContain('ref_type=trial') // Preserved from new format
123+
expect(fixedUrl).not.toContain('ref_cta=') // Old parameter removed
124+
expect(fixedUrl).not.toContain('ref_loc=') // Old parameter removed
125+
})
126+
127+
test('hash fragment preservation during conversion', async () => {
128+
const markdown = `
129+
[Copilot Pricing](https://github.com/copilot?ref_cta=Copilot+trial&ref_loc=getting+started&ref_page=docs#pricing)
130+
`
131+
const result = await runRule(ctasSchema, { strings: { markdown } })
132+
const errors = result.markdown
133+
expect(errors.length).toBe(1)
134+
expect(errors[0].fixInfo).toBeDefined()
135+
136+
const fixedUrl = errors[0].fixInfo?.insertText
137+
expect(fixedUrl).toBeDefined()
138+
expect(fixedUrl).toContain('#pricing') // Hash fragment preserved
139+
expect(fixedUrl).toContain('ref_product=copilot')
140+
})
141+
142+
test('UTM parameter preservation during conversion', async () => {
143+
const markdown = `
144+
[Track This](https://github.com/copilot?utm_source=docs&utm_campaign=trial&ref_cta=Copilot+trial&ref_loc=getting+started&other_param=value)
145+
`
146+
const result = await runRule(ctasSchema, { strings: { markdown } })
147+
const errors = result.markdown
148+
expect(errors.length).toBe(1)
149+
expect(errors[0].fixInfo).toBeDefined()
150+
151+
const fixedUrl = errors[0].fixInfo?.insertText
152+
expect(fixedUrl).toBeDefined()
153+
expect(fixedUrl).toContain('utm_source=docs') // UTM preserved
154+
expect(fixedUrl).toContain('utm_campaign=trial') // UTM preserved
155+
expect(fixedUrl).toContain('other_param=value') // Other params preserved
156+
expect(fixedUrl).toContain('ref_product=copilot') // New CTA params added
157+
expect(fixedUrl).not.toContain('ref_cta=') // Old CTA params removed
158+
})
159+
160+
test('multiple query parameter types handled correctly', async () => {
161+
const markdown = `
162+
[Complex URL](https://github.com/features/copilot?utm_source=docs&ref_product=copilot&ref_type=invalid_type&campaign_id=123&ref_cta=old_cta&locale=en#section)
163+
`
164+
const result = await runRule(ctasSchema, { strings: { markdown } })
165+
const errors = result.markdown
166+
expect(errors.length).toBe(1) // Only old format conversion error
167+
expect(errors[0].errorDetail).toContain('old parameter format')
168+
expect(errors[0].fixInfo).toBeDefined() // Should have autofix
169+
})
170+
})

0 commit comments

Comments
 (0)