github-docs-arabic-enhanced / src /content-linter /lib /linting-rules /outdated-release-phase-terminology.ts
| 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: [string, string][] = [ | |
| // Beta variations → public preview (longer phrases first) | |
| ['limited public beta', 'public preview'], | |
| ['public beta', 'public preview'], | |
| ['private beta', 'private preview'], | |
| ['beta', 'public preview'], | |
| // Alpha → private preview | |
| ['alpha', 'private preview'], | |
| // Deprecated variations → closing down | |
| ['deprecation', 'closing down'], | |
| ['deprecated', 'closing down'], | |
| // Sunset → retired | |
| ['sunset', 'retired'], | |
| ] | |
| // Don't lint filepaths that have legitimate uses of these terms | |
| const EXCLUDED_PATHS: string[] = [ | |
| // Individual files | |
| 'content/actions/reference/runners/github-hosted-runners.md', | |
| 'content/actions/reference/workflows-and-actions/metadata-syntax.md', | |
| 'content/admin/administering-your-instance/administering-your-instance-from-the-command-line/command-line-utilities.md', | |
| 'content/authentication/managing-commit-signature-verification/checking-for-existing-gpg-keys.md', | |
| 'content/codespaces/setting-your-user-preferences/choosing-the-stable-or-beta-host-image.md', | |
| 'content/rest/using-the-rest-api/getting-started-with-the-rest-api.md', | |
| 'data/reusables/actions/jobs/choosing-runner-github-hosted.md', | |
| 'data/reusables/code-scanning/codeql-query-tables/cpp.md', | |
| 'data/reusables/dependabot/dependabot-updates-supported-versioning-tags.md', | |
| 'data/variables/release-phases.yml', | |
| 'data/release-notes/enterprise-server/3-17/0-rc1.yml', | |
| // Directories | |
| 'content/site-policy/', | |
| 'data/features/', | |
| 'data/release-notes/enterprise-server/3-14/', | |
| 'data/release-notes/enterprise-server/3-15/', | |
| ] | |
| 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: CompiledRegex[] = TERMINOLOGY_REPLACEMENTS.map( | |
| ([outdatedTerm, replacement]) => ({ | |
| regex: new RegExp(`(?<!\\w|-|_)${outdatedTerm.replace(/\s+/g, '\\s+')}(?!\\w|-|_)`, 'gi'), | |
| outdatedTerm, | |
| replacement, | |
| }), | |
| ) | |
| /** | |
| * Find all non-overlapping matches of outdated terminology in a line | |
| * @param line - The line of text to search | |
| * @returns Array of match objects with start, end, text, replacement, and outdatedTerm | |
| */ | |
| function findOutdatedTerminologyMatches(line: string): MatchInfo[] { | |
| const foundMatches: MatchInfo[] = [] | |
| // Check each outdated term (in order - longest first) | |
| for (const { regex, outdatedTerm, replacement } of COMPILED_REGEXES) { | |
| // Reset regex state for each line | |
| regex.lastIndex = 0 | |
| let match: RegExpExecArray | null | |
| while ((match = regex.exec(line)) !== null) { | |
| // Check if this match overlaps with any existing matches | |
| const overlaps = foundMatches.some( | |
| (existing) => | |
| (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) { | |
| foundMatches.push({ | |
| start: match.index, | |
| end: match.index + match[0].length, | |
| text: match[0], | |
| replacement, | |
| outdatedTerm, | |
| }) | |
| } | |
| } | |
| } | |
| // Sort matches by position for consistent ordering | |
| return foundMatches.sort((a, b) => a.start - b.start) | |
| } | |
| export const outdatedReleasePhaseTerminology = { | |
| names: ['GHD046', 'outdated-release-phase-terminology'], | |
| description: | |
| 'Outdated release phase terminology should be replaced with current GitHub terminology', | |
| tags: ['terminology', 'consistency', 'release-phases'], | |
| severity: 'error', | |
| function: (params: RuleParams, onError: RuleErrorCallback) => { | |
| // Skip excluded files | |
| for (const filepath of EXCLUDED_PATHS) { | |
| if (params.name.startsWith(filepath)) { | |
| return | |
| } | |
| } | |
| // Skip autogenerated files | |
| const frontmatterString = params.frontMatterLines.join('\n') | |
| const fm = frontmatter(frontmatterString).data | |
| if (fm && fm.autogenerated) return | |
| // Check all lines for outdated terminology | |
| for (let i = 0; i < params.lines.length; i++) { | |
| const line = params.lines[i] | |
| const lineNumber = i + 1 | |
| // Find all matches on this line | |
| const foundMatches = findOutdatedTerminologyMatches(line) | |
| // Report all found matches | |
| for (const matchInfo of foundMatches) { | |
| const range = getRange(line, matchInfo.text) | |
| const errorMessage = `Replace outdated terminology "${matchInfo.text}" with "${matchInfo.replacement}"` | |
| // Provide a fix suggestion | |
| const fixInfo = { | |
| editColumn: matchInfo.start + 1, | |
| deleteCount: matchInfo.text.length, | |
| insertText: matchInfo.replacement, | |
| } | |
| addError(onError, lineNumber, errorMessage, ellipsify(line), range, fixInfo) | |
| } | |
| } | |
| }, | |
| } | |