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 |
|
| 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 |
|
| 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 |
|
| 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: 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) | 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 | |
|
| [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",
|
"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",
|
"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",
|
"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",
|
"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",
|
"delete-orphan-translation-files": "tsx src/workflows/delete-orphan-translation-files.ts",
|
||||||
"docsaudit": "tsx src/metrics/scripts/docsaudit.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 { headerContentRequirement } from '@/content-linter/lib/linting-rules/header-content-requirement'
|
||||||
import { thirdPartyActionsReusable } from '@/content-linter/lib/linting-rules/third-party-actions-reusable'
|
import { thirdPartyActionsReusable } from '@/content-linter/lib/linting-rules/third-party-actions-reusable'
|
||||||
import { frontmatterLandingRecommended } from '@/content-linter/lib/linting-rules/frontmatter-landing-recommended'
|
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) =>
|
const noDefaultAltText = markdownlintGitHub.find((elem) =>
|
||||||
elem.names.includes('no-default-alt-text'),
|
elem.names.includes('no-default-alt-text'),
|
||||||
@@ -117,6 +118,7 @@ export const gitHubDocsMarkdownlint = {
|
|||||||
thirdPartyActionsReusable, // GHD054
|
thirdPartyActionsReusable, // GHD054
|
||||||
frontmatterValidation, // GHD055
|
frontmatterValidation, // GHD055
|
||||||
frontmatterLandingRecommended, // GHD056
|
frontmatterLandingRecommended, // GHD056
|
||||||
|
ctasSchema, // GHD057
|
||||||
|
|
||||||
// Search-replace rules
|
// Search-replace rules
|
||||||
searchReplace, // Open-source plugin
|
searchReplace, // Open-source plugin
|
||||||
|
|||||||
@@ -316,6 +316,12 @@ export const githubDocsFrontmatterConfig = {
|
|||||||
'partial-markdown-files': false,
|
'partial-markdown-files': false,
|
||||||
'yml-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
|
// 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