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

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>
This commit is contained in:
Sarah Schneider
2025-10-10 15:04:38 -04:00
committed by GitHub
parent 29ac8677f1
commit 10eac91b8c
8 changed files with 909 additions and 0 deletions

View File

@@ -72,6 +72,7 @@
| GHD054 | third-party-actions-reusable | Code examples with third-party actions must include disclaimer reusable | warning | actions, reusable, third-party |
| 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 |
| [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

@@ -25,6 +25,7 @@
"copy-fixture-data": "tsx src/tests/scripts/copy-fixture-data.ts",
"count-translation-corruptions": "cross-env NODE_OPTIONS=--max-old-space-size=8192 tsx src/languages/scripts/count-translation-corruptions.ts",
"create-enterprise-issue": "tsx src/ghes-releases/scripts/create-enterprise-issue.ts",
"cta-builder": "tsx src/content-render/scripts/cta-builder.ts",
"debug": "cross-env NODE_ENV=development ENABLED_LANGUAGES=en nodemon --inspect src/frame/server.ts",
"delete-orphan-translation-files": "tsx src/workflows/delete-orphan-translation-files.ts",
"docsaudit": "tsx src/metrics/scripts/docsaudit.ts",

View File

@@ -0,0 +1,139 @@
// @ts-ignore - markdownlint-rule-helpers doesn't have TypeScript declarations
import { addError } from 'markdownlint-rule-helpers'
import Ajv from 'ajv'
import { convertOldCTAUrl } from '@/content-render/scripts/cta-builder'
import ctaSchemaDefinition from '@/data-directory/lib/data-schemas/ctas'
import type { RuleParams, RuleErrorCallback, Rule } from '../../types'
const ajv = new Ajv({ strict: false, allErrors: true })
const validateCTASchema = ajv.compile(ctaSchemaDefinition)
export const ctasSchema: Rule = {
names: ['GHD057', 'ctas-schema'],
description: 'CTA URLs must conform to the schema',
tags: ['ctas', 'schema', 'urls'],
function: (params: RuleParams, onError: RuleErrorCallback) => {
// Find all URLs in the content that might be CTAs
// Updated regex to properly handle URLs in quotes and other contexts
const urlRegex = /https?:\/\/[^\s)\]{}'">]+/g
const content = params.lines.join('\n')
let match
while ((match = urlRegex.exec(content)) !== null) {
const url = match[0]
// Check if this URL has ref_ parameters and is on a GitHub domain (indicating it's a CTA URL)
if (!url.includes('ref_')) continue
// Only validate CTA URLs on GitHub domains
let hostname: string
try {
hostname = new URL(url).hostname
} catch {
// Invalid URL, skip validation
continue
}
const allowedHosts = ['github.com', 'desktop.github.com']
if (!allowedHosts.includes(hostname)) continue
// Skip placeholder/documentation example URLs
const isPlaceholderUrl =
/[A-Z_]+/.test(url) &&
(url.includes('DESTINATION') ||
url.includes('CTA+NAME') ||
url.includes('LOCATION') ||
url.includes('PRODUCT'))
if (isPlaceholderUrl) continue
try {
const urlObj = new URL(url)
const searchParams = urlObj.searchParams
// Extract ref_ parameters
const refParams: Record<string, string> = {}
const hasRefParams = Array.from(searchParams.keys()).some((key) => key.startsWith('ref_'))
if (!hasRefParams) continue
// Collect all ref_ parameters
for (const [key, value] of searchParams.entries()) {
if (key.startsWith('ref_')) {
refParams[key] = value
}
}
// Check if this has old CTA parameters that can be auto-fixed
const hasOldParams =
'ref_cta' in refParams || 'ref_loc' in refParams || 'ref_page' in refParams
if (hasOldParams) {
const result = convertOldCTAUrl(url)
if (result && result.newUrl !== url) {
// Find the line and create fix info
const lineIndex = params.lines.findIndex((line) => line.includes(url))
const lineNumber = lineIndex >= 0 ? lineIndex + 1 : 1
const line = lineIndex >= 0 ? params.lines[lineIndex] : ''
const urlStartInLine = line.indexOf(url)
const fixInfo = {
editColumn: urlStartInLine + 1,
deleteCount: url.length,
insertText: result.newUrl,
}
addError(
onError,
lineNumber,
'CTA URL uses old parameter format (ref_cta, ref_loc, ref_page). Use new schema format (ref_product, ref_type, ref_style, ref_plan).',
line,
[urlStartInLine + 1, url.length],
fixInfo,
)
}
} else {
// Validate new format URLs against schema
const isValid = validateCTASchema(refParams)
if (!isValid) {
const lineIndex = params.lines.findIndex((line) => line.includes(url))
const lineNumber = lineIndex >= 0 ? lineIndex + 1 : 1
const line = lineIndex >= 0 ? params.lines[lineIndex] : ''
// Process AJV errors manually for CTA URLs
const errors = validateCTASchema.errors || []
for (const error of errors) {
let message = ''
if (error.keyword === 'required') {
message = `Missing required parameter: ${(error.params as any)?.missingProperty}`
} else if (error.keyword === 'enum') {
const paramName = error.instancePath.substring(1)
// Get the actual invalid value from refParams and allowed values from params
const invalidValue = refParams[paramName]
const allowedValues = (error.params as any)?.allowedValues || []
message = `Invalid value for ${paramName}: "${invalidValue}". Valid values are: ${allowedValues.join(', ')}`
} else if (error.keyword === 'additionalProperties') {
message = `Unexpected parameter: ${(error.params as any)?.additionalProperty}`
} else {
message = `CTA URL validation error: ${error.message}`
}
addError(
onError,
lineNumber,
message,
line,
null,
null, // No fix for these types of schema violations
)
}
}
}
} catch {
// Invalid URL, skip validation
continue
}
}
},
}

View File

@@ -55,6 +55,7 @@ import { frontmatterValidation } from '@/content-linter/lib/linting-rules/frontm
import { headerContentRequirement } from '@/content-linter/lib/linting-rules/header-content-requirement'
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'
const noDefaultAltText = markdownlintGitHub.find((elem) =>
elem.names.includes('no-default-alt-text'),
@@ -117,6 +118,7 @@ export const gitHubDocsMarkdownlint = {
thirdPartyActionsReusable, // GHD054
frontmatterValidation, // GHD055
frontmatterLandingRecommended, // GHD056
ctasSchema, // GHD057
// Search-replace rules
searchReplace, // Open-source plugin

View File

@@ -316,6 +316,12 @@ export const githubDocsFrontmatterConfig = {
'partial-markdown-files': false,
'yml-files': false,
},
'ctas-schema': {
// GHD057
severity: 'error',
'partial-markdown-files': true,
'yml-files': true,
},
}
// Configures rules from the `github/markdownlint-github` repo

View File

@@ -0,0 +1,170 @@
import { describe, expect, test } from 'vitest'
import { runRule } from '../../lib/init-test'
import { ctasSchema } from '../../lib/linting-rules/ctas-schema'
describe(ctasSchema.names.join(' - '), () => {
test('valid CTA URL passes validation', async () => {
const markdown = `
[Try Copilot](https://github.com/github-copilot/signup?ref_product=copilot&ref_type=trial&ref_style=text&ref_plan=pro)
`
const result = await runRule(ctasSchema, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(0)
})
test('invalid ref_product value fails validation', async () => {
const markdown = `
[Try Copilot](https://github.com/github-copilot/signup?ref_product=invalid&ref_type=trial&ref_style=text)
`
const result = await runRule(ctasSchema, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(1)
expect(errors[0].errorDetail).toContain('Invalid value for ref_product')
})
test('missing required parameter fails validation', async () => {
const markdown = `
[Try Copilot](https://github.com/github-copilot/signup?ref_product=copilot&ref_style=text)
`
const result = await runRule(ctasSchema, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(1)
expect(errors[0].errorDetail).toContain('Missing required parameter: ref_type')
})
test('unexpected parameter fails validation', async () => {
const markdown = `
[Try Copilot](https://github.com/github-copilot/signup?ref_product=copilot&ref_type=trial&ref_style=text&ref_unknown=test)
`
const result = await runRule(ctasSchema, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(1)
expect(errors[0].errorDetail).toContain('Unexpected parameter: ref_unknown')
})
test('non-CTA URLs are ignored', async () => {
const markdown = `
[Regular link](https://github.com/features)
[External link](https://example.com?param=value)
`
const result = await runRule(ctasSchema, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(0)
})
test('case sensitive validation enforces lowercase values', async () => {
const markdown = `
[Try Copilot](https://github.com/github-copilot/signup?ref_product=copilot&ref_type=Trial&ref_style=Button)
`
const result = await runRule(ctasSchema, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(2) // Should have errors for 'Trial' and 'Button'
// Check that both expected errors are present (order may vary)
const errorMessages = errors.map((error) => error.errorDetail)
expect(errorMessages.some((msg) => msg.includes('Invalid value for ref_type: "Trial"'))).toBe(
true,
)
expect(errorMessages.some((msg) => msg.includes('Invalid value for ref_style: "Button"'))).toBe(
true,
)
})
test('URL regex correctly stops at curly braces (not overgreedy)', async () => {
const markdown = `
---
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 %}'
---
`
const result = await runRule(ctasSchema, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(1) // Should detect and try to convert the old CTA format
expect(errors[0].fixInfo).toBeDefined()
// The extracted URL should not include the curly brace - verify by checking the fix
const fixedUrl = errors[0].fixInfo?.insertText
expect(fixedUrl).toBeDefined()
expect(fixedUrl).not.toContain('{') // Should not include curly brace from Liquid syntax
expect(fixedUrl).not.toContain('}') // Should not include curly brace from Liquid syntax
expect(fixedUrl).toContain('ref_product=ghec') // Should have converted old format correctly
})
test('old CTA format autofix preserves original URL structure', async () => {
const markdown = `
[Try Copilot](https://github.com?ref_cta=Copilot+trial&ref_loc=getting+started&ref_page=docs)
`
const result = await runRule(ctasSchema, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(1)
expect(errors[0].fixInfo).toBeDefined()
// The fixed URL should not introduce extra slashes
const fixedUrl = errors[0].fixInfo?.insertText
expect(fixedUrl).toBeDefined()
expect(fixedUrl).toMatch(/^https:\/\/github\.com\?ref_product=/) // Should not have github.com/?
expect(fixedUrl).not.toMatch(/github\.com\/\?/) // Should not contain extra slash before query
})
test('mixed parameter scenarios - new format takes precedence over old', async () => {
const markdown = `
[Mixed Format](https://github.com/copilot?ref_product=copilot&ref_type=trial&ref_cta=Copilot+Enterprise+trial&ref_loc=enterprise+page)
`
const result = await runRule(ctasSchema, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(1)
expect(errors[0].fixInfo).toBeDefined()
// Should preserve existing new format parameters, only convert old ones not already covered
const fixedUrl = errors[0].fixInfo?.insertText
expect(fixedUrl).toBeDefined()
expect(fixedUrl).toContain('ref_product=copilot') // Preserved from new format
expect(fixedUrl).toContain('ref_type=trial') // Preserved from new format
expect(fixedUrl).not.toContain('ref_cta=') // Old parameter removed
expect(fixedUrl).not.toContain('ref_loc=') // Old parameter removed
})
test('hash fragment preservation during conversion', async () => {
const markdown = `
[Copilot Pricing](https://github.com/copilot?ref_cta=Copilot+trial&ref_loc=getting+started&ref_page=docs#pricing)
`
const result = await runRule(ctasSchema, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(1)
expect(errors[0].fixInfo).toBeDefined()
const fixedUrl = errors[0].fixInfo?.insertText
expect(fixedUrl).toBeDefined()
expect(fixedUrl).toContain('#pricing') // Hash fragment preserved
expect(fixedUrl).toContain('ref_product=copilot')
})
test('UTM parameter preservation during conversion', async () => {
const markdown = `
[Track This](https://github.com/copilot?utm_source=docs&utm_campaign=trial&ref_cta=Copilot+trial&ref_loc=getting+started&other_param=value)
`
const result = await runRule(ctasSchema, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(1)
expect(errors[0].fixInfo).toBeDefined()
const fixedUrl = errors[0].fixInfo?.insertText
expect(fixedUrl).toBeDefined()
expect(fixedUrl).toContain('utm_source=docs') // UTM preserved
expect(fixedUrl).toContain('utm_campaign=trial') // UTM preserved
expect(fixedUrl).toContain('other_param=value') // Other params preserved
expect(fixedUrl).toContain('ref_product=copilot') // New CTA params added
expect(fixedUrl).not.toContain('ref_cta=') // Old CTA params removed
})
test('multiple query parameter types handled correctly', async () => {
const markdown = `
[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)
`
const result = await runRule(ctasSchema, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(1) // Only old format conversion error
expect(errors[0].errorDetail).toContain('old parameter format')
expect(errors[0].fixInfo).toBeDefined() // Should have autofix
})
})

View File

@@ -0,0 +1,544 @@
import { Command } from 'commander'
import readline from 'readline'
import chalk from 'chalk'
import Ajv from 'ajv'
import ctaSchema from '@/data-directory/lib/data-schemas/ctas'
const ajv = new Ajv({ strict: false, allErrors: true })
const validateCTASchema = ajv.compile(ctaSchema)
interface CTAParams {
ref_product?: string
ref_plan?: string
ref_type?: string
ref_style?: string
}
// Conversion mappings from old CTA format to new schema
const ctaToTypeMapping: Record<string, string> = {
'GHEC trial': 'trial',
'Copilot trial': 'trial',
'Copilot Enterprise trial': 'trial',
'Copilot Business trial': 'trial',
'Copilot Pro+': 'purchase',
'Copilot plans signup': 'engagement',
'download desktop': 'engagement',
'Copilot free': 'engagement',
}
const ctaToPlanMapping: Record<string, string> = {
'Copilot Enterprise trial': 'enterprise',
'Copilot Business trial': 'business',
'Copilot Pro+': 'pro',
'Copilot free': 'free',
'GHEC trial': 'enterprise',
}
// Keywords that suggest a button context vs inline text link
const buttonKeywords = ['landing', 'signup', 'download', 'trial']
const program = new Command()
// CLI setup
program
.name('cta-builder')
.description('Create a properly formatted Call-to-Action URL with tracking parameters.')
.version('1.0.0')
// Add conversion command
program
.command('convert')
.description('Convert old CTA URLs to new schema format')
.option('-u, --url <url>', 'Convert a single URL')
.option('-q, --quiet', 'Only output the new URL (no other messages)')
.action((options) => {
convertUrls(options)
})
// Add validation command
program
.command('validate')
.description('Validate a CTA URL against the schema')
.option('-u, --url <url>', 'URL to validate')
.action((options) => {
validateUrl(options)
})
// Default to interactive mode
program.action(() => {
interactiveBuilder()
})
// Only run CLI when script is executed directly, not when imported
if (import.meta.url === `file://${process.argv[1]}`) {
program.parse()
}
// Helper function to select from lettered options
async function selectFromOptions(
paramName: string,
message: string,
options: string[],
promptFn: (question: string) => Promise<string>,
): Promise<string> {
console.log(chalk.yellow(`\n${message} (${paramName}):`))
options.forEach((option, index) => {
const letter = String.fromCharCode(97 + index) // 97 is 'a' in ASCII
console.log(chalk.white(` ${letter}. ${option}`))
})
let attempts = 0
while (true) {
const answer = await promptFn('Enter the letter of your choice: ')
if (!answer) continue
const letterIndex = answer.toLowerCase().charCodeAt(0) - 97 // Convert letter to index
if (letterIndex >= 0 && letterIndex < options.length && answer.length === 1) {
return options[letterIndex]
}
const validLetters = options.map((_, index) => String.fromCharCode(97 + index)).join(', ')
console.log(chalk.red(`Invalid choice. Please enter one of: ${validLetters}`))
// Safety: prevent infinite loops in automated scenarios
if (++attempts > 50) {
throw new Error('Too many invalid attempts. Please restart the tool.')
}
}
}
// Helper function to confirm yes/no
async function confirmChoice(
message: string,
promptFn: (question: string) => Promise<string>,
): Promise<boolean> {
let attempts = 0
while (true) {
const answer = await promptFn(`${message} (y/n): `)
if (!answer) continue
const lower = answer.toLowerCase()
if (lower === 'y' || lower === 'yes') return true
if (lower === 'n' || lower === 'no') return false
console.log(chalk.red('Please enter y or n'))
// Safety: prevent infinite loops in automated scenarios
if (++attempts > 50) {
throw new Error('Too many invalid attempts. Please restart the tool.')
}
}
}
// Extract CTA parameters from a URL
function extractCTAParams(url: string): CTAParams {
const urlObj = new URL(url)
const ctaParams: CTAParams = {}
for (const [key, value] of urlObj.searchParams.entries()) {
if (key.startsWith('ref_')) {
;(ctaParams as any)[key] = value
}
}
return ctaParams
}
// Process AJV validation errors into readable messages
function formatValidationErrors(ctaParams: CTAParams, errors: any[]): string[] {
const errorMessages: string[] = []
for (const error of errors) {
let message = ''
if (error.keyword === 'required') {
message = `Missing required parameter: ${(error.params as any)?.missingProperty}`
} else if (error.keyword === 'enum') {
const paramName = error.instancePath.substring(1)
const invalidValue = ctaParams[paramName as keyof CTAParams]
const allowedValues = (error.params as any)?.allowedValues || []
message = `Invalid value for ${paramName}: "${invalidValue}". Valid values are: ${allowedValues.join(', ')}`
} else if (error.keyword === 'additionalProperties') {
message = `Unexpected parameter: ${(error.params as any)?.additionalProperty}`
} else {
message = `Validation error: ${error.message}`
}
errorMessages.push(message)
}
return errorMessages
}
// Full validation using AJV schema (consistent across all commands)
function validateCTAParams(params: CTAParams): { isValid: boolean; errors: string[] } {
const isValid = validateCTASchema(params)
const ajvErrors = validateCTASchema.errors || []
if (isValid) {
return { isValid: true, errors: [] }
}
const errors = formatValidationErrors(params, ajvErrors)
return {
isValid: false,
errors,
}
}
// Build URL with CTA parameters
function buildCTAUrl(baseUrl: string, params: CTAParams): string {
const url = new URL(baseUrl)
Object.entries(params).forEach(([key, value]) => {
if (value) {
url.searchParams.set(key, value)
}
})
return url.toString()
}
// Convert old CTA URL to new schema format
export function convertOldCTAUrl(oldUrl: string): { newUrl: string; notes: string[] } {
const notes: string[] = []
try {
const url = new URL(oldUrl)
// Build new parameters
const newParams: CTAParams = {}
// First, check if any of the new params already exist, and preserve those if so
for (const [key, value] of url.searchParams.entries()) {
for (const param of Object.keys(ctaSchema.properties)) {
if (key === param && key in ctaSchema.properties) {
if (
ctaSchema.properties[key as keyof typeof ctaSchema.properties].enum.includes(
value.toLowerCase(),
)
) {
newParams[key as keyof CTAParams] = value.toLowerCase()
} else {
notes.push(`- Found ${key} but "${value}" is not an allowed value, removing it`)
}
}
}
}
// Try to convert old params to new params
const refCta = url.searchParams.get('ref_cta') || ''
const refLoc = url.searchParams.get('ref_loc') || ''
// Map ref_product
if (!newParams.ref_product) {
newParams.ref_product = inferProductFromUrl(oldUrl, refCta)
notes.push(`- Missing ref_product - made an inference, manually update if needed`)
}
// Map ref_type
if (!newParams.ref_type) {
newParams.ref_type = ctaToTypeMapping[refCta] || 'engagement'
if (!ctaToTypeMapping[refCta]) {
notes.push(`- Missing ref_type - defaulted to "engagement", manually update if needed`)
}
}
// Map ref_style
if (!newParams.ref_style) {
newParams.ref_style = inferStyleFromContext(refLoc)
notes.push(`- Missing ref_style - made an inference, manually update if needed`)
}
// Map ref_plan (optional)
if (!newParams.ref_plan) {
if (ctaToPlanMapping[refCta]) {
newParams.ref_plan = ctaToPlanMapping[refCta]
}
}
// Build new URL - preserve all existing parameters except old ref_ parameters
const newUrl = new URL(url.toString())
// Remove old CTA parameters
newUrl.searchParams.delete('ref_cta')
newUrl.searchParams.delete('ref_loc')
newUrl.searchParams.delete('ref_page')
// Add new CTA parameters
Object.entries(newParams).forEach(([key, value]) => {
if (value) {
newUrl.searchParams.set(key, value)
}
})
// The URL constructor may add a slash before the question mark in
// "github.com?foo", but we don't want that. First, check if original
// URL had trailing slash before query params.
const urlBeforeQuery = oldUrl.split('?')[0]
const hadTrailingSlash = urlBeforeQuery.endsWith('/')
let finalUrl = newUrl.toString()
// Remove unwanted trailing slash if original didn't have one.
if (!hadTrailingSlash && finalUrl.includes('/?')) {
finalUrl = finalUrl.replace('/?', '?')
}
if (oldUrl === finalUrl) {
notes.push(`- Original URL is valid, no changes made!`)
}
return { newUrl: finalUrl, notes }
} catch (error) {
return {
newUrl: oldUrl,
notes: [`❌ Failed to parse URL: ${error}`],
}
}
}
function inferProductFromUrl(url: string, refCta: string): string {
let hostname = ''
try {
hostname = new URL(url).hostname.toLowerCase()
} catch {
// Fallback if url isn't valid: leave hostname empty
}
// Strict hostname check for desktop.github.com
if (hostname === 'desktop.github.com' || refCta.includes('desktop')) {
return 'desktop'
}
// Hostname contains 'copilot' (e.g., copilot.github.com), or refCta mentions copilot
if (
(hostname.includes('copilot') && hostname.endsWith('.github.com')) ||
refCta.toLowerCase().includes('copilot')
) {
return 'copilot'
}
// Hostname contains 'enterprise' (e.g. enterprise.github.com), or refCta mentions GHEC
if (
(hostname.includes('enterprise') && hostname.endsWith('.github.com')) ||
refCta.includes('GHEC')
) {
return 'ghec'
}
// Default fallback
return 'copilot'
}
function inferStyleFromContext(refLoc: string): string {
// If location suggests it's in a button context, return button
// Otherwise default to text for inline links
const isButton = buttonKeywords.some((keyword) => refLoc.toLowerCase().includes(keyword))
return isButton ? 'button' : 'text'
}
// Interactive CTA builder
async function interactiveBuilder(): Promise<void> {
// Create readline interface for interactive mode
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
})
// Helper function to prompt user (scoped to this function)
function prompt(question: string): Promise<string> {
return new Promise((resolve) => {
rl.question(question, (answer) => {
resolve(answer.trim())
})
})
}
try {
console.log(chalk.blue.bold('🚀 Guided CTA URL builder\n'))
// Get base URL with validation
let baseUrl = ''
while (!baseUrl) {
const input = await prompt('Enter the base URL (e.g., https://github.com/features/copilot): ')
try {
new URL(input)
baseUrl = input
} catch {
console.log(chalk.red('Please enter a valid URL'))
}
}
const params: CTAParams = {}
// Required parameters
console.log(chalk.white(`\nRequired parameters:`))
for (const requiredParam of ctaSchema.required) {
;(params as any)[requiredParam] = await selectFromOptions(
requiredParam,
(ctaSchema.properties as any)[requiredParam].description,
(ctaSchema.properties as any)[requiredParam].enum,
prompt,
)
}
// Optional parameters (properties not in required array)
console.log(chalk.white(`\nOptional parameters:\n`))
const allProperties = Object.keys(ctaSchema.properties)
const optionalProperties = allProperties.filter((prop) => !ctaSchema.required.includes(prop))
for (const optionalParam of optionalProperties) {
const includeParam = await confirmChoice(
`Include ${(ctaSchema.properties as any)[optionalParam].name.toLowerCase()}?`,
prompt,
)
if (includeParam) {
;(params as any)[optionalParam] = await selectFromOptions(
optionalParam,
(ctaSchema.properties as any)[optionalParam].description,
(ctaSchema.properties as any)[optionalParam].enum,
prompt,
)
}
}
// Validate parameters
const validation = validateCTAParams(params)
if (!validation.isValid) {
console.log(chalk.red('\n❌ Validation Errors:'))
validation.errors.forEach((error) => console.log(chalk.red(`${error}`)))
rl.close()
return
}
// Build and display URL
const ctaUrl = buildCTAUrl(baseUrl, params)
console.log(chalk.green('\n✅ CTA URL generated successfully!'))
console.log(chalk.white.bold('\nParameters summary:'))
Object.entries(params).forEach(([key, value]) => {
if (value) {
console.log(chalk.white(` ${key}: ${value}`))
}
})
console.log(chalk.white.bold('\nYour CTA URL:'))
console.log(chalk.cyan(ctaUrl))
console.log(chalk.yellow('\nCopy the URL above and use it in your documentation!'))
} catch (error) {
console.error(chalk.red('\n❌ An error occurred:'), error)
} finally {
rl.close()
}
}
// Convert URLs command handler
async function convertUrls(options: { url?: string; quiet?: boolean }): Promise<void> {
try {
if (!options.quiet) {
console.log(chalk.blue.bold('CTA URL converter'))
}
if (options.url) {
const result = convertOldCTAUrl(options.url)
if (options.quiet) {
// In quiet mode, only output the new URL
console.log(result.newUrl)
return
}
console.log(chalk.white('\nOriginal URL:'))
console.log(chalk.gray(options.url))
console.log(chalk.white('\nNew URL:'))
console.log(chalk.cyan(result.newUrl))
// Validate the converted URL using shared validation function
try {
const newParams = extractCTAParams(result.newUrl)
const validation = validateCTAParams(newParams)
if (!validation.isValid) {
console.log(chalk.red('\n❌ Validation errors in converted URL:'))
validation.errors.forEach((message) => console.log(chalk.red(`${message}`)))
}
} catch (validationError) {
console.log(chalk.red(`\n❌ Failed to validate new URL: ${validationError}`))
}
if (result.notes.length) {
console.log(chalk.white('\n👉 Notes:'))
result.notes.forEach((note) => console.log(` ${note}`))
}
} else {
if (!options.quiet) {
console.log(chalk.yellow('Please specify the --url option'))
console.log(chalk.white('\nExample:'))
console.log(
chalk.gray(
' tsx cta-builder.ts convert --url "https://github.com/copilot?ref_cta=Copilot+free&ref_loc=getting+started&ref_page=docs"',
),
)
}
}
} catch (error) {
if (!options.quiet) {
console.error(chalk.red('❌ An error occurred:'), error)
}
}
// The convert command doesn't use readline, so script should exit naturally
}
// Validate URLs command handler
async function validateUrl(options: { url?: string }): Promise<void> {
try {
console.log(chalk.blue.bold('CTA URL validator'))
if (options.url) {
console.log(chalk.white('\nValidating URL:'))
console.log(chalk.gray(options.url))
// Extract CTA parameters from URL
let ctaParams: CTAParams
try {
ctaParams = extractCTAParams(options.url)
} catch (error) {
console.log(chalk.red(`\n❌ Invalid URL: ${error}`))
return
}
// Check if URL has any CTA parameters
if (Object.keys(ctaParams).length === 0) {
console.log(chalk.yellow('\n No CTA parameters found in URL'))
return
}
// Validate against schema using shared validation function
const validation = validateCTAParams(ctaParams)
if (validation.isValid) {
console.log(chalk.green('\n✅ URL is valid'))
console.log(chalk.white('\nCTA parameters found:'))
Object.entries(ctaParams).forEach(([key, value]) => {
console.log(chalk.white(` ${key}: ${value}`))
})
} else {
console.log(chalk.red('\n❌ Validation errors:'))
validation.errors.forEach((message) => console.log(chalk.red(`${message}`)))
console.log(
chalk.yellow(
'\n💡 Try: npm run cta-builder -- convert --url "your-url" to auto-fix old format URLs',
),
)
}
} else {
console.log(chalk.yellow('Please specify the --url option'))
console.log(chalk.white('\nExample:'))
console.log(
chalk.gray(
' tsx cta-builder.ts validate --url "https://github.com/copilot?ref_product=copilot&ref_type=trial&ref_style=button"',
),
)
}
} catch (error) {
console.error(chalk.red('❌ An error occurred:'), error)
}
}

View File

@@ -0,0 +1,46 @@
// This schema enforces the structure for CTA (Call-to-Action) URL parameters
// Used to validate CTA tracking parameters in documentation links
export default {
type: 'object',
additionalProperties: false,
required: ['ref_product', 'ref_type', 'ref_style'],
properties: {
// GitHub Product: The GitHub product the CTA leads users to
// Format: ref_product=copilot
ref_product: {
type: 'string',
name: 'Product',
description: 'The GitHub product the CTA leads users to',
enum: ['copilot', 'ghec', 'desktop'],
},
// Type of CTA: The type of action the CTA encourages users to take
// Format: ref_type=trial
ref_type: {
type: 'string',
name: 'Type',
description: 'The type of action the CTA encourages users to take',
enum: ['trial', 'purchase', 'engagement'],
},
// CTA style: The way we are formatting the CTA in the docs
// Format: ref_style=button
ref_style: {
type: 'string',
name: 'Style',
description: 'The way we are formatting the CTA in the docs',
enum: ['button', 'text'],
},
// Type of plan (Optional): For links to sign up for or trial a plan, the specific plan we link to
// Format: ref_plan=business
ref_plan: {
type: 'string',
name: 'Plan',
description:
'For links to sign up for or trial a plan, the specific plan we link to (optional)',
enum: ['enterprise', 'business', 'pro', 'free'],
},
},
}