diff --git a/.github/actions/get-changed-files/action.yml b/.github/actions/get-changed-files/action.yml
index a966c8fceb3f..ba55f80a3ec4 100644
--- a/.github/actions/get-changed-files/action.yml
+++ b/.github/actions/get-changed-files/action.yml
@@ -33,6 +33,7 @@ runs:
- name: Gather changed files
id: get_changes
env:
+ INPUT_FILES: ${{ inputs.files }}
INPUT_HEAD: ${{ inputs.head || github.event.pull_request.head.ref || github.event.merge_group.head_ref || github.ref_name }}
INPUT_OUTPUT_FILE: ${{ inputs.output_file }}
shell: bash
diff --git a/assets/images/banner-images/hero-2.png b/assets/images/banner-images/hero-2.png
new file mode 100644
index 000000000000..0d02dfd6c078
Binary files /dev/null and b/assets/images/banner-images/hero-2.png differ
diff --git a/assets/images/banner-images/hero-3.png b/assets/images/banner-images/hero-3.png
new file mode 100644
index 000000000000..7de28041c963
Binary files /dev/null and b/assets/images/banner-images/hero-3.png differ
diff --git a/assets/images/banner-images/hero-4.png b/assets/images/banner-images/hero-4.png
new file mode 100644
index 000000000000..8d2bcb1ebaf6
Binary files /dev/null and b/assets/images/banner-images/hero-4.png differ
diff --git a/assets/images/banner-images/hero-5.png b/assets/images/banner-images/hero-5.png
new file mode 100644
index 000000000000..ef06249436e7
Binary files /dev/null and b/assets/images/banner-images/hero-5.png differ
diff --git a/assets/images/banner-images/hero-6.png b/assets/images/banner-images/hero-6.png
new file mode 100644
index 000000000000..3f68a2ebdf33
Binary files /dev/null and b/assets/images/banner-images/hero-6.png differ
diff --git a/content/code-security/secret-scanning/introduction/supported-secret-scanning-patterns.md b/content/code-security/secret-scanning/introduction/supported-secret-scanning-patterns.md
index 18f9231b709d..7422499daacb 100644
--- a/content/code-security/secret-scanning/introduction/supported-secret-scanning-patterns.md
+++ b/content/code-security/secret-scanning/introduction/supported-secret-scanning-patterns.md
@@ -53,6 +53,7 @@ This table lists the secrets supported by {% data variables.product.prodname_sec
| Provider | Token |
|----------|:--------------------|
+| Generic | ec_private_key |
| Generic | http_basic_authentication_header |
| Generic | http_bearer_authentication_header |
| Generic | mongodb_connection_string |
diff --git a/content/contributing/style-guide-and-content-model/style-guide.md b/content/contributing/style-guide-and-content-model/style-guide.md
index e1d9ca6ad151..05a6904befd3 100644
--- a/content/contributing/style-guide-and-content-model/style-guide.md
+++ b/content/contributing/style-guide-and-content-model/style-guide.md
@@ -151,16 +151,34 @@ A CTA is an explicit direction to the user to take an immediate action, such as
For example, the CTA on [AUTOTITLE](/enterprise-cloud@latest/admin/overview/setting-up-a-trial-of-github-enterprise-cloud) links to [an enterprise sales page](https://github.com/account/enterprises/new?ref_product=ghec&ref_type=trial&ref_style=text&ref_plan=enterprise) on {% data variables.product.prodname_dotcom_the_website %}.
-Style a CTA using the following format.
+### Required CTA parameters
+
+* `ref_product`:
+ * **Purpose**: The GitHub product the CTA leads users to.
+ * **Allowed values**: `copilot`, `ghec`, `desktop`
+ * **Example**: `ref_product=copilot`
+* `ref_type`:
+ * **Purpose**: The type of action the CTA encourages users to take.
+ * **Allowed values**: `trial`, `purchase`, `engagement`
+ * **Example**: `ref_type=purchase`
+* `ref_style`:
+ * **Purpose**: The way we are formatting the CTA in the docs.
+ * **Allowed values**: `button` or `text`
+ * **Example**: `ref_style=button`
+* `ref_plan` (_optional_):
+ * **Purpose**: For links to sign up for or trial a plan, the specific plan we link to.
+ * **Allowed values**: `enterprise`, `business`, `pro`, `free`
+ * **Example**: `ref_plan=business`
+
+Replace the placeholders with the relevant information for your CTA, where `DESTINATION/URL` is the URL that the button should navigate to:
```html
-{% raw %}Try PRODUCT NAME {% octicon "link-external" height:16 %}{% endraw %}
+{% raw %}Try PRODUCT NAME {% octicon "link-external" height:16 %}{% endraw %}
```
-Replace the placeholders with the relevant information for your CTA.
-* `DESTINATION/URL`: The URL that the button should navigate to.
-* `CTA+NAME`: The name of the CTA. For example, `GHEC+trial` or `Copilot+Business+Trial`.
-* `LOCATION`: The location in {% data variables.product.prodname_docs %} of the CTA. For example, `Setting+up+a+trial+of+GitHub+Enterprise+Cloud`.
+### Getting help with CTAs
+
+For help building a valid CTA URL, you can enter the command `npm run cta-builder` in your docs repo checkout. Answer each question and at the end you'll see your valid CTA.
## Code
diff --git a/content/repositories/managing-your-repositorys-settings-and-features/managing-repository-settings/setting-repository-visibility.md b/content/repositories/managing-your-repositorys-settings-and-features/managing-repository-settings/setting-repository-visibility.md
index 9a7c07b0fd93..a1c3e76abac4 100644
--- a/content/repositories/managing-your-repositorys-settings-and-features/managing-repository-settings/setting-repository-visibility.md
+++ b/content/repositories/managing-your-repositorys-settings-and-features/managing-repository-settings/setting-repository-visibility.md
@@ -128,8 +128,9 @@ For information about improving repository security, see [AUTOTITLE](/code-secur
{% data reusables.repositories.sidebar-settings %}
1. In the "Danger Zone" section, to the right of to "Change repository visibility", click **Change visibility**.
1. Select a visibility.
-1. To verify that you're changing the correct repository's visibility, type the name of the repository you want to change the visibility of.
-1. Click **I understand, change repository visibility**.
+1. Click to confirm that you are changing the visibility of the correct repository.
+1. Click **I have read and understand these effects**.
+1. Click **Make this repository public** or **Make this repository private**.
## Further reading
diff --git a/data/reusables/contributing/content-linter-rules.md b/data/reusables/contributing/content-linter-rules.md
index b8a6b9f41a6f..390f34bad904 100644
--- a/data/reusables/contributing/content-linter-rules.md
+++ b/data/reusables/contributing/content-linter-rules.md
@@ -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- | The octicon liquid syntax used is deprecated. Use this format instead `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 | |
diff --git a/data/tables/README.md b/data/tables/README.md
index 1595c428b479..277669049288 100644
--- a/data/tables/README.md
+++ b/data/tables/README.md
@@ -1,4 +1,4 @@
-# Data-driven tables
+# YAML-powered tables
## Overview
@@ -34,7 +34,7 @@ Every data-driven table needs **three files** that work together:
|-----------|----------|---------|
| **Data file** | `data/tables/` | Stores the table content in YAML format |
| **Content file** | `content/` | Displays the table using Liquid templating |
-| **Schema file** | `src/data-directory/lib/data-schemas/` | Validates the YAML structure |
+| **Schema file** | `src/data-directory/lib/data-schemas/tables/` | Validates the YAML structure |
**Estimated time**: 30-60 minutes for a new table
@@ -49,7 +49,7 @@ Create a new `.yml` file in `data/tables/` with a descriptive name.
Create a YAML structure that will allow me to generate a table that looks like:
[describe your table headers, rows, and columns OR attach an example]
-See src/secret-scanning/data/public-docs.yml for an example.
+See data/tables/supported-code-languages.yml for an example.
```
### Step 2: Create the content display
@@ -62,7 +62,7 @@ Create a Markdown table that is dynamically rendered using Liquid code.
Pull data from data/tables/TABLE_NAME.yml.
The table should look like: [describe your desired output OR attach an example]
-See content/code-security/secret-scanning/introduction/supported-secret-scanning-patterns.md for an example.
+See content/get-started/learning-about-github/github-language-support.md for an example.
Liquid docs: https://shopify.github.io/liquid
```
@@ -70,14 +70,14 @@ Liquid docs: https://shopify.github.io/liquid
### Step 3: Create the schema file
-Create a `.ts` file in `src/data-directory/lib/data-schemas/` with the same name as your YAML file.
+Create a `.ts` file in `src/data-directory/lib/data-schemas/tables/` with the same name as your YAML file.
**Copilot prompt template:**
```
Create a TypeScript schema following prior art under data-schemas that enforces
the structure of the data/TABLE_NAME.yml file.
-See src/data-directory/lib/data-schemas/learning-tracks.ts for an example.
+See src/data-directory/lib/data-schemas/tables/supported-code-languages.ts for an example.
```
## Testing and validation
diff --git a/package.json b/package.json
index 9381bfb2be42..8a7f1a02fc57 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/assets/scripts/find-orphaned-assets.ts b/src/assets/scripts/find-orphaned-assets.ts
index d6c539766ab2..9180fdbc9e1f 100755
--- a/src/assets/scripts/find-orphaned-assets.ts
+++ b/src/assets/scripts/find-orphaned-assets.ts
@@ -48,6 +48,13 @@ const EXCEPTIONS = new Set([
'assets/images/social-cards/default.png',
'assets/images/social-cards/issues.png',
'assets/images/social-cards/code-security.png',
+ // Hero images may not be used, but we keep them around for future use
+ 'assets/images/banner-images/hero-1.png',
+ 'assets/images/banner-images/hero-2.png',
+ 'assets/images/banner-images/hero-3.png',
+ 'assets/images/banner-images/hero-4.png',
+ 'assets/images/banner-images/hero-5.png',
+ 'assets/images/banner-images/hero-6.png',
])
function isExceptionPath(imagePath: string) {
diff --git a/src/content-linter/lib/helpers/utils.js b/src/content-linter/lib/helpers/utils.ts
similarity index 69%
rename from src/content-linter/lib/helpers/utils.js
rename to src/content-linter/lib/helpers/utils.ts
index f994deac22c3..b6279b0c69b6 100644
--- a/src/content-linter/lib/helpers/utils.js
+++ b/src/content-linter/lib/helpers/utils.ts
@@ -1,20 +1,38 @@
+// @ts-ignore - markdownlint-rule-helpers doesn't provide TypeScript declarations
import { addError, filterTokens } from 'markdownlint-rule-helpers'
import matter from '@gr2m/gray-matter'
+import type { RuleParams, RuleErrorCallback, MarkdownToken } from '@/content-linter/types'
+
// Adds an error object with details conditionally via the onError callback
-export function addFixErrorDetail(onError, lineNumber, expected, actual, range, fixInfo) {
+export function addFixErrorDetail(
+ onError: RuleErrorCallback,
+ lineNumber: number,
+ expected: string,
+ actual: string,
+ // Using flexible type to accommodate different range formats from various linting rules
+ range: [number, number] | number[] | null,
+ // Using any for fixInfo as markdownlint-rule-helpers accepts various fix info structures
+ fixInfo: any,
+): void {
addError(onError, lineNumber, `Expected: ${expected}`, ` Actual: ${actual}`, range, fixInfo)
}
-export function forEachInlineChild(params, type, handler) {
- filterTokens(params, 'inline', (token) => {
- for (const child of token.children.filter((c) => c.type === type)) {
+export function forEachInlineChild(
+ params: RuleParams,
+ type: string,
+ // Using any for child and token types because different linting rules pass tokens with varying structures
+ // beyond the base MarkdownToken interface (e.g., ImageToken with additional properties)
+ handler: (child: any, token: any) => void,
+): void {
+ filterTokens(params, 'inline', (token: MarkdownToken) => {
+ for (const child of token.children!.filter((c) => c.type === type)) {
handler(child, token)
}
})
}
-export function getRange(line, content) {
+export function getRange(line: string, content: string): [number, number] | null {
if (content.length === 0) {
// This function assumes that the content is something. If it's an
// empty string it can never produce a valid range.
@@ -24,7 +42,7 @@ export function getRange(line, content) {
return startColumnIndex !== -1 ? [startColumnIndex + 1, content.length] : null
}
-export function isStringQuoted(text) {
+export function isStringQuoted(text: string): boolean {
// String starts with either a single or double quote
// ends with either a single or double quote
// and optionally ends with a question mark or exclamation point
@@ -32,7 +50,7 @@ export function isStringQuoted(text) {
return /^['"].*['"][?!]?$/.test(text)
}
-export function isStringPunctuated(text) {
+export function isStringPunctuated(text: string): boolean {
// String ends with punctuation of either
// . ? ! and optionally ends with single
// or double quotes. This also allows
@@ -41,7 +59,7 @@ export function isStringPunctuated(text) {
return /^.*[.?!]['"]?$/.test(text)
}
-export function doesStringEndWithPeriod(text) {
+export function doesStringEndWithPeriod(text: string): boolean {
// String ends with punctuation of either
// . ? ! and optionally ends with single
// or double quotes. This also allows
@@ -50,7 +68,7 @@ export function doesStringEndWithPeriod(text) {
return /^.*\.['"]?$/.test(text)
}
-export function quotePrecedesLinkOpen(text) {
+export function quotePrecedesLinkOpen(text: string | undefined): boolean {
if (!text) return false
return text.endsWith('"') || text.endsWith("'")
}
@@ -87,12 +105,15 @@ export function quotePrecedesLinkOpen(text) {
// { type: 'paragraph_close'}, <-- Index 5 - NOT INCLUDED
// ]
//
-export function filterTokensByOrder(tokens, tokenOrder) {
- const matches = []
+export function filterTokensByOrder(
+ tokens: MarkdownToken[],
+ tokenOrder: string[],
+): MarkdownToken[] {
+ const matches: MarkdownToken[] = []
// Get a list of token indexes that match the
// first token (root) in the tokenOrder array
- const tokenRootIndexes = []
+ const tokenRootIndexes: number[] = []
const firstTokenOrderType = tokenOrder[0]
tokens.forEach((token, index) => {
if (token.type === firstTokenOrderType) {
@@ -125,7 +146,8 @@ export const docsDomains = ['docs.github.com', 'help.github.com', 'developer.git
// This is the format we get from Markdownlint.
// Returns null if the lines do not contain
// frontmatter properties.
-export function getFrontmatter(lines) {
+// Returns frontmatter as a Record with any values since YAML can contain various types
+export function getFrontmatter(lines: string[]): Record | null {
const fmString = lines.join('\n')
const { data } = matter(fmString)
// If there is no frontmatter or the frontmatter contains
@@ -134,7 +156,7 @@ export function getFrontmatter(lines) {
return data
}
-export function getFrontmatterLines(lines) {
+export function getFrontmatterLines(lines: string[]): string[] {
const indexStart = lines.indexOf('---')
if (indexStart === -1) return []
const indexEnd = lines.indexOf('---', indexStart + 1)
diff --git a/src/content-linter/lib/linting-rules/british-english-quotes.js b/src/content-linter/lib/linting-rules/british-english-quotes.ts
similarity index 85%
rename from src/content-linter/lib/linting-rules/british-english-quotes.js
rename to src/content-linter/lib/linting-rules/british-english-quotes.ts
index 3154659ed348..eaa8cf28c7b0 100644
--- a/src/content-linter/lib/linting-rules/british-english-quotes.js
+++ b/src/content-linter/lib/linting-rules/british-english-quotes.ts
@@ -1,14 +1,17 @@
+// @ts-ignore - markdownlint-rule-helpers doesn't provide TypeScript declarations
import { addError } from 'markdownlint-rule-helpers'
import { getRange } from '../helpers/utils'
import frontmatter from '@/frame/lib/read-frontmatter'
+import type { RuleParams, RuleErrorCallback } from '@/content-linter/types'
+
export const britishEnglishQuotes = {
names: ['GHD048', 'british-english-quotes'],
description:
'Periods and commas should be placed inside quotation marks (American English style)',
tags: ['punctuation', 'quotes', 'style', 'consistency'],
severity: 'warning', // Non-blocking as requested in the issue
- function: (params, onError) => {
+ function: (params: RuleParams, onError: RuleErrorCallback) => {
// Skip autogenerated files
const frontmatterString = params.frontMatterLines.join('\n')
const fm = frontmatter(frontmatterString).data
@@ -33,7 +36,7 @@ export const britishEnglishQuotes = {
/**
* Check if the current position is within a code context (code blocks, inline code, URLs)
*/
-function isInCodeContext(line, allLines, lineIndex) {
+function isInCodeContext(line: string, allLines: string[], lineIndex: number): boolean {
// Skip if line contains code fences
if (line.includes('```') || line.includes('~~~')) {
return true
@@ -67,18 +70,22 @@ function isInCodeContext(line, allLines, lineIndex) {
/**
* Find and report British English quote patterns in a line
*/
-function findAndReportBritishQuotes(line, lineNumber, onError) {
+function findAndReportBritishQuotes(
+ line: string,
+ lineNumber: number,
+ onError: RuleErrorCallback,
+): void {
// Pattern to find quote followed by punctuation outside
// Matches: "text". or 'text', or "text", etc.
const britishPattern = /(["'])([^"']*?)\1\s*([.,])/g
- let match
+ let match: RegExpMatchArray | null
while ((match = britishPattern.exec(line)) !== null) {
const quoteChar = match[1]
const quotedText = match[2]
const punctuation = match[3]
const fullMatch = match[0]
- const startIndex = match.index
+ const startIndex = match.index ?? 0
// Create the corrected version (punctuation inside quotes)
const correctedText = quoteChar + quotedText + punctuation + quoteChar
diff --git a/src/content-linter/lib/linting-rules/ctas-schema.ts b/src/content-linter/lib/linting-rules/ctas-schema.ts
new file mode 100644
index 000000000000..e70c924316a7
--- /dev/null
+++ b/src/content-linter/lib/linting-rules/ctas-schema.ts
@@ -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 = {}
+ 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
+ }
+ }
+ },
+}
diff --git a/src/content-linter/lib/linting-rules/header-content-requirement.js b/src/content-linter/lib/linting-rules/header-content-requirement.ts
similarity index 79%
rename from src/content-linter/lib/linting-rules/header-content-requirement.js
rename to src/content-linter/lib/linting-rules/header-content-requirement.ts
index 68d6c0e74aab..3f0cd923a943 100644
--- a/src/content-linter/lib/linting-rules/header-content-requirement.js
+++ b/src/content-linter/lib/linting-rules/header-content-requirement.ts
@@ -1,18 +1,28 @@
+// @ts-ignore - markdownlint-rule-helpers doesn't provide TypeScript declarations
import { addError, filterTokens } from 'markdownlint-rule-helpers'
+import type { RuleParams, RuleErrorCallback, MarkdownToken } from '@/content-linter/types'
+
+interface HeadingInfo {
+ token: MarkdownToken
+ lineNumber: number
+ level: number
+ line: string
+}
+
export const headerContentRequirement = {
names: ['GHD053', 'header-content-requirement'],
description: 'Headers must have content between them, such as an introduction',
tags: ['headers', 'structure', 'content'],
- function: (params, onError) => {
- const headings = []
+ function: (params: RuleParams, onError: RuleErrorCallback) => {
+ const headings: HeadingInfo[] = []
// Collect all heading tokens with their line numbers and levels
- filterTokens(params, 'heading_open', (token) => {
+ filterTokens(params, 'heading_open', (token: MarkdownToken) => {
headings.push({
token,
lineNumber: token.lineNumber,
- level: parseInt(token.tag.slice(1)), // Extract number from h1, h2, etc.
+ level: parseInt(token.tag!.slice(1)), // Extract number from h1, h2, etc.
line: params.lines[token.lineNumber - 1],
})
})
@@ -49,7 +59,11 @@ export const headerContentRequirement = {
* Check if there is meaningful content between two headings
* Returns true if content exists, false if only whitespace/empty lines
*/
-function checkForContentBetweenHeadings(lines, startLineNumber, endLineNumber) {
+function checkForContentBetweenHeadings(
+ lines: string[],
+ startLineNumber: number,
+ endLineNumber: number,
+): boolean {
// Convert to 0-based indexes and skip the heading lines themselves
const startIndex = startLineNumber // Skip the current heading line
const endIndex = endLineNumber - 2 // Stop before the next heading line
@@ -82,7 +96,7 @@ function checkForContentBetweenHeadings(lines, startLineNumber, endLineNumber) {
* Check if a line contains only Liquid tags that don't produce visible content
* This helps avoid false positives for conditional blocks
*/
-function isNonContentLiquidTag(line) {
+function isNonContentLiquidTag(line: string): boolean {
// Match common non-content Liquid tags
const nonContentTags = [
/^{%\s*ifversion\s+.*%}$/,
diff --git a/src/content-linter/lib/linting-rules/index.js b/src/content-linter/lib/linting-rules/index.ts
similarity index 90%
rename from src/content-linter/lib/linting-rules/index.js
rename to src/content-linter/lib/linting-rules/index.ts
index f267fbc14c2a..cc2422b03a65 100644
--- a/src/content-linter/lib/linting-rules/index.js
+++ b/src/content-linter/lib/linting-rules/index.ts
@@ -1,4 +1,6 @@
+// @ts-ignore - markdownlint-rule-search-replace doesn't provide TypeScript declarations
import searchReplace from 'markdownlint-rule-search-replace'
+// @ts-ignore - @github/markdownlint-github doesn't provide TypeScript declarations
import markdownlintGitHub from '@github/markdownlint-github'
import { codeFenceLineLength } from '@/content-linter/lib/linting-rules/code-fence-line-length'
@@ -55,11 +57,15 @@ 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) =>
+// Using any type because @github/markdownlint-github doesn't provide TypeScript declarations
+// The elements in the array have a 'names' property that contains rule identifiers
+const noDefaultAltText = markdownlintGitHub.find((elem: any) =>
elem.names.includes('no-default-alt-text'),
)
-const noGenericLinkText = markdownlintGitHub.find((elem) =>
+// Using any type because @github/markdownlint-github doesn't provide TypeScript declarations
+const noGenericLinkText = markdownlintGitHub.find((elem: any) =>
elem.names.includes('no-generic-link-text'),
)
@@ -117,6 +123,7 @@ export const gitHubDocsMarkdownlint = {
thirdPartyActionsReusable, // GHD054
frontmatterValidation, // GHD055
frontmatterLandingRecommended, // GHD056
+ ctasSchema, // GHD057
// Search-replace rules
searchReplace, // Open-source plugin
diff --git a/src/content-linter/lib/linting-rules/liquid-data-tags.js b/src/content-linter/lib/linting-rules/liquid-data-tags.ts
similarity index 80%
rename from src/content-linter/lib/linting-rules/liquid-data-tags.js
rename to src/content-linter/lib/linting-rules/liquid-data-tags.ts
index 66ef3b17550b..28bf2898b403 100644
--- a/src/content-linter/lib/linting-rules/liquid-data-tags.js
+++ b/src/content-linter/lib/linting-rules/liquid-data-tags.ts
@@ -1,5 +1,6 @@
-import { TokenKind } from 'liquidjs'
+// @ts-ignore - markdownlint-rule-helpers doesn't provide TypeScript declarations
import { addError } from 'markdownlint-rule-helpers'
+import { TokenKind } from 'liquidjs'
import { getDataByLanguage } from '@/data-directory/lib/get-data'
import {
@@ -9,6 +10,8 @@ import {
OUTPUT_CLOSE,
} from '../helpers/liquid-utils'
+import type { RuleParams, RuleErrorCallback } from '@/content-linter/types'
+
/*
Checks for instances where a Liquid data or indented_data_reference
tag is used but is not defined.
@@ -19,11 +22,12 @@ export const liquidDataReferencesDefined = {
'Liquid data or indented data references were found in content that have no value or do not exist in the data directory',
tags: ['liquid'],
parser: 'markdownit',
- function: (params, onError) => {
+ function: (params: RuleParams, onError: RuleErrorCallback) => {
const content = params.lines.join('\n')
+ // Using any type because getLiquidTokens returns tokens from liquidjs library without complete type definitions
const tokens = getLiquidTokens(content)
- .filter((token) => token.kind === TokenKind.Tag)
- .filter((token) => token.name === 'data' || token.name === 'indented_data_reference')
+ .filter((token: any) => token.kind === TokenKind.Tag)
+ .filter((token: any) => token.name === 'data' || token.name === 'indented_data_reference')
if (!tokens.length) return
@@ -54,12 +58,16 @@ export const liquidDataTagFormat = {
description:
'Liquid data or indented data references tags must be correctly formatted and have the correct number of arguments and spacing',
tags: ['liquid', 'format'],
- function: (params, onError) => {
+ function: (params: RuleParams, onError: RuleErrorCallback) => {
const CHECK_LIQUID_TAGS = [OUTPUT_OPEN, OUTPUT_CLOSE, '{', '}']
const content = params.lines.join('\n')
- const tokenTags = getLiquidTokens(content).filter((token) => token.kind === TokenKind.Tag)
- const dataTags = tokenTags.filter((token) => token.name === 'data')
- const indentedDataTags = tokenTags.filter((token) => token.name === 'indented_data_reference')
+ // Using any type because getLiquidTokens returns tokens from liquidjs library without complete type definitions
+ // Tokens have properties like 'kind', 'name', 'args', and 'content' that aren't fully typed
+ const tokenTags = getLiquidTokens(content).filter((token: any) => token.kind === TokenKind.Tag)
+ const dataTags = tokenTags.filter((token: any) => token.name === 'data')
+ const indentedDataTags = tokenTags.filter(
+ (token: any) => token.name === 'indented_data_reference',
+ )
for (const token of dataTags) {
// A data tag has only one argument, the data directory path.
@@ -125,9 +133,9 @@ export const liquidDataTagFormat = {
}
// Convenient wrapper because linting is always about English content
-const getData = (liquidRef) => getDataByLanguage(liquidRef, 'en')
+const getData = (liquidRef: string) => getDataByLanguage(liquidRef, 'en')
-const hasData = (liquidRef) => {
+const hasData = (liquidRef: string): boolean => {
try {
// If a reusable contains a nonexistent data reference, it will
// return undefined. If the data reference is inherently broken
diff --git a/src/content-linter/lib/linting-rules/liquid-syntax.js b/src/content-linter/lib/linting-rules/liquid-syntax.ts
similarity index 84%
rename from src/content-linter/lib/linting-rules/liquid-syntax.js
rename to src/content-linter/lib/linting-rules/liquid-syntax.ts
index d84e4d62bdf6..1203b7e07367 100644
--- a/src/content-linter/lib/linting-rules/liquid-syntax.js
+++ b/src/content-linter/lib/linting-rules/liquid-syntax.ts
@@ -1,9 +1,18 @@
+// @ts-ignore - markdownlint-rule-helpers doesn't provide TypeScript declarations
import { addError } from 'markdownlint-rule-helpers'
import { getFrontmatter } from '../helpers/utils'
import { liquid } from '@/content-render/index'
import { isLiquidError } from '@/languages/lib/render-with-fallback'
+import type { RuleParams, RuleErrorCallback } from '@/content-linter/types'
+
+interface ErrorMessageInfo {
+ errorDescription: string
+ lineNumber: number
+ columnNumber: number
+}
+
/*
Attempts to parse all liquid in the frontmatter of a file
to verify the syntax is correct.
@@ -12,7 +21,7 @@ export const frontmatterLiquidSyntax = {
names: ['GHD017', 'frontmatter-liquid-syntax'],
description: 'Frontmatter properties must use valid Liquid',
tags: ['liquid', 'frontmatter'],
- function: (params, onError) => {
+ function: (params: RuleParams, onError: RuleErrorCallback) => {
const fm = getFrontmatter(params.lines)
if (!fm) return
@@ -31,7 +40,7 @@ export const frontmatterLiquidSyntax = {
// If the error source is not a Liquid error but rather a
// ReferenceError or bad type we should allow that error to be thrown
if (!isLiquidError(error)) throw error
- const { errorDescription, columnNumber } = getErrorMessageInfo(error.message)
+ const { errorDescription, columnNumber } = getErrorMessageInfo((error as Error).message)
const lineNumber = params.lines.findIndex((line) => line.trim().startsWith(`${key}:`)) + 1
// Add the key length plus 3 to the column number to account colon and
// for the space after the key and column number starting at 1.
@@ -42,7 +51,7 @@ export const frontmatterLiquidSyntax = {
startRange + value.length - 1 > params.lines[lineNumber - 1].length
? params.lines[lineNumber - 1].length - startRange + 1
: value.length
- const range = [startRange, endRange]
+ const range: [number, number] = [startRange, endRange]
addError(
onError,
lineNumber,
@@ -64,20 +73,22 @@ export const liquidSyntax = {
names: ['GHD018', 'liquid-syntax'],
description: 'Markdown content must use valid Liquid',
tags: ['liquid'],
- function: function GHD018(params, onError) {
+ function: function GHD018(params: RuleParams, onError: RuleErrorCallback) {
try {
liquid.parse(params.lines.join('\n'))
} catch (error) {
// If the error source is not a Liquid error but rather a
// ReferenceError or bad type we should allow that error to be thrown
if (!isLiquidError(error)) throw error
- const { errorDescription, lineNumber, columnNumber } = getErrorMessageInfo(error.message)
+ const { errorDescription, lineNumber, columnNumber } = getErrorMessageInfo(
+ (error as Error).message,
+ )
const line = params.lines[lineNumber - 1]
// We don't have enough information to know the length of the full
// liquid tag without doing some regex testing and making assumptions
// about if the end tag is correctly formed, so we just give a
// range from the start of the tag to the end of the line.
- const range = [columnNumber, line.slice(columnNumber - 1).length]
+ const range: [number, number] = [columnNumber, line.slice(columnNumber - 1).length]
addError(
onError,
lineNumber,
@@ -90,7 +101,7 @@ export const liquidSyntax = {
},
}
-function getErrorMessageInfo(message) {
+function getErrorMessageInfo(message: string): ErrorMessageInfo {
const [errorDescription, lineString, columnString] = message.split(',')
// There has to be a line number so we'll default to line 1 if the message
// doesn't contain a line number.
diff --git a/src/content-linter/lib/linting-rules/outdated-release-phase-terminology.js b/src/content-linter/lib/linting-rules/outdated-release-phase-terminology.ts
similarity index 69%
rename from src/content-linter/lib/linting-rules/outdated-release-phase-terminology.js
rename to src/content-linter/lib/linting-rules/outdated-release-phase-terminology.ts
index dd799faff65a..d91249170547 100644
--- a/src/content-linter/lib/linting-rules/outdated-release-phase-terminology.js
+++ b/src/content-linter/lib/linting-rules/outdated-release-phase-terminology.ts
@@ -1,11 +1,14 @@
+// @ts-ignore - markdownlint-rule-helpers doesn't provide TypeScript declarations
import { addError, ellipsify } from 'markdownlint-rule-helpers'
import { getRange } from '@/content-linter/lib/helpers/utils'
import frontmatter from '@/frame/lib/read-frontmatter'
+import type { RuleParams, RuleErrorCallback } from '@/content-linter/types'
+
// Mapping of outdated terms to their new replacements
// Order matters - longer phrases must come first to avoid partial matches
-const TERMINOLOGY_REPLACEMENTS = [
+const TERMINOLOGY_REPLACEMENTS: [string, string][] = [
// Beta variations ā public preview (longer phrases first)
['limited public beta', 'public preview'],
['public beta', 'public preview'],
@@ -23,35 +26,51 @@ const TERMINOLOGY_REPLACEMENTS = [
['sunset', 'retired'],
]
+interface CompiledRegex {
+ regex: RegExp
+ outdatedTerm: string
+ replacement: string
+}
+
+interface MatchInfo {
+ start: number
+ end: number
+ text: string
+ replacement: string
+ outdatedTerm: string
+}
+
// Precompile RegExp objects for better performance
-const COMPILED_REGEXES = TERMINOLOGY_REPLACEMENTS.map(([outdatedTerm, replacement]) => ({
- regex: new RegExp(`(? ({
+ regex: new RegExp(`(?
- (match.index >= existing.start && match.index < existing.end) ||
- (match.index + match[0].length > existing.start &&
- match.index + match[0].length <= existing.end) ||
- (match.index <= existing.start && match.index + match[0].length >= existing.end),
+ (match!.index >= existing.start && match!.index < existing.end) ||
+ (match!.index + match![0].length > existing.start &&
+ match!.index + match![0].length <= existing.end) ||
+ (match!.index <= existing.start && match!.index + match![0].length >= existing.end),
)
if (!overlaps) {
@@ -76,7 +95,7 @@ export const outdatedReleasePhaseTerminology = {
'Outdated release phase terminology should be replaced with current GitHub terminology',
tags: ['terminology', 'consistency', 'release-phases'],
severity: 'error',
- function: (params, onError) => {
+ function: (params: RuleParams, onError: RuleErrorCallback) => {
// Skip autogenerated files
const frontmatterString = params.frontMatterLines.join('\n')
const fm = frontmatter(frontmatterString).data
diff --git a/src/content-linter/lib/linting-rules/table-column-integrity.js b/src/content-linter/lib/linting-rules/table-column-integrity.ts
similarity index 87%
rename from src/content-linter/lib/linting-rules/table-column-integrity.js
rename to src/content-linter/lib/linting-rules/table-column-integrity.ts
index 8456f2aec38e..35a2893bf689 100644
--- a/src/content-linter/lib/linting-rules/table-column-integrity.js
+++ b/src/content-linter/lib/linting-rules/table-column-integrity.ts
@@ -1,7 +1,10 @@
+// @ts-ignore - markdownlint-rule-helpers doesn't provide TypeScript declarations
import { addError } from 'markdownlint-rule-helpers'
import { getRange } from '../helpers/utils'
import frontmatter from '@/frame/lib/read-frontmatter'
+import type { RuleParams, RuleErrorCallback } from '@/content-linter/types'
+
// Regex to detect table rows (must start with |, contain at least one more |, and end with optional whitespace)
const TABLE_ROW_REGEX = /^\s*\|.*\|\s*$/
// Regex to detect table separator rows (contains only |, :, -, and whitespace)
@@ -12,7 +15,7 @@ const LIQUID_ONLY_CELL_REGEX = /^\s*{%\s*(ifversion|else|endif|elsif).*%}\s*$/
/**
* Counts the number of columns in a table row by splitting on | and handling edge cases
*/
-function countColumns(row) {
+function countColumns(row: string): number {
// Remove leading and trailing whitespace
const trimmed = row.trim()
@@ -38,7 +41,7 @@ function countColumns(row) {
/**
* Checks if a table row contains only Liquid conditionals
*/
-function isLiquidOnlyRow(row) {
+function isLiquidOnlyRow(row: string): boolean {
const trimmed = row.trim()
if (!trimmed.includes('|')) return false
@@ -61,7 +64,7 @@ export const tableColumnIntegrity = {
description: 'Tables must have consistent column counts across all rows',
tags: ['tables', 'accessibility', 'formatting'],
severity: 'error',
- function: (params, onError) => {
+ function: (params: RuleParams, onError: RuleErrorCallback) => {
// Skip autogenerated files
const frontmatterString = params.frontMatterLines.join('\n')
const fm = frontmatter(frontmatterString).data
@@ -69,9 +72,7 @@ export const tableColumnIntegrity = {
const lines = params.lines
let inTable = false
- let expectedColumnCount = null
- let tableStartLine = null
- let headerRow = null
+ let expectedColumnCount: number | null = null
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
@@ -84,8 +85,6 @@ export const tableColumnIntegrity = {
const nextLine = lines[i + 1]
if (nextLine && TABLE_SEPARATOR_REGEX.test(nextLine)) {
inTable = true
- tableStartLine = i + 1
- headerRow = line
expectedColumnCount = countColumns(line)
continue
}
@@ -95,8 +94,6 @@ export const tableColumnIntegrity = {
if (inTable && !isTableRow) {
inTable = false
expectedColumnCount = null
- tableStartLine = null
- headerRow = null
continue
}
@@ -113,10 +110,10 @@ export const tableColumnIntegrity = {
const range = getRange(line, line.trim())
let errorMessage
- if (actualColumnCount > expectedColumnCount) {
- errorMessage = `Table row has ${actualColumnCount} columns but header has ${expectedColumnCount}. Add ${actualColumnCount - expectedColumnCount} more column(s) to the header row to match this row.`
+ if (actualColumnCount > expectedColumnCount!) {
+ errorMessage = `Table row has ${actualColumnCount} columns but header has ${expectedColumnCount}. Add ${actualColumnCount - expectedColumnCount!} more column(s) to the header row to match this row.`
} else {
- errorMessage = `Table row has ${actualColumnCount} columns but header has ${expectedColumnCount}. Add ${expectedColumnCount - actualColumnCount} missing column(s) to this row.`
+ errorMessage = `Table row has ${actualColumnCount} columns but header has ${expectedColumnCount}. Add ${expectedColumnCount! - actualColumnCount} missing column(s) to this row.`
}
addError(
diff --git a/src/content-linter/style/github-docs.js b/src/content-linter/style/github-docs.js
index 4532b6c7a596..258eba71e290 100644
--- a/src/content-linter/style/github-docs.js
+++ b/src/content-linter/style/github-docs.js
@@ -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
diff --git a/src/content-linter/tests/lint-frontmatter-links.js b/src/content-linter/tests/lint-frontmatter-links.ts
similarity index 83%
rename from src/content-linter/tests/lint-frontmatter-links.js
rename to src/content-linter/tests/lint-frontmatter-links.ts
index 265da6670570..bd0dd476335c 100644
--- a/src/content-linter/tests/lint-frontmatter-links.js
+++ b/src/content-linter/tests/lint-frontmatter-links.ts
@@ -9,10 +9,12 @@ const pages = await loadPageMap(pageList)
const redirects = await loadRedirects(pageList)
const liquidElsif = /{%\s*elsif/
-const containsLiquidElseIf = (text) => liquidElsif.test(text)
+const containsLiquidElseIf = (text: string) => liquidElsif.test(text)
describe('front matter', () => {
- function makeCustomErrorMessage(page, trouble, key) {
+ // Using any type for page because it comes from loadPages which returns dynamic page objects with varying properties
+ // Using any[] for trouble because the error objects have different shapes depending on the validation that failed
+ function makeCustomErrorMessage(page: any, trouble: any[], key: string) {
let customErrorMessage = `In the front matter of ${page.relativePath} `
if (trouble.length > 0) {
if (trouble.length === 1) {
@@ -20,7 +22,8 @@ describe('front matter', () => {
} else {
customErrorMessage += `there are ${trouble.length} .${key} front matter entries that are not correct.`
}
- const nonWarnings = trouble.filter((t) => !t.warning)
+ // Using any type because trouble array contains objects with varying error properties
+ const nonWarnings = trouble.filter((t: any) => !t.warning)
for (const { uri, index, redirects } of nonWarnings) {
customErrorMessage += `\nindex: ${index} URI: ${uri}`
if (redirects) {
@@ -29,7 +32,8 @@ describe('front matter', () => {
customErrorMessage += '\tPage not found'
}
}
- if (trouble.find((t) => t.redirects)) {
+ // Using any type because trouble array contains objects with varying error properties
+ if (trouble.find((t: any) => t.redirects)) {
customErrorMessage += `\n\nNOTE! To automatically fix the redirects run this command:\n`
customErrorMessage += `\n\t./src/links/scripts/update-internal-links.js content/${page.relativePath}\n\n`
}
@@ -46,7 +50,8 @@ describe('front matter', () => {
const redirectsContext = { redirects, pages }
const trouble = page.includeGuides
- .map((uri, i) => checkURL(uri, i, redirectsContext))
+ // Using any type for uri because includeGuides can contain various URI formats
+ .map((uri: any, i: number) => checkURL(uri, i, redirectsContext))
.filter(Boolean)
const customErrorMessage = makeCustomErrorMessage(page, trouble, 'includeGuides')
diff --git a/src/content-linter/tests/unit/ctas-schema.ts b/src/content-linter/tests/unit/ctas-schema.ts
new file mode 100644
index 000000000000..0712e6541857
--- /dev/null
+++ b/src/content-linter/tests/unit/ctas-schema.ts
@@ -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
+ })
+})
diff --git a/src/content-linter/tests/unit/frontmatter-schema.js b/src/content-linter/tests/unit/frontmatter-schema.ts
similarity index 100%
rename from src/content-linter/tests/unit/frontmatter-schema.js
rename to src/content-linter/tests/unit/frontmatter-schema.ts
diff --git a/src/content-linter/tests/unit/frontmatter-search-replace.js b/src/content-linter/tests/unit/frontmatter-search-replace.ts
similarity index 97%
rename from src/content-linter/tests/unit/frontmatter-search-replace.js
rename to src/content-linter/tests/unit/frontmatter-search-replace.ts
index 152e61e67290..77cdbc8e2bc3 100644
--- a/src/content-linter/tests/unit/frontmatter-search-replace.js
+++ b/src/content-linter/tests/unit/frontmatter-search-replace.ts
@@ -1,5 +1,6 @@
import { describe, expect, test } from 'vitest'
import markdownlint from 'markdownlint'
+// @ts-ignore - markdownlint-rule-search-replace doesn't provide TypeScript declarations
import searchReplace from 'markdownlint-rule-search-replace'
import { searchReplaceConfig } from '../../style/github-docs'
diff --git a/src/content-linter/tests/unit/liquid-quoted-conditional-args.js b/src/content-linter/tests/unit/liquid-quoted-conditional-args.ts
similarity index 95%
rename from src/content-linter/tests/unit/liquid-quoted-conditional-args.js
rename to src/content-linter/tests/unit/liquid-quoted-conditional-args.ts
index 1c42c4ad3f11..3e32d96ac8cc 100644
--- a/src/content-linter/tests/unit/liquid-quoted-conditional-args.js
+++ b/src/content-linter/tests/unit/liquid-quoted-conditional-args.ts
@@ -42,7 +42,9 @@ describe(liquidQuotedConditionalArg.names.join(' - '), () => {
const errors = result.markdown
expect(errors.length).toBe(3)
expect(errors.map((error) => error.lineNumber)).toEqual([6, 7, 8])
- expect(errors[0].errorRange).toEqual([1, 22], [1, 29], [1, 23])
+ expect(errors[0].errorRange).toEqual([1, 22])
+ expect(errors[1].errorRange).toEqual([1, 29])
+ expect(errors[2].errorRange).toEqual([1, 30])
})
test('unless conditional with quote args fails', async () => {
const markdown = [
diff --git a/src/content-linter/tests/unit/liquid-versioning.js b/src/content-linter/tests/unit/liquid-versioning.ts
similarity index 100%
rename from src/content-linter/tests/unit/liquid-versioning.js
rename to src/content-linter/tests/unit/liquid-versioning.ts
diff --git a/src/content-linter/tests/unit/list-first-word-captitalization.js b/src/content-linter/tests/unit/list-first-word-captitalization.ts
similarity index 100%
rename from src/content-linter/tests/unit/list-first-word-captitalization.js
rename to src/content-linter/tests/unit/list-first-word-captitalization.ts
diff --git a/src/content-linter/tests/unit/rai-resuable-usage.js b/src/content-linter/tests/unit/rai-resuable-usage.ts
similarity index 100%
rename from src/content-linter/tests/unit/rai-resuable-usage.js
rename to src/content-linter/tests/unit/rai-resuable-usage.ts
diff --git a/src/content-linter/tests/unit/third-party-action-pinning.js b/src/content-linter/tests/unit/third-party-action-pinning.ts
similarity index 100%
rename from src/content-linter/tests/unit/third-party-action-pinning.js
rename to src/content-linter/tests/unit/third-party-action-pinning.ts
diff --git a/src/content-linter/tests/unit/yaml-scheduled-jobs.js b/src/content-linter/tests/unit/yaml-scheduled-jobs.ts
similarity index 100%
rename from src/content-linter/tests/unit/yaml-scheduled-jobs.js
rename to src/content-linter/tests/unit/yaml-scheduled-jobs.ts
diff --git a/src/content-render/scripts/cta-builder.ts b/src/content-render/scripts/cta-builder.ts
new file mode 100644
index 000000000000..df1c706593b7
--- /dev/null
+++ b/src/content-render/scripts/cta-builder.ts
@@ -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 = {
+ '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 = {
+ '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 ', '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 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,
+): Promise {
+ 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,
+): Promise {
+ 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 {
+ // 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 {
+ 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 {
+ 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 {
+ 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)
+ }
+}
diff --git a/src/content-render/unified/rewrite-table-captions.js b/src/content-render/unified/rewrite-table-captions.ts
similarity index 71%
rename from src/content-render/unified/rewrite-table-captions.js
rename to src/content-render/unified/rewrite-table-captions.ts
index af534430e9c4..79cf93f6df40 100644
--- a/src/content-render/unified/rewrite-table-captions.js
+++ b/src/content-render/unified/rewrite-table-captions.ts
@@ -1,4 +1,6 @@
import { visit } from 'unist-util-visit'
+import type { Node, Parent } from 'unist'
+import type { Element } from 'hast'
/**
* A rehype plugin that automatically adds aria-labelledby attributes to tables
@@ -30,16 +32,24 @@ import { visit } from 'unist-util-visit'
* 4. Skipping tables that already have accessibility attributes
*/
-function isTableElement(node) {
- return node.type === 'element' && node.tagName === 'table'
+interface HeadingInfo {
+ id: string
+ text: string
}
-function isHeadingElement(node) {
- return node.type === 'element' && ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(node.tagName)
+function isTableElement(node: Node): node is Element {
+ return node.type === 'element' && (node as Element).tagName === 'table'
}
-function hasExistingAccessibilityAttributes(tableNode) {
+function isHeadingElement(node: Node): node is Element {
return (
+ node.type === 'element' &&
+ ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes((node as Element).tagName)
+ )
+}
+
+function hasExistingAccessibilityAttributes(tableNode: Element): boolean {
+ return !!(
tableNode.properties &&
(tableNode.properties.ariaLabel ||
tableNode.properties.ariaLabelledBy ||
@@ -48,13 +58,13 @@ function hasExistingAccessibilityAttributes(tableNode) {
)
}
-function hasExistingCaption(tableNode) {
+function hasExistingCaption(tableNode: Element): boolean {
return tableNode.children?.some(
- (child) => child.type === 'element' && child.tagName === 'caption',
+ (child) => child.type === 'element' && (child as Element).tagName === 'caption',
)
}
-function findPrecedingHeading(parent, tableIndex) {
+function findPrecedingHeading(parent: Parent, tableIndex: number): HeadingInfo | null {
if (!parent.children || tableIndex === 0) return null
// Look backwards from the table position for the nearest heading
@@ -66,7 +76,7 @@ function findPrecedingHeading(parent, tableIndex) {
const headingId = node.properties?.id
if (headingId) {
return {
- id: headingId,
+ id: headingId as string,
text: extractTextFromNode(node),
}
}
@@ -75,7 +85,7 @@ function findPrecedingHeading(parent, tableIndex) {
// Stop searching if we hit another table or significant content block
if (
isTableElement(node) ||
- (node.type === 'element' && ['section', 'article', 'div'].includes(node.tagName))
+ (node.type === 'element' && ['section', 'article', 'div'].includes((node as Element).tagName))
) {
break
}
@@ -84,13 +94,13 @@ function findPrecedingHeading(parent, tableIndex) {
return null
}
-function extractTextFromNode(node) {
+function extractTextFromNode(node: Node): string {
if (node.type === 'text') {
- return node.value
+ return (node as any).value
}
- if (node.type === 'element' && node.children) {
- return node.children
+ if (node.type === 'element' && (node as Element).children) {
+ return (node as Element).children
.map((child) => extractTextFromNode(child))
.filter(Boolean)
.join('')
@@ -101,8 +111,8 @@ function extractTextFromNode(node) {
}
export default function addTableAccessibilityLabels() {
- return (tree) => {
- visit(tree, (node, index, parent) => {
+ return (tree: Node) => {
+ visit(tree, (node: Node, index: number | undefined, parent: Parent | undefined) => {
if (!isTableElement(node) || !parent || typeof index !== 'number') {
return
}
diff --git a/src/data-directory/lib/data-schemas/ctas.ts b/src/data-directory/lib/data-schemas/ctas.ts
new file mode 100644
index 000000000000..0d9a4eb6e15e
--- /dev/null
+++ b/src/data-directory/lib/data-schemas/ctas.ts
@@ -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'],
+ },
+ },
+}
diff --git a/src/data-directory/lib/data-schemas/index.ts b/src/data-directory/lib/data-schemas/index.ts
index f74735770fbb..12a957e39c99 100644
--- a/src/data-directory/lib/data-schemas/index.ts
+++ b/src/data-directory/lib/data-schemas/index.ts
@@ -1,8 +1,37 @@
+import fs from 'fs'
+import path from 'path'
+import { fileURLToPath } from 'url'
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+
interface DataSchemas {
[key: string]: string
}
-const dataSchemas: DataSchemas = {
+// Auto-discover table schemas from data/tables/ directory
+function loadTableSchemas(): DataSchemas {
+ const tablesDir = path.join(process.cwd(), 'data/tables')
+ const schemasDir = path.join(__dirname, 'tables')
+ const tableSchemas: DataSchemas = {}
+
+ if (fs.existsSync(tablesDir)) {
+ const yamlFiles = fs.readdirSync(tablesDir).filter((file) => file.endsWith('.yml'))
+
+ for (const yamlFile of yamlFiles) {
+ const name = path.basename(yamlFile, '.yml')
+ const schemaPath = path.join(schemasDir, `${name}.ts`)
+
+ if (fs.existsSync(schemaPath)) {
+ tableSchemas[`data/tables/${yamlFile}`] = `@/data-directory/lib/data-schemas/tables/${name}`
+ }
+ }
+ }
+
+ return tableSchemas
+}
+
+// Manual schema registrations for non-table data
+const manualSchemas: DataSchemas = {
'data/features': '@/data-directory/lib/data-schemas/features.js',
'data/variables': '@/data-directory/lib/data-schemas/variables',
'data/learning-tracks': '@/data-directory/lib/data-schemas/learning-tracks.js',
@@ -10,8 +39,12 @@ const dataSchemas: DataSchemas = {
'data/code-languages.yml': '@/data-directory/lib/data-schemas/code-languages',
'data/glossaries/candidates.yml': '@/data-directory/lib/data-schemas/glossaries-candidates.js',
'data/glossaries/external.yml': '@/data-directory/lib/data-schemas/glossaries-external.js',
- 'data/tables/supported-code-languages.yml':
- '@/data-directory/lib/data-schemas/supported-code-languages.js',
+}
+
+// Combine manual registrations with auto-discovered table schemas
+const dataSchemas: DataSchemas = {
+ ...manualSchemas,
+ ...loadTableSchemas(),
}
export default dataSchemas
diff --git a/src/data-directory/lib/data-schemas/supported-code-languages.ts b/src/data-directory/lib/data-schemas/tables/supported-code-languages.ts
similarity index 100%
rename from src/data-directory/lib/data-schemas/supported-code-languages.ts
rename to src/data-directory/lib/data-schemas/tables/supported-code-languages.ts
diff --git a/src/data-directory/tests/data-schemas.ts b/src/data-directory/tests/data-schemas.ts
index d2966023a63e..d0b71720bc6f 100644
--- a/src/data-directory/tests/data-schemas.ts
+++ b/src/data-directory/tests/data-schemas.ts
@@ -1,6 +1,6 @@
import yaml from 'js-yaml'
-import { readFileSync } from 'fs'
-import { extname, basename } from 'path'
+import { readFileSync, existsSync, readdirSync } from 'fs'
+import { extname, basename, join } from 'path'
import walk from 'walk-sync'
import { beforeAll, describe, expect, test } from 'vitest'
@@ -49,3 +49,24 @@ describe('single data files', () => {
expect(isValid, formattedErrors).toBe(true)
})
})
+
+describe('YAML-powered tables', () => {
+ test('all table files have corresponding schemas', () => {
+ const tablesDir = join(process.cwd(), 'data/tables')
+ const schemasDir = join(__dirname, '../lib/data-schemas/tables')
+
+ if (existsSync(tablesDir)) {
+ const yamlFiles = readdirSync(tablesDir).filter((file) => file.endsWith('.yml'))
+
+ for (const yamlFile of yamlFiles) {
+ const name = basename(yamlFile, '.yml')
+ const schemaPath = join(schemasDir, `${name}.ts`)
+ expect(existsSync(schemaPath)).toBe(true)
+
+ // Also verify it's registered in the dataSchemas
+ const dataKey = `data/tables/${yamlFile}`
+ expect(dataSchemas[dataKey]).toBeDefined()
+ }
+ }
+ })
+})
diff --git a/src/frame/lib/get-remote-json.js b/src/frame/lib/get-remote-json.ts
similarity index 81%
rename from src/frame/lib/get-remote-json.js
rename to src/frame/lib/get-remote-json.ts
index c5691b3709d8..670ef09c6405 100644
--- a/src/frame/lib/get-remote-json.js
+++ b/src/frame/lib/get-remote-json.ts
@@ -7,10 +7,21 @@ import statsd from '@/observability/lib/statsd'
// The only reason this is exported is for the sake of the unit tests'
// ability to test in-memory miss after purging this with a mutation
-export const cache = new Map()
+// Using any type for cache values because this function can fetch any JSON structure
+// The returned JSON content structure is unknown until runtime
+export const cache = new Map()
const inProd = process.env.NODE_ENV === 'production'
+interface GetRemoteJSONConfig {
+ retry?: {
+ limit?: number
+ }
+ timeout?: {
+ response?: number
+ }
+}
+
// Wrapper on `got()` that is able to both cache in memory and on disk.
// The on-disk caching is in `.remotejson/`.
// We use this for downloading `redirects.json` files from one of the
@@ -21,7 +32,12 @@ const inProd = process.env.NODE_ENV === 'production'
// 1. Is it in memory cache?
// 2. No, is it on disk?
// 3. No, download from the internet then store responses in memory and disk
-export default async function getRemoteJSON(url, config) {
+// Using any return type because this function fetches arbitrary JSON from remote URLs
+// The JSON structure varies depending on the URL and cannot be known at compile time
+export default async function getRemoteJSON(
+ url: string,
+ config?: GetRemoteJSONConfig,
+): Promise {
// We could get fancy and make the cache key depend on the `config` too
// given that this is A) only used for archived enterprise stuff,
// and B) the config is only applicable on cache miss when doing the `got()`.
@@ -59,7 +75,14 @@ export default async function getRemoteJSON(url, config) {
}
}
} catch (error) {
- if (!(error instanceof SyntaxError || (error instanceof Error && error.code === 'ENOENT'))) {
+ if (
+ !(
+ error instanceof SyntaxError ||
+ (error instanceof Error &&
+ 'code' in error &&
+ (error as NodeJS.ErrnoException).code === 'ENOENT')
+ )
+ ) {
throw error
}
}
diff --git a/src/frame/tests/get-remote-json.js b/src/frame/tests/get-remote-json.ts
similarity index 100%
rename from src/frame/tests/get-remote-json.js
rename to src/frame/tests/get-remote-json.ts
diff --git a/src/github-apps/tests/excluded-actors.js b/src/github-apps/tests/excluded-actors.ts
similarity index 100%
rename from src/github-apps/tests/excluded-actors.js
rename to src/github-apps/tests/excluded-actors.ts
diff --git a/src/landings/components/bespoke/BespokeLanding.tsx b/src/landings/components/bespoke/BespokeLanding.tsx
index 490860a5aaa8..c20b23ff99c6 100644
--- a/src/landings/components/bespoke/BespokeLanding.tsx
+++ b/src/landings/components/bespoke/BespokeLanding.tsx
@@ -4,11 +4,12 @@ import { DefaultLayout } from '@/frame/components/DefaultLayout'
import { useLandingContext } from '@/landings/context/LandingContext'
import { LandingHero } from '@/landings/components/shared/LandingHero'
import { ArticleGrid } from '@/landings/components/shared/LandingArticleGridWithFilter'
+import { LandingCarousel } from '@/landings/components/shared/LandingCarousel'
import type { ArticleCardItems } from '@/landings/types'
export const BespokeLanding = () => {
- const { title, intro, heroImage, introLinks, tocItems } = useLandingContext()
+ const { title, intro, heroImage, introLinks, tocItems, recommended } = useLandingContext()
const flatArticles: ArticleCardItems = useMemo(
() => tocItems.flatMap((item) => item.childTocItems || []),
@@ -21,6 +22,7 @@ export const BespokeLanding = () => {
diff --git a/src/landings/context/LandingContext.tsx b/src/landings/context/LandingContext.tsx
index 28f26ba20581..1fa89ab55e72 100644
--- a/src/landings/context/LandingContext.tsx
+++ b/src/landings/context/LandingContext.tsx
@@ -21,7 +21,7 @@ export type LandingContextT = {
currentLearningTrack?: LearningTrack
currentLayout: string
heroImage?: string
- // For discovery landing pages
+ // For landing pages with carousels
recommended?: Array<{ title: string; intro: string; href: string; category: string[] }> // Resolved article data
introLinks?: Record
// For journey landing pages
@@ -51,7 +51,7 @@ export const getLandingContextFromRequest = async (
let recommended: Array<{ title: string; intro: string; href: string; category: string[] }> = []
- if (landingType === 'discovery') {
+ if (landingType === 'discovery' || landingType === 'bespoke') {
// Use resolved recommended articles from the page after middleware processing
if (page.recommended && Array.isArray(page.recommended) && page.recommended.length > 0) {
recommended = page.recommended