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