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:
@@ -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 | |
|
||||
|
||||
@@ -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",
|
||||
|
||||
139
src/content-linter/lib/linting-rules/ctas-schema.ts
Normal file
139
src/content-linter/lib/linting-rules/ctas-schema.ts
Normal 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
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
170
src/content-linter/tests/unit/ctas-schema.ts
Normal file
170
src/content-linter/tests/unit/ctas-schema.ts
Normal 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
|
||||
})
|
||||
})
|
||||
544
src/content-render/scripts/cta-builder.ts
Normal file
544
src/content-render/scripts/cta-builder.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
46
src/data-directory/lib/data-schemas/ctas.ts
Normal file
46
src/data-directory/lib/data-schemas/ctas.ts
Normal 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'],
|
||||
},
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user