1
0
mirror of synced 2025-12-19 18:10:59 -05:00

Batch linter updates (#58270)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Sarah Schneider
2025-10-30 11:41:32 -04:00
committed by GitHub
parent c54668d165
commit df04d106df
25 changed files with 260 additions and 2651 deletions

View File

@@ -3,14 +3,10 @@
| Rule ID | Rule Name(s) | Description | Severity | Tags |
| ------- | ------------ | ----------- | -------- | ---- |
| [MD001](https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md001.md) | heading-increment | Heading levels should only increment by one level at a time | error | headings |
| [MD004](https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md004.md) | ul-style | Unordered list style | error | bullet, ul |
| [MD009](https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md009.md) | no-trailing-spaces | Trailing spaces | error | whitespace |
| [MD011](https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md011.md) | no-reversed-links | Reversed link syntax | error | links |
| [MD012](https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md012.md) | no-multiple-blanks | Multiple consecutive blank lines | error | whitespace, blank_lines |
| [MD014](https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md014.md) | commands-show-output | Dollar signs used before commands without showing output | error | code |
| [MD018](https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md018.md) | no-missing-space-atx | No space after hash on atx style heading | error | headings, atx, spaces |
| [MD019](https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md019.md) | no-multiple-space-atx | Multiple spaces after hash on atx style heading | error | headings, atx, spaces |
| [MD022](https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md022.md) | blanks-around-headings | Headings should be surrounded by blank lines | error | headings, blank_lines |
| [MD023](https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md023.md) | heading-start-left | Headings must start at the beginning of the line | error | headings, spaces |
| [MD027](https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md027.md) | no-multiple-space-blockquote | Multiple spaces after blockquote symbol | error | blockquote, whitespace, indentation |
| [MD029](https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md029.md) | ol-prefix | Ordered list item prefix | error | ol |
@@ -20,8 +16,6 @@
| [MD039](https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md039.md) | no-space-in-links | Spaces inside link text | error | whitespace, links |
| [MD040](https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md040.md) | fenced-code-language | Fenced code blocks should have a language specified | error | code, language |
| [MD042](https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md042.md) | no-empty-links | No empty links | error | links |
| [MD047](https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md047.md) | single-trailing-newline | Files should end with a single newline character | error | blank_lines |
| [MD049](https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md049.md) | emphasis-style | Emphasis style | error | emphasis |
| [MD050](https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md050.md) | strong-style | Strong style | error | emphasis |
| [GH001](https://github.com/github/markdownlint-github/blob/main/docs/rules/GH001-no-default-alt-text.md) | no-default-alt-text | Images should have meaningful alternative text (alt text) | error | accessibility, images |
| [GH002](https://github.com/github/markdownlint-github/blob/main/docs/rules/GH002-no-generic-link-text.md) | no-generic-link-text | Avoid using generic link text like `Learn more` or `Click here` | error | accessibility, links |
@@ -47,11 +41,9 @@
| GHD020 | liquid-ifversion-tags | Liquid `ifversion` tags should contain valid version names as arguments | error | liquid, versioning |
| GHD021 | yaml-scheduled-jobs | YAML snippets that include scheduled workflows must not run on the hour and must be unique | error | feature, actions |
| GHD022 | liquid-ifversion-versions | Liquid `ifversion`, `elsif`, and `else` tags should be valid and not contain unsupported versions. | error | liquid, versioning |
| GHD030 | code-fence-line-length | Code fence lines should not exceed a maximum length | warning | code, accessibility |
| GHD031 | image-alt-text-exclude-words | Alternate text for images should not begin with words like "image" or "graphic" | error | accessibility, images |
| GHD032 | image-alt-text-end-punctuation | Alternate text for images should end with punctuation | error | accessibility, images |
| GHD033 | incorrect-alt-text-length | Images alternate text should be between 40-150 characters | warning | accessibility, images |
| GHD034 | list-first-word-capitalization | First word of list item should be capitalized | warning | ul, ol |
| GHD035 | rai-reusable-usage | RAI articles and reusables can only reference reusable content in the data/reusables/rai directory | error | feature, rai |
| GHD036 | image-no-gif | Image must not be a gif, styleguide reference: contributing/style-guide-and-content-model/style-guide.md#images | error | images |
| GHD038 | expired-content | Expired content must be remediated. | warning | expired |
@@ -64,13 +56,9 @@
| GHD045 | code-annotation-comment-spacing | Code comments in annotation blocks must have exactly one space after the comment character(s) | warning | code, comments, annotate, spacing |
| GHD046 | outdated-release-phase-terminology | Outdated release phase terminology should be replaced with current GitHub terminology | warning | terminology, consistency, release-phases |
| GHD047 | table-column-integrity | Tables must have consistent column counts across all rows | warning | tables, accessibility, formatting |
| GHD048 | british-english-quotes | Periods and commas should be placed inside quotation marks (American English style) | warning | punctuation, quotes, style, consistency |
| GHD049 | note-warning-formatting | Note and warning tags should be formatted according to style guide | warning | formatting, callouts, notes, warnings, style |
| GHD050 | multiple-emphasis-patterns | Do not use more than one emphasis/strong, italics, or uppercase for a string | warning | formatting, emphasis, style |
| GHD051 | frontmatter-versions-whitespace | Versions frontmatter should not contain unnecessary whitespace | warning | frontmatter, versions |
| GHD053 | header-content-requirement | Headers must have content between them, such as an introduction | warning | headers, structure, content |
| 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 |
| GHD058 | journey-tracks-liquid | Journey track properties must use valid Liquid syntax | error | frontmatter, journey-tracks, liquid |

View File

@@ -0,0 +1,22 @@
interface LintFlaw {
severity: string
ruleNames: string[]
errorDetail?: string
}
/**
* Gets all rule names from a flaw, including sub-rules from search-replace errors
*/
export function getAllRuleNames(flaw: LintFlaw): string[] {
const ruleNames = [...flaw.ruleNames]
// Extract sub-rule name from search-replace error details
if (flaw.ruleNames.includes('search-replace') && flaw.errorDetail) {
const match = flaw.errorDetail.match(/^([^:]+):/)
if (match) {
ruleNames.push(match[1])
}
}
return ruleNames
}

View File

@@ -1,67 +0,0 @@
import nodePath from 'path'
import { reportingConfig } from '@/content-linter/style/github-docs'
interface LintFlaw {
severity: string
ruleNames: string[]
errorDetail?: string
}
/**
* Determines if a lint result should be included based on reporting configuration
*
* @param flaw - The lint flaw object containing rule names, severity, etc.
* @param filePath - The path of the file being linted
* @returns true if the flaw should be included, false if it should be excluded
*/
export function shouldIncludeResult(flaw: LintFlaw, filePath: string): boolean {
if (!flaw.ruleNames || !Array.isArray(flaw.ruleNames)) {
return true
}
// Extract all possible rule names including sub-rules from search-replace
const allRuleNames = [...flaw.ruleNames]
// For search-replace rules, extract the sub-rule name from errorDetail
if (flaw.ruleNames.includes('search-replace') && flaw.errorDetail) {
const match = flaw.errorDetail.match(/^([^:]+):/)
if (match) {
allRuleNames.push(match[1])
}
}
// Check if any rule name is in the exclude list
const hasExcludedRule = allRuleNames.some((ruleName: string) =>
reportingConfig.excludeRules.includes(ruleName),
)
if (hasExcludedRule) {
return false
}
// Check if this specific file should be excluded for any of the rules
for (const ruleName of allRuleNames) {
const excludedFiles =
reportingConfig.excludeFilesFromRules?.[
ruleName as keyof typeof reportingConfig.excludeFilesFromRules
]
if (
excludedFiles &&
excludedFiles.some((excludedPath: string) => {
// Normalize paths for comparison
const normalizedFilePath = nodePath.normalize(filePath)
const normalizedExcludedPath = nodePath.normalize(excludedPath)
return (
normalizedFilePath === normalizedExcludedPath ||
normalizedFilePath.endsWith(normalizedExcludedPath)
)
})
) {
return false
}
}
// Default to true - include everything unless explicitly excluded
// This function only handles exclusions; reporting-specific inclusion logic
// (like severity/rule filtering) is handled separately in lint-report.ts
return true
}

View File

@@ -1,106 +0,0 @@
// @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: RuleParams, onError: RuleErrorCallback) => {
// Skip autogenerated files
const frontmatterString = params.frontMatterLines.join('\n')
const fm = frontmatter(frontmatterString).data
if (fm && fm.autogenerated) return
// Check each line for British English quote patterns
for (let i = 0; i < params.lines.length; i++) {
const line = params.lines[i]
const lineNumber = i + 1
// Skip code blocks, code spans, and URLs
if (isInCodeContext(line, params.lines, i)) {
continue
}
// Find British English quote patterns and report them
findAndReportBritishQuotes(line, lineNumber, onError)
}
},
}
/**
* Check if the current position is within a code context (code blocks, inline code, URLs)
*/
function isInCodeContext(line: string, allLines: string[], lineIndex: number): boolean {
// Skip if line contains code fences
if (line.includes('```') || line.includes('~~~')) {
return true
}
// Check if we're inside a code block
let inCodeBlock = false
for (let i = 0; i < lineIndex; i++) {
if (allLines[i].includes('```') || allLines[i].includes('~~~')) {
inCodeBlock = !inCodeBlock
}
}
if (inCodeBlock) {
return true
}
// Skip if line appears to be mostly code (has multiple backticks)
const backtickCount = (line.match(/`/g) || []).length
if (backtickCount >= 4) {
return true
}
// Skip URLs and email addresses
if (line.includes('http://') || line.includes('https://') || line.includes('mailto:')) {
return true
}
return false
}
/**
* Find and report British English quote patterns in a line
*/
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: 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 ?? 0
// Create the corrected version (punctuation inside quotes)
const correctedText = quoteChar + quotedText + punctuation + quoteChar
const range = getRange(line, fullMatch)
const punctuationName = punctuation === '.' ? 'period' : 'comma'
const errorMessage = `Use American English punctuation: place ${punctuationName} inside the quotation marks`
// Provide auto-fix
const fixInfo = {
editColumn: startIndex + 1,
deleteCount: fullMatch.length,
insertText: correctedText,
}
addError(onError, lineNumber, errorMessage, line, range, fixInfo)
}
}

View File

@@ -1,34 +0,0 @@
// @ts-ignore - markdownlint-rule-helpers doesn't provide TypeScript declarations
import { addError, filterTokens, newLineRe } from 'markdownlint-rule-helpers'
import type { RuleParams, RuleErrorCallback, MarkdownToken, Rule } from '@/content-linter/types'
export const codeFenceLineLength: Rule = {
names: ['GHD030', 'code-fence-line-length'],
description: 'Code fence lines should not exceed a maximum length',
tags: ['code', 'accessibility'],
parser: 'markdownit',
function: (params: RuleParams, onError: RuleErrorCallback) => {
const MAX_LINE_LENGTH: number = params.config?.maxLength || 60
filterTokens(params, 'fence', (token: MarkdownToken) => {
if (!token.content) return
const lines: string[] = token.content.split(newLineRe)
lines.forEach((line: string, index: number) => {
if (line.length > MAX_LINE_LENGTH) {
// The token line number is the line number of the first line of the
// code fence. We want to report the line number of the content within
// the code fence so we need to add 1 + the index.
const lineNumber: number = token.lineNumber + index + 1
addError(
onError,
lineNumber,
`Code fence line exceeds ${MAX_LINE_LENGTH} characters.`,
line,
[1, line.length],
null, // No fix possible
)
}
})
})
},
}

View File

@@ -1,214 +0,0 @@
// @ts-ignore - no types available for markdownlint-rule-helpers
import { addError } from 'markdownlint-rule-helpers'
import { getFrontmatter } from '@/content-linter/lib/helpers/utils'
import type { RuleParams, RuleErrorCallback } from '@/content-linter/types'
interface PropertyLimits {
max: number
recommended: number
required?: boolean
}
interface ContentRules {
title: PropertyLimits
shortTitle: PropertyLimits
intro: PropertyLimits
requiredProperties: string[]
}
type ContentType = 'category' | 'mapTopic' | 'article' | null
// Strip liquid tags from text for character counting purposes
function stripLiquidTags(text: unknown): string {
if (typeof text !== 'string') return text as string
// Remove both {% %} and {{ }} liquid tags
return text.replace(/\{%.*?%\}/g, '').replace(/\{\{.*?\}\}/g, '')
}
export const frontmatterValidation = {
names: ['GHD055', 'frontmatter-validation'],
description:
'Frontmatter properties must meet character limits and required property requirements',
tags: ['frontmatter', 'character-limits', 'required-properties'],
function: (params: RuleParams, onError: RuleErrorCallback) => {
const fm = getFrontmatter(params.lines as string[])
if (!fm) return
// Detect content type based on frontmatter properties and file path
const contentType = detectContentType(fm, params.name)
// Define character limits and requirements for different content types
const contentRules: Record<string, ContentRules> = {
category: {
title: { max: 70, recommended: 67 },
shortTitle: { max: 30, recommended: 27 },
intro: { required: true, recommended: 280, max: 362 },
requiredProperties: ['intro'],
},
mapTopic: {
title: { max: 70, recommended: 63 },
shortTitle: { max: 35, recommended: 30 },
intro: { required: true, recommended: 280, max: 362 },
requiredProperties: ['intro'],
},
article: {
title: { max: 80, recommended: 60 },
shortTitle: { max: 30, recommended: 25 },
intro: { required: false, recommended: 251, max: 354 },
requiredProperties: ['topics'],
},
}
const rules = contentType ? contentRules[contentType] : null
if (!rules) return
// Check required properties
for (const property of rules.requiredProperties) {
if (!fm[property]) {
addError(
onError,
1,
`Missing required property '${property}' for ${contentType} content type`,
null,
null,
null,
)
}
}
// Check title length
if (fm.title) {
validatePropertyLength(
onError,
params.lines as string[],
'title',
fm.title,
rules.title,
'Title',
)
}
// Check shortTitle length
if (fm.shortTitle) {
validatePropertyLength(
onError,
params.lines as string[],
'shortTitle',
fm.shortTitle,
rules.shortTitle,
'ShortTitle',
)
}
// Check intro length if it exists
if (fm.intro && rules.intro) {
validatePropertyLength(
onError,
params.lines as string[],
'intro',
fm.intro,
rules.intro,
'Intro',
)
}
// Cross-property validation: if title is longer than shortTitle limit, shortTitle must exist
const strippedTitle = stripLiquidTags(fm.title)
if (fm.title && (strippedTitle as string).length > rules.shortTitle.max && !fm.shortTitle) {
const titleLine = findPropertyLine(params.lines as string[], 'title')
addError(
onError,
titleLine,
`Title is ${(strippedTitle as string).length} characters, which exceeds the shortTitle limit of ${rules.shortTitle.max} characters. A shortTitle must be provided.`,
fm.title,
null,
null,
)
}
// Special validation for articles: should have at least one topic
if (contentType === 'article' && fm.topics) {
if (!Array.isArray(fm.topics)) {
const topicsLine = findPropertyLine(params.lines as string[], 'topics')
addError(onError, topicsLine, 'Topics must be an array', String(fm.topics), null, null)
} else if (fm.topics.length === 0) {
const topicsLine = findPropertyLine(params.lines as string[], 'topics')
addError(
onError,
topicsLine,
'Articles should have at least one topic',
'topics: []',
null,
null,
)
}
}
},
}
function validatePropertyLength(
onError: RuleErrorCallback,
lines: string[],
propertyName: string,
propertyValue: string,
limits: PropertyLimits,
displayName: string,
): void {
const strippedValue = stripLiquidTags(propertyValue)
const propertyLength = (strippedValue as string).length
const propertyLine = findPropertyLine(lines, propertyName)
// Only report the most severe error - maximum takes precedence over recommended
if (propertyLength > limits.max) {
addError(
onError,
propertyLine,
`${displayName} exceeds maximum length of ${limits.max} characters (current: ${propertyLength})`,
propertyValue,
null,
null,
)
} else if (propertyLength > limits.recommended) {
addError(
onError,
propertyLine,
`${displayName} exceeds recommended length of ${limits.recommended} characters (current: ${propertyLength})`,
propertyValue,
null,
null,
)
}
}
// frontmatter object structure varies based on YAML content, using any for flexibility
function detectContentType(frontmatter: any, filePath: string): ContentType {
// Only apply validation to markdown files
if (!filePath || !filePath.endsWith('.md')) {
return null
}
// Map topics have mapTopic: true
if (frontmatter.mapTopic === true) {
return 'mapTopic'
}
// Categories are index.md files that contain children but no mapTopic
// Only check files that look like they're in the content directory structure
if (
filePath.includes('/index.md') &&
frontmatter.children &&
Array.isArray(frontmatter.children) &&
!frontmatter.mapTopic
) {
return 'category'
}
// Everything else is an article
return 'article'
}
function findPropertyLine(lines: string[], property: string): number {
const line = lines.find((line) => line.trim().startsWith(`${property}:`))
return line ? lines.indexOf(line) + 1 : 1
}

View File

@@ -3,14 +3,12 @@ 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'
import { imageAltTextEndPunctuation } from '@/content-linter/lib/linting-rules/image-alt-text-end-punctuation'
import { imageFileKebabCase } from '@/content-linter/lib/linting-rules/image-file-kebab-case'
import { incorrectAltTextLength } from '@/content-linter/lib/linting-rules/image-alt-text-length'
import { internalLinksNoLang } from '@/content-linter/lib/linting-rules/internal-links-no-lang'
import { internalLinksSlash } from '@/content-linter/lib/linting-rules/internal-links-slash'
import { imageAltTextExcludeStartWords } from '@/content-linter/lib/linting-rules/image-alt-text-exclude-start-words'
import { listFirstWordCapitalization } from '@/content-linter/lib/linting-rules/list-first-word-capitalization'
import { linkPunctuation } from '@/content-linter/lib/linting-rules/link-punctuation'
import {
earlyAccessReferences,
@@ -49,11 +47,7 @@ import { linkQuotation } from '@/content-linter/lib/linting-rules/link-quotation
import { octiconAriaLabels } from '@/content-linter/lib/linting-rules/octicon-aria-labels'
import { liquidIfversionVersions } from '@/content-linter/lib/linting-rules/liquid-ifversion-versions'
import { outdatedReleasePhaseTerminology } from '@/content-linter/lib/linting-rules/outdated-release-phase-terminology'
import { britishEnglishQuotes } from '@/content-linter/lib/linting-rules/british-english-quotes'
import { multipleEmphasisPatterns } from '@/content-linter/lib/linting-rules/multiple-emphasis-patterns'
import { noteWarningFormatting } from '@/content-linter/lib/linting-rules/note-warning-formatting'
import { frontmatterVersionsWhitespace } from '@/content-linter/lib/linting-rules/frontmatter-versions-whitespace'
import { frontmatterValidation } from '@/content-linter/lib/linting-rules/frontmatter-validation'
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'
@@ -103,11 +97,9 @@ export const gitHubDocsMarkdownlint = {
liquidIfVersionTags, // GHD020
yamlScheduledJobs, // GHD021
liquidIfversionVersions, // GHD022
codeFenceLineLength, // GHD030
imageAltTextExcludeStartWords, // GHD031
imageAltTextEndPunctuation, // GHD032
incorrectAltTextLength, // GHD033
listFirstWordCapitalization, // GHD034
raiReusableUsage, // GHD035
imageNoGif, // GHD036
expiredContent, // GHD038
@@ -120,13 +112,9 @@ export const gitHubDocsMarkdownlint = {
codeAnnotationCommentSpacing, // GHD045
outdatedReleasePhaseTerminology, // GHD046
tableColumnIntegrity, // GHD047
britishEnglishQuotes, // GHD048
noteWarningFormatting, // GHD049
multipleEmphasisPatterns, // GHD050
frontmatterVersionsWhitespace, // GHD051
headerContentRequirement, // GHD053
thirdPartyActionsReusable, // GHD054
frontmatterValidation, // GHD055
frontmatterLandingRecommended, // GHD056
ctasSchema, // GHD057
journeyTracksLiquid, // GHD058

View File

@@ -1,70 +0,0 @@
import { addFixErrorDetail, getRange, filterTokensByOrder } from '../helpers/utils'
import type { RuleParams, RuleErrorCallback, MarkdownToken, Rule } from '../../types'
export const listFirstWordCapitalization: Rule = {
names: ['GHD034', 'list-first-word-capitalization'],
description: 'First word of list item should be capitalized',
tags: ['ul', 'ol'],
function: (params: RuleParams, onError: RuleErrorCallback) => {
// Skip site-policy directory as these are legal documents with specific formatting requirements
if (params.name && params.name.includes('content/site-policy/')) return
// We're going to look for a sequence of 3 tokens. If the markdown
// is a really small string, it might not even have that many tokens
// in it. Can bail early.
if (!params.tokens || params.tokens.length < 3) return
const inlineListItems = filterTokensByOrder(params.tokens, [
'list_item_open',
'paragraph_open',
'inline',
]).filter((token: MarkdownToken) => token.type === 'inline')
inlineListItems.forEach((token: MarkdownToken) => {
// Only proceed if all of the token's children start with a text
// node that is not empty.
// This filters out cases where the list item is inline code, or
// a link, or an image, etc.
// This also avoids cases like `- **bold** text` where the first
// child is a text node string but the text node content is empty.
const firstWordTextNode =
token.children &&
token.children.length > 0 &&
token.children[0].type === 'text' &&
token.children[0].content !== ''
if (!firstWordTextNode) return
const content = (token.content || '').trim()
const firstWord = content.trim().split(' ')[0]
// If the first character in the first word is not an alphanumeric,
// don't bother. For example `"ubunut-latest"` or `{% data ... %}`.
if (/^[^a-z]/i.test(firstWord)) return
// If the first letter is capitalized, it's not an error
// And any special characters (like @) that can't be capitalized
if (/[A-Z@]/.test(firstWord[0])) return
// There are items that start with a number or words that contain numbers
// e.g., x64
if (/\d/.test(firstWord)) return
// Catches proper nouns like macOS or openSUSE
if (/[A-Z]/.test(firstWord.slice(1))) return
const lineNumber = token.lineNumber
const range = getRange(token.line, firstWord)
if (!range) return
addFixErrorDetail(
onError,
lineNumber,
`${firstWord[0].toUpperCase()}${firstWord.slice(1)}`,
firstWord,
range,
{
lineNumber,
editColumn: range[0],
deleteCount: 1,
insertText: firstWord[0].toUpperCase(),
},
)
})
},
}

View File

@@ -1,100 +0,0 @@
// @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, Rule } from '@/content-linter/types'
interface Frontmatter {
autogenerated?: boolean
[key: string]: any
}
export const multipleEmphasisPatterns: Rule = {
names: ['GHD050', 'multiple-emphasis-patterns'],
description: 'Do not use more than one emphasis/strong, italics, or uppercase for a string',
tags: ['formatting', 'emphasis', 'style'],
severity: 'warning',
function: (params: RuleParams, onError: RuleErrorCallback) => {
// Skip autogenerated files
const frontmatterString = params.frontMatterLines.join('\n')
const fm = frontmatter(frontmatterString).data as Frontmatter
if (fm && fm.autogenerated) return
const lines = params.lines
let inCodeBlock = false
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const lineNumber = i + 1
// Track code block state
if (line.trim().startsWith('```')) {
inCodeBlock = !inCodeBlock
continue
}
// Skip code blocks and indented code
if (inCodeBlock || line.trim().startsWith(' ')) continue
// Check for multiple emphasis patterns
checkMultipleEmphasis(line, lineNumber, onError)
}
},
}
/**
* Check for multiple emphasis types in a single text segment
*/
function checkMultipleEmphasis(line: string, lineNumber: number, onError: RuleErrorCallback): void {
// Focus on the clearest violations of the style guide
const multipleEmphasisPatterns: Array<{ regex: RegExp; types: string[] }> = [
// Bold + italic combinations (***text***)
{ regex: /\*\*\*([^*]+)\*\*\*/g, types: ['bold', 'italic'] },
{ regex: /___([^_]+)___/g, types: ['bold', 'italic'] },
// Bold with code nested inside
{ regex: /\*\*([^*]*`[^`]+`[^*]*)\*\*/g, types: ['bold', 'code'] },
{ regex: /__([^_]*`[^`]+`[^_]*)__/g, types: ['bold', 'code'] },
// Code with bold nested inside
{ regex: /`([^`]*\*\*[^*]+\*\*[^`]*)`/g, types: ['code', 'bold'] },
{ regex: /`([^`]*__[^_]+__[^`]*)`/g, types: ['code', 'bold'] },
]
for (const pattern of multipleEmphasisPatterns) {
let match
while ((match = pattern.regex.exec(line)) !== null) {
// Skip if this is likely intentional or very short
if (shouldSkipMatch(match[0], match[1])) continue
const range = getRange(line, match[0])
addError(
onError,
lineNumber,
`Do not use multiple emphasis types in a single string: ${pattern.types.join(' + ')}`,
line,
range,
null, // No auto-fix as this requires editorial judgment
)
}
}
}
/**
* Determine if a match should be skipped (likely intentional formatting)
*/
function shouldSkipMatch(fullMatch: string, content: string): boolean {
// Skip common false positives
if (!content) return true
// Skip very short content (likely intentional single chars)
if (content.trim().length < 2) return true
// Skip if it's mostly code-like content (constants, variables)
if (/^[A-Z_][A-Z0-9_]*$/.test(content.trim())) return true
// Skip file extensions or URLs
if (/\.[a-z]{2,4}$/i.test(content.trim()) || /https?:\/\//.test(content)) return true
return false
}

View File

@@ -1,236 +0,0 @@
// @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'
interface NoteContentItem {
text: string
lineNumber: number
}
export const noteWarningFormatting = {
names: ['GHD049', 'note-warning-formatting'],
description: 'Note and warning tags should be formatted according to style guide',
tags: ['formatting', 'callouts', 'notes', 'warnings', 'style'],
severity: 'warning',
function: (params: RuleParams, onError: RuleErrorCallback) => {
// Skip autogenerated files
const frontmatterString = params.frontMatterLines.join('\n')
const fm = frontmatter(frontmatterString).data
if (fm && fm.autogenerated) return
const lines = params.lines
let inLegacyNote = false
let noteStartLine: number | null = null
let noteContent: NoteContentItem[] = []
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const lineNumber = i + 1
// Check for legacy {% note %} tags
if (line.trim() === '{% note %}') {
inLegacyNote = true
noteStartLine = lineNumber
noteContent = []
// Check for missing line break before {% note %}
const prevLine = i > 0 ? lines[i - 1] : ''
if (prevLine.trim() !== '') {
const range = getRange(line, '{% note %}')
addError(onError, lineNumber, 'Add a blank line before {% note %} tag', line, range, {
editColumn: 1,
deleteCount: 0,
insertText: '\n',
})
}
continue
}
// Check for end of legacy note
if (line.trim() === '{% endnote %}') {
if (inLegacyNote) {
inLegacyNote = false
// Check for missing line break after {% endnote %}
const nextLine = i < lines.length - 1 ? lines[i + 1] : ''
if (nextLine.trim() !== '') {
const range = getRange(line, '{% endnote %}')
addError(onError, lineNumber, 'Add a blank line after {% endnote %} tag', line, range, {
editColumn: line.length + 1,
deleteCount: 0,
insertText: '\n',
})
}
// Check note content formatting
validateNoteContent(noteContent, noteStartLine, onError)
}
continue
}
// Collect content inside legacy notes
if (inLegacyNote) {
noteContent.push({ text: line, lineNumber })
continue
}
// Check for new-style callouts > [!NOTE], > [!WARNING], > [!DANGER]
const calloutMatch = line.match(/^>\s*\[!(NOTE|WARNING|DANGER)\]\s*$/)
if (calloutMatch) {
const calloutType = calloutMatch[1]
// Check for missing line break before callout
const prevLine = i > 0 ? lines[i - 1] : ''
if (prevLine.trim() !== '') {
const range = getRange(line, line.trim())
addError(
onError,
lineNumber,
`Add a blank line before > [!${calloutType}] callout`,
line,
range,
{
editColumn: 1,
deleteCount: 0,
insertText: '\n',
},
)
}
// Find the end of this callout block and validate content
const calloutContent = []
let j = i + 1
while (j < lines.length && lines[j].startsWith('>')) {
if (lines[j].trim() !== '>') {
calloutContent.push({ text: lines[j], lineNumber: j + 1 })
}
j++
}
// Check for missing line break after callout
if (j < lines.length && lines[j].trim() !== '') {
const range = getRange(lines[j], lines[j].trim())
addError(
onError,
j + 1,
`Add a blank line after > [!${calloutType}] callout block`,
lines[j],
range,
{
editColumn: 1,
deleteCount: 0,
insertText: '\n',
},
)
}
validateCalloutContent(calloutContent, calloutType, lineNumber, onError)
i = j - 1 // Skip to end of callout block
continue
}
// Check for orphaned **Note:**/**Warning:**/**Danger:** outside callouts
const orphanedPrefixMatch = line.match(/\*\*(Note|Warning|Danger):\*\*/)
if (orphanedPrefixMatch && !inLegacyNote && !line.startsWith('>')) {
const range = getRange(line, orphanedPrefixMatch[0])
addError(
onError,
lineNumber,
`${orphanedPrefixMatch[1]} prefix should be inside a callout block`,
line,
range,
null, // No auto-fix as this requires human decision
)
}
}
},
}
/**
* Validate content inside legacy {% note %} blocks
*/
function validateNoteContent(
noteContent: NoteContentItem[],
noteStartLine: number | null,
onError: RuleErrorCallback,
) {
if (noteContent.length === 0) return
const contentLines = noteContent.filter((item) => item.text.trim() !== '')
if (contentLines.length === 0) return
// Count bullet points
const bulletLines = contentLines.filter((item) => item.text.trim().match(/^[*\-+]\s/))
if (bulletLines.length > 2) {
const range = getRange(bulletLines[2].text, bulletLines[2].text.trim())
addError(
onError,
bulletLines[2].lineNumber,
'Do not include more than 2 bullet points inside a callout',
bulletLines[2].text,
range,
null, // No auto-fix as this requires content restructuring
)
}
// Check for missing prefix (only if it looks like a traditional note)
const firstContentLine = contentLines[0]
const allContent = contentLines.map((line) => line.text).join(' ')
const hasButtons =
allContent.includes('<a href=') || allContent.includes('btn') || allContent.includes('class=')
if (
!hasButtons &&
!firstContentLine.text.includes('**Note:**') &&
!firstContentLine.text.includes('**Warning:**') &&
!firstContentLine.text.includes('**Danger:**')
) {
const range = getRange(firstContentLine.text, firstContentLine.text.trim())
addError(
onError,
firstContentLine.lineNumber,
'Note content should start with **Note:**, **Warning:**, or **Danger:**',
firstContentLine.text,
range,
{
editColumn: firstContentLine.text.indexOf(firstContentLine.text.trim()) + 1,
deleteCount: 0,
insertText: '**Note:** ',
},
)
}
}
/**
* Validate content inside new-style callouts
*/
function validateCalloutContent(
calloutContent: NoteContentItem[],
calloutType: string,
calloutStartLine: number,
onError: RuleErrorCallback,
) {
if (calloutContent.length === 0) return
const contentLines = calloutContent.filter((item) => item.text.trim() !== '>')
if (contentLines.length === 0) return
// Count bullet points
const bulletLines = contentLines.filter((item) => item.text.match(/^>\s*[*\-+]\s/))
if (bulletLines.length > 2) {
const range = getRange(bulletLines[2].text, bulletLines[2].text.trim())
addError(
onError,
bulletLines[2].lineNumber,
'Do not include more than 2 bullet points inside a callout',
bulletLines[2].text,
range,
null, // No auto-fix as this requires content restructuring
)
}
// For new-style callouts, the prefix is handled by the [!NOTE] syntax itself
// so we don't need to check for manual **Note:** prefixes
}

View File

@@ -26,6 +26,26 @@ const TERMINOLOGY_REPLACEMENTS: [string, string][] = [
['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',
// 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
@@ -96,6 +116,13 @@ export const outdatedReleasePhaseTerminology = {
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

View File

@@ -43,34 +43,46 @@ export const thirdPartyActionsReusable = {
/**
* Find third-party actions in YAML content
* Third-party actions are identified by the pattern: owner/action@version
* where owner is not 'actions' or 'github'
* Third-party actions are identified by actions that are not GitHub-owned or documentation examples
*/
function findThirdPartyActions(yamlContent: string): string[] {
const thirdPartyActions: string[] = []
// Pattern to match 'uses: owner/action@version' where owner is not actions or github
const actionPattern = /uses:\s+([^{\s]+\/[^@\s]+@[^\s]+)/g
let match
while ((match = actionPattern.exec(yamlContent)) !== null) {
const actionRef = match[1]
// Extract owner from action reference
const parts = actionRef.split('/')
if (parts.length >= 2) {
const owner = parts[0]
// Skip GitHub-owned actions (actions/* and github/*)
if (owner !== 'actions' && owner !== 'github') {
if (!isExampleOrGitHubAction(actionRef)) {
thirdPartyActions.push(actionRef)
}
}
}
return thirdPartyActions
}
/**
* Check if an action should be skipped (GitHub-owned or documentation example)
*/
function isExampleOrGitHubAction(actionRef: string): boolean {
// List of patterns to exclude (GitHub-owned and documentation examples)
const excludePatterns = [
// GitHub-owned
/^actions\//,
/^github\//,
// Example organizations
/^(octo-org|octocat|different-org|fakeaction|some|OWNER|my-org)\//,
// Example repos (any owner)
/\/example-repo[/@]/,
/\/octo-repo[/@]/,
/\/hello-world-composite-action[/@]/,
/\/monorepo[/@]/,
// Monorepo patterns
]
return excludePatterns.some((pattern) => pattern.test(actionRef))
}
/**
* Check if the disclaimer reusable is present before the given line number or inside the code block
* Looks backward from the code block and also inside the code block content

View File

@@ -16,7 +16,14 @@ import { prettyPrintResults } from './pretty-print-results'
import { getLintableYml } from '@/content-linter/lib/helpers/get-lintable-yml'
import { printAnnotationResults } from '../lib/helpers/print-annotations'
import languages from '@/languages/lib/languages-server'
import { shouldIncludeResult } from '../lib/helpers/should-include-result'
/**
* Config that applies to all rules in all environments (CI, reports, precommit).
*/
export const globalConfig = {
// Do not ever lint these filepaths
excludePaths: ['content/contributing/'],
}
program
.description('Run GitHub Docs Markdownlint rules.')
@@ -197,12 +204,7 @@ async function main() {
if (printAnnotations) {
printAnnotationResults(formattedResults, {
skippableRules: [
// As of Feb 2024, this rule is quite noisy. It's present in
// many files and is not always a problem. And besides, when it
// does warn, it's usually a very long one.
'code-fence-line-length', // a.k.a. GHD030
],
skippableRules: [],
skippableFlawProperties: [
// As of Feb 2024, we don't support reporting flaws for lines
// and columns numbers of YAML files. YAML files consist of one
@@ -349,7 +351,14 @@ function getFilesToLint(paths) {
(!filePath.endsWith('.md') && !filePath.endsWith('.yml'))
)
continue
const relPath = path.relative(root, filePath)
// Skip files that match any of the excluded paths
if (globalConfig.excludePaths.some((excludePath) => relPath.startsWith(excludePath))) {
continue
}
if (seen.has(relPath)) continue
seen.add(relPath)
clean.push(relPath)
@@ -427,9 +436,7 @@ function getFormattedResults(allResults, isPrecommit) {
if (verbose) {
output[key] = [...results]
} else {
const formattedResults = results
.map((flaw) => formatResult(flaw, isPrecommit))
.filter((flaw) => shouldIncludeResult(flaw, key))
const formattedResults = results.map((flaw) => formatResult(flaw, isPrecommit))
// Only add the file to output if there are results after filtering
if (formattedResults.length > 0) {
@@ -562,9 +569,6 @@ function getMarkdownLintConfig(errorsOnly, runRules) {
// Check if the rule should be included based on user-specified rules
if (runRules && !shouldIncludeRule(ruleName, runRules)) continue
// Skip british-english-quotes rule in CI/PRs (only run in pre-commit)
if (ruleName === 'british-english-quotes' && !isPrecommit) continue
// There are a subset of rules run on just the frontmatter in files
if (githubDocsFrontmatterConfig[ruleName]) {
config.frontMatter[ruleName] = ruleConfig

View File

@@ -5,12 +5,21 @@ import coreLib from '@actions/core'
import github from '@/workflows/github'
import { getEnvInputs } from '@/workflows/get-env-inputs'
import { createReportIssue, linkReports } from '@/workflows/issue-report'
import { shouldIncludeResult } from '@/content-linter/lib/helpers/should-include-result'
import { reportingConfig } from '@/content-linter/style/github-docs'
import { getAllRuleNames } from '@/content-linter/lib/helpers/rule-utils'
// GitHub issue body size limit is ~65k characters, so we'll use 60k as a safe limit
const MAX_ISSUE_BODY_SIZE = 60000
/**
* Config that only applies to automated weekly reports.
*/
export const reportingConfig = {
// Include only rules with these severities in reports
includeSeverities: ['error'],
// Include these rules regardless of severity in reports
includeRules: ['expired-content'],
}
interface LintFlaw {
severity: string
ruleNames: string[]
@@ -19,34 +28,16 @@ interface LintFlaw {
/**
* Determines if a lint result should be included in the automated report
* Uses shared exclusion logic with additional reporting-specific filtering
*/
function shouldIncludeInReport(flaw: LintFlaw, filePath: string): boolean {
if (!flaw.ruleNames || !Array.isArray(flaw.ruleNames)) {
return false
}
function shouldIncludeInReport(flaw: LintFlaw): boolean {
const allRuleNames = getAllRuleNames(flaw)
// First check if it should be excluded (file-specific or rule-specific exclusions)
if (!shouldIncludeResult(flaw, filePath)) {
return false
}
// Extract all possible rule names including sub-rules from search-replace
const allRuleNames = [...flaw.ruleNames]
if (flaw.ruleNames.includes('search-replace') && flaw.errorDetail) {
const match = flaw.errorDetail.match(/^([^:]+):/)
if (match) {
allRuleNames.push(match[1])
}
}
// Apply reporting-specific filtering
// Check if severity should be included
if (reportingConfig.includeSeverities.includes(flaw.severity)) {
return true
}
// Check if any rule name is in the include list
// Check if any rule name is in the include list that overrides severity
const hasIncludedRule = allRuleNames.some((ruleName: string) =>
reportingConfig.includeRules.includes(ruleName),
)
@@ -101,7 +92,7 @@ async function main() {
// Filter results based on reporting configuration
const filteredResults: Record<string, LintFlaw[]> = {}
for (const [file, flaws] of Object.entries(parsedResults)) {
const filteredFlaws = (flaws as LintFlaw[]).filter((flaw) => shouldIncludeInReport(flaw, file))
const filteredFlaws = (flaws as LintFlaw[]).filter((flaw) => shouldIncludeInReport(flaw))
// Only include files that have remaining flaws after filtering
if (filteredFlaws.length > 0) {

View File

@@ -33,32 +33,12 @@ export const baseConfig: BaseConfig = {
'partial-markdown-files': false,
'yml-files': false,
},
'ul-style': {
// MD004
severity: 'error',
style: 'asterisk',
'partial-markdown-files': true,
'yml-files': false,
context: `We use asterisks to format bulleted lists because this gives clearer, more accessible source code.`,
},
'no-trailing-spaces': {
// MD009
severity: 'error',
'partial-markdown-files': true,
'yml-files': true,
},
'no-reversed-links': {
// MD011
severity: 'error',
'partial-markdown-files': true,
'yml-files': true,
},
'no-multiple-blanks': {
// MD012
severity: 'error',
'partial-markdown-files': true,
'yml-files': true,
},
'commands-show-output': {
// MD014
severity: 'error',
@@ -77,12 +57,6 @@ export const baseConfig: BaseConfig = {
'partial-markdown-files': true,
'yml-files': true,
},
'blanks-around-headings': {
// MD022
severity: 'error',
'partial-markdown-files': false,
'yml-files': false,
},
'heading-start-left': {
// MD023
severity: 'error',
@@ -140,19 +114,6 @@ export const baseConfig: BaseConfig = {
'partial-markdown-files': true,
'yml-files': true,
},
'single-trailing-newline': {
// MD047
severity: 'error',
'partial-markdown-files': true,
'yml-files': false,
},
'emphasis-style': {
// MD049
severity: 'error',
style: 'underscore',
'partial-markdown-files': true,
'yml-files': true,
},
'strong-style': {
// MD050
severity: 'error',

View File

@@ -1,31 +1,3 @@
export const reportingConfig = {
// Always include all rules with these severities in automated weekly reports
includeSeverities: ['error'],
// Specific rules to include regardless of severity
// Add rule names (short or long form) that should always be reported
includeRules: [
'GHD038', // expired-content - Content that has passed its expiration date
'expired-content',
],
// Specific rules to exclude from CI and reports (overrides severity-based inclusion)
// Add rule names here if you want to suppress them from reports
excludeRules: [
// Example: 'GHD030' // Uncomment to exclude code-fence-line-length warnings
'british-english-quotes', // Exclude from reports but keep for pre-commit
],
// Files to exclude from specific rules in CI and reports
// Format: { 'rule-name': ['file/path/pattern1', 'file/path/pattern2'] }
excludeFilesFromRules: {
'todocs-placeholder': [
'content/contributing/collaborating-on-github-docs/using-the-todocs-placeholder-to-leave-notes.md',
'content/contributing/collaborating-on-github-docs/index.md',
],
},
}
const githubDocsConfig = {
'link-punctuation': {
// GHD001
@@ -129,12 +101,6 @@ const githubDocsConfig = {
'partial-markdown-files': true,
'yml-files': true,
},
'code-fence-line-length': {
// GHD030
severity: 'warning',
'partial-markdown-files': true,
'yml-files': true,
},
'image-alt-text-exclude-words': {
// GHD031
severity: 'error',
@@ -153,12 +119,6 @@ const githubDocsConfig = {
'partial-markdown-files': true,
'yml-files': true,
},
'list-first-word-capitalization': {
// GHD034
severity: 'warning',
'partial-markdown-files': true,
'yml-files': true,
},
'rai-reusable-usage': {
// GHD035
severity: 'error',
@@ -226,25 +186,6 @@ const githubDocsConfig = {
'partial-markdown-files': true,
'yml-files': true,
},
'british-english-quotes': {
// GHD048
severity: 'warning',
precommitSeverity: 'warning', // Show warnings locally for writer awareness
'partial-markdown-files': true,
'yml-files': true,
},
'note-warning-formatting': {
// GHD049
severity: 'warning',
'partial-markdown-files': true,
'yml-files': true,
},
'multiple-emphasis-patterns': {
// GHD050
severity: 'warning',
'partial-markdown-files': true,
'yml-files': true,
},
'header-content-requirement': {
// GHD053
severity: 'warning',
@@ -312,12 +253,6 @@ export const githubDocsFrontmatterConfig = {
'partial-markdown-files': false,
'yml-files': false,
},
'frontmatter-validation': {
// GHD055
severity: 'warning',
'partial-markdown-files': false,
'yml-files': false,
},
'frontmatter-landing-recommended': {
// GHD056
severity: 'error',

View File

@@ -1,225 +0,0 @@
import { describe, expect, test } from 'vitest'
import { runRule } from '../../lib/init-test'
import { britishEnglishQuotes } from '../../lib/linting-rules/british-english-quotes'
describe(britishEnglishQuotes.names.join(' - '), () => {
test('Correct American English punctuation passes', async () => {
const markdown = [
'She said, "Hello, world."',
'The guide mentions "Getting started."',
'See "[AUTOTITLE]."',
'Zara replied, "That sounds great!"',
'The section titled "Prerequisites," explains the setup.',
].join('\n')
const result = await runRule(britishEnglishQuotes, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(0)
})
test('British English quotes with AUTOTITLE are flagged', async () => {
const markdown = [
'For more information, see "[AUTOTITLE]".',
'The article "[AUTOTITLE]", covers this topic.',
].join('\n')
const result = await runRule(britishEnglishQuotes, { strings: { markdown } })
// Markdownlint error objects include detail property not in base LintError type
const errors = result.markdown as any[]
expect(errors.length).toBe(2)
expect(errors[0].lineNumber).toBe(1)
if (errors[0].detail) {
expect(errors[0].detail).toContain('place period inside the quotation marks')
}
expect(errors[1].lineNumber).toBe(2)
if (errors[1].detail) {
expect(errors[1].detail).toContain('place comma inside the quotation marks')
}
})
test('General British English punctuation patterns are detected', async () => {
const markdown = [
'Priya said "Hello".',
'The tutorial called "Advanced Git", is helpful.',
'Marcus mentioned "DevOps best practices".',
'See the guide titled "Getting Started", for details.',
].join('\n')
const result = await runRule(britishEnglishQuotes, { strings: { markdown } })
// Markdownlint error objects include detail property not in base LintError type
const errors = result.markdown as any[]
expect(errors.length).toBe(4)
if (errors[0].detail) {
expect(errors[0].detail).toContain('period inside')
}
if (errors[1].detail) {
expect(errors[1].detail).toContain('comma inside')
}
if (errors[2].detail) {
expect(errors[2].detail).toContain('period inside')
}
if (errors[3].detail) {
expect(errors[3].detail).toContain('comma inside')
}
})
test('Single quotes are also detected', async () => {
const markdown = [
"Aisha said 'excellent work'.",
"The term 'API endpoint', refers to a specific URL.",
].join('\n')
const result = await runRule(britishEnglishQuotes, { strings: { markdown } })
// Markdownlint error objects include detail property not in base LintError type
const errors = result.markdown as any[]
expect(errors.length).toBe(2)
if (errors[0].detail) {
expect(errors[0].detail).toContain('period inside')
}
if (errors[1].detail) {
expect(errors[1].detail).toContain('comma inside')
}
})
test('Code blocks and inline code are ignored', async () => {
const markdown = [
'```javascript',
'console.log("Hello");',
'const message = "World";',
'```',
'',
'In code, use `console.log("Debug");` for logging.',
'The command `git commit -m "Fix bug";` creates a commit.',
].join('\n')
const result = await runRule(britishEnglishQuotes, { strings: { markdown } })
// Markdownlint error objects include detail property not in base LintError type
const errors = result.markdown as any[]
expect(errors.length).toBe(0)
})
test('URLs and emails are ignored', async () => {
const markdown = [
'Visit https://example.com/api"docs" for more info.',
'Email support@company.com"help" for assistance.',
'The webhook URL http://api.service.com"endpoint" should work.',
].join('\n')
const result = await runRule(britishEnglishQuotes, { strings: { markdown } })
// Markdownlint error objects include detail property not in base LintError type
const errors = result.markdown as any[]
expect(errors.length).toBe(0)
})
test('Auto-fix suggestions work correctly', async () => {
const markdown = [
'See "[AUTOTITLE]".',
'The guide "Setup Instructions", explains everything.',
].join('\n')
const result = await runRule(britishEnglishQuotes, { strings: { markdown } })
// Markdownlint error objects include detail and fixInfo properties not in base LintError type
const errors = result.markdown as any[]
expect(errors.length).toBe(2)
// Check that fix info is provided
expect(errors[0].fixInfo).toBeDefined()
expect(errors[0].fixInfo.insertText).toContain('."')
expect(errors[1].fixInfo).toBeDefined()
expect(errors[1].fixInfo.insertText).toContain(',"')
})
test('Mixed punctuation scenarios', async () => {
const markdown = [
'Chen explained, "The process involves three steps". First, prepare the data.',
'The error message "File not found", appears when the path is incorrect.',
'As Fatima noted, "Testing is crucial"; quality depends on it.',
].join('\n')
const result = await runRule(britishEnglishQuotes, { strings: { markdown } })
// Markdownlint error objects include detail property not in base LintError type
const errors = result.markdown as any[]
expect(errors.length).toBe(2)
expect(errors[0].lineNumber).toBe(1)
expect(errors[1].lineNumber).toBe(2)
})
test('Nested quotes are handled appropriately', async () => {
const markdown = [
'She said, "The article \'Best Practices\', is recommended".',
'The message "Error: \'Invalid input\'" appears sometimes.',
].join('\n')
const result = await runRule(britishEnglishQuotes, { strings: { markdown } })
// Markdownlint error objects include detail property not in base LintError type
const errors = result.markdown as any[]
expect(errors.length).toBe(1)
if (errors[0].detail) {
expect(errors[0].detail).toContain('period inside')
}
})
test('Edge cases with spacing', async () => {
const markdown = [
'The command "npm install" .',
'See documentation "API Guide" , which covers authentication.',
'Reference "[AUTOTITLE]" .',
].join('\n')
const result = await runRule(britishEnglishQuotes, { strings: { markdown } })
// Markdownlint error objects include detail property not in base LintError type
const errors = result.markdown as any[]
expect(errors.length).toBe(3)
if (errors[0].detail) {
expect(errors[0].detail).toContain('period inside')
}
if (errors[1].detail) {
expect(errors[1].detail).toContain('comma inside')
}
if (errors[2].detail) {
expect(errors[2].detail).toContain('period inside')
}
})
test('Autogenerated files are skipped', async () => {
const frontmatter = ['---', 'title: API Reference', 'autogenerated: rest', '---'].join('\n')
const markdown = ['The endpoint "GET /users", returns user data.', 'See "[AUTOTITLE]".'].join(
'\n',
)
const result = await runRule(britishEnglishQuotes, {
strings: {
markdown: frontmatter + '\n' + markdown,
},
})
const errors = result.markdown
expect(errors.length).toBe(0)
})
test('Complex real-world examples', async () => {
const markdown = [
'## Configuration Options',
'',
'To enable the feature, set `enabled: true` in "config.yml".',
'Aaliyah mentioned that the tutorial "Docker Basics", covers containers.',
'The error "Permission denied", occurs when access is restricted.',
'For troubleshooting, see "[AUTOTITLE]".',
'',
'```yaml',
'name: "production"',
'debug: false',
'```',
'',
'Dmitri explained, "The workflow has multiple stages."',
].join('\n')
const result = await runRule(britishEnglishQuotes, { strings: { markdown } })
// Markdownlint error objects include detail property not in base LintError type
const errors = result.markdown as any[]
expect(errors.length).toBe(4)
expect(errors[0].lineNumber).toBe(3) // config.yml line
expect(errors[1].lineNumber).toBe(4) // Docker Basics line
expect(errors[2].lineNumber).toBe(5) // Permission denied line
expect(errors[3].lineNumber).toBe(6) // AUTOTITLE line
})
test('Warning severity is set correctly', () => {
expect(britishEnglishQuotes.severity).toBe('warning')
})
test('Rule has correct metadata', () => {
expect(britishEnglishQuotes.names).toEqual(['GHD048', 'british-english-quotes'])
expect(britishEnglishQuotes.description).toContain('American English style')
expect(britishEnglishQuotes.tags).toContain('punctuation')
expect(britishEnglishQuotes.tags).toContain('quotes')
})
})

View File

@@ -1,50 +0,0 @@
import { describe, expect, test } from 'vitest'
import { runRule } from '../../lib/init-test'
import { codeFenceLineLength } from '../../lib/linting-rules/code-fence-line-length'
describe(codeFenceLineLength.names.join(' - '), () => {
test('line length of max + 1 fails', async () => {
const markdown = [
'```shell',
'111',
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
'bbb',
'```',
].join('\n')
const result = await runRule(codeFenceLineLength, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(1)
expect(errors[0].lineNumber).toBe(3)
expect(errors[0].errorRange).toEqual([1, 61])
expect(errors[0].fixInfo).toBeNull()
})
test('line length less than or equal to max length passes', async () => {
const markdown = [
'```javascript',
'111',
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
'```',
].join('\n')
const result = await runRule(codeFenceLineLength, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(0)
})
test('multiple lines in code block that exceed max length fail', async () => {
const markdown = [
'```',
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaccc',
'1',
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbb',
'```',
].join('\n')
const result = await runRule(codeFenceLineLength, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(2)
expect(errors[0].lineNumber).toBe(2)
expect(errors[1].lineNumber).toBe(4)
expect(errors[0].errorRange).toEqual([1, 61])
expect(errors[1].errorRange).toEqual([1, 61])
})
})

View File

@@ -1,576 +0,0 @@
import { describe, expect, test } from 'vitest'
import { runRule } from '@/content-linter/lib/init-test'
import { frontmatterValidation } from '@/content-linter/lib/linting-rules/frontmatter-validation'
const ruleName = frontmatterValidation.names[1]
// Configure the test fixture to not split frontmatter and content
const fmOptions = { markdownlintOptions: { frontMatter: null } }
describe(ruleName, () => {
// Character limit tests
test('category title within limits passes', async () => {
const markdown = `---
title: 'Short category title'
intro: 'Category introduction'
children:
- /path/to/child
---
# Content
`
const result = await runRule(frontmatterValidation, {
strings: { 'content/section/index.md': markdown },
...fmOptions,
})
expect(result['content/section/index.md']).toEqual([])
})
test('category title exceeds recommended limit shows warning', async () => {
const markdown = `---
title: 'This category title is exactly 68 characters long for testing purpos'
shortTitle: 'Short title'
intro: 'Category introduction'
children:
- /path/to/child
---
# Content
`
const result = await runRule(frontmatterValidation, {
strings: { 'content/section/index.md': markdown },
...fmOptions,
})
expect(result['content/section/index.md']).toHaveLength(1)
expect(result['content/section/index.md'][0].errorDetail).toContain(
'exceeds recommended length of 67 characters',
)
})
test('category title exceeds maximum limit shows error', async () => {
const markdown = `---
title: 'This is exactly 71 characters long to exceed the maximum limit for catx'
shortTitle: 'Short title'
intro: 'Category introduction'
children:
- /path/to/child
---
# Content
`
const result = await runRule(frontmatterValidation, {
strings: { 'content/section/index.md': markdown },
...fmOptions,
})
expect(result['content/section/index.md']).toHaveLength(1)
expect(result['content/section/index.md'][0].errorDetail).toContain(
'exceeds maximum length of 70 characters',
)
})
test('category shortTitle exceeds limit shows error', async () => {
const markdown = `---
title: 'Category title'
shortTitle: 'This short title is exactly 31x'
intro: 'Category introduction'
children:
- /path/to/child
---
# Content
`
const result = await runRule(frontmatterValidation, {
strings: { 'content/section/index.md': markdown },
...fmOptions,
})
expect(result['content/section/index.md']).toHaveLength(1)
expect(result['content/section/index.md'][0].errorDetail).toContain('ShortTitle exceeds')
})
test('mapTopic title within limits passes', async () => {
const markdown = `---
title: 'Using workflows'
intro: 'Map topic introduction'
mapTopic: true
---
# Content
`
const result = await runRule(frontmatterValidation, {
strings: { 'content/actions/using-workflows/index.md': markdown },
...fmOptions,
})
expect(result['content/actions/using-workflows/index.md']).toEqual([])
})
test('mapTopic title exceeds recommended limit shows warning', async () => {
const markdown = `---
title: 'This map topic title is exactly 64 characters long for tests now'
shortTitle: 'Short title'
intro: 'Map topic introduction'
mapTopic: true
---
# Content
`
const result = await runRule(frontmatterValidation, {
strings: { 'content/actions/using-workflows/index.md': markdown },
...fmOptions,
})
expect(result['content/actions/using-workflows/index.md']).toHaveLength(1)
expect(result['content/actions/using-workflows/index.md'][0].errorDetail).toContain(
'exceeds recommended length of 63 characters',
)
})
test('article title within limits passes', async () => {
const markdown = `---
title: 'GitHub Actions quickstart'
topics:
- Actions
---
# Content
`
const result = await runRule(frontmatterValidation, {
strings: { 'content/actions/quickstart.md': markdown },
...fmOptions,
})
expect(result['content/actions/quickstart.md']).toEqual([])
})
test('article title exceeds recommended limit shows warning', async () => {
const markdown = `---
title: 'This article title is exactly 61 characters long for test now'
shortTitle: 'Short title'
topics:
- Actions
---
# Content
`
const result = await runRule(frontmatterValidation, {
strings: { 'content/actions/quickstart.md': markdown },
...fmOptions,
})
expect(result['content/actions/quickstart.md']).toHaveLength(1)
expect(result['content/actions/quickstart.md'][0].errorDetail).toContain(
'exceeds recommended length of 60 characters',
)
})
test('article title exceeds maximum limit shows error', async () => {
const markdown = `---
title: 'This article title is exactly 81 characters long to exceed the maximum limits now'
shortTitle: 'Short title'
topics:
- Actions
---
# Content
`
const result = await runRule(frontmatterValidation, {
strings: { 'content/actions/quickstart.md': markdown },
...fmOptions,
})
expect(result['content/actions/quickstart.md']).toHaveLength(1)
expect(result['content/actions/quickstart.md'][0].errorDetail).toContain(
'exceeds maximum length of 80 characters',
)
})
test('cross-property validation: long title without shortTitle shows error', async () => {
const markdown = `---
title: 'This article title is exactly 50 characters long'
topics:
- Actions
---
# Content
`
const result = await runRule(frontmatterValidation, {
strings: { 'content/actions/quickstart.md': markdown },
...fmOptions,
})
expect(result['content/actions/quickstart.md']).toHaveLength(1)
expect(result['content/actions/quickstart.md'][0].errorDetail).toContain(
'A shortTitle must be provided',
)
})
test('cross-property validation: long title with shortTitle passes', async () => {
const markdown = `---
title: 'This article title is exactly 50 characters long'
shortTitle: 'Actions quickstart'
topics:
- Actions
---
# Content
`
const result = await runRule(frontmatterValidation, {
strings: { 'content/actions/quickstart.md': markdown },
...fmOptions,
})
expect(result['content/actions/quickstart.md']).toEqual([])
})
// Required properties tests
test('category with required intro passes', async () => {
const markdown = `---
title: 'Category title'
intro: 'This is the category introduction.'
children:
- /path/to/child
---
# Content
`
const result = await runRule(frontmatterValidation, {
strings: { 'content/section/index.md': markdown },
...fmOptions,
})
expect(result['content/section/index.md']).toEqual([])
})
test('category without required intro fails', async () => {
const markdown = `---
title: 'Category title'
children:
- /path/to/child
---
# Content
`
const result = await runRule(frontmatterValidation, {
strings: { 'content/section/index.md': markdown },
...fmOptions,
})
expect(result['content/section/index.md']).toHaveLength(1)
expect(result['content/section/index.md'][0].errorDetail).toContain(
"Missing required property 'intro' for category content type",
)
})
test('category with intro too long shows warning', async () => {
const longIntro = 'A'.repeat(400) // Exceeds 362 char limit
const markdown = `---
title: 'Category title'
intro: '${longIntro}'
children:
- /path/to/child
---
# Content
`
const result = await runRule(frontmatterValidation, {
strings: { 'content/section/index.md': markdown },
...fmOptions,
})
expect(result['content/section/index.md']).toHaveLength(1)
expect(result['content/section/index.md'][0].errorDetail).toContain(
'Intro exceeds maximum length of 362 characters',
)
})
test('mapTopic with required intro passes', async () => {
const markdown = `---
title: 'Map topic title'
intro: 'This is the map topic introduction.'
mapTopic: true
---
# Content
`
const result = await runRule(frontmatterValidation, {
strings: { 'content/section/topic.md': markdown },
...fmOptions,
})
expect(result['content/section/topic.md']).toEqual([])
})
test('mapTopic without required intro fails', async () => {
const markdown = `---
title: 'Map topic title'
mapTopic: true
---
# Content
`
const result = await runRule(frontmatterValidation, {
strings: { 'content/section/topic.md': markdown },
...fmOptions,
})
expect(result['content/section/topic.md']).toHaveLength(1)
expect(result['content/section/topic.md'][0].errorDetail).toContain(
"Missing required property 'intro' for mapTopic content type",
)
})
test('mapTopic with intro too long shows warning', async () => {
const longIntro = 'A'.repeat(400) // Exceeds 362 char limit
const markdown = `---
title: 'Map topic title'
intro: '${longIntro}'
mapTopic: true
---
# Content
`
const result = await runRule(frontmatterValidation, {
strings: { 'content/section/topic.md': markdown },
...fmOptions,
})
expect(result['content/section/topic.md']).toHaveLength(1)
expect(result['content/section/topic.md'][0].errorDetail).toContain(
'Intro exceeds maximum length of 362 characters',
)
})
test('article with required topics passes', async () => {
const markdown = `---
title: 'Article title'
topics:
- Actions
- CI/CD
---
# Content
`
const result = await runRule(frontmatterValidation, {
strings: { 'content/section/article.md': markdown },
...fmOptions,
})
expect(result['content/section/article.md']).toEqual([])
})
test('article without required topics fails', async () => {
const markdown = `---
title: 'Article title'
---
# Content
`
const result = await runRule(frontmatterValidation, {
strings: { 'content/section/article.md': markdown },
...fmOptions,
})
expect(result['content/section/article.md']).toHaveLength(1)
expect(result['content/section/article.md'][0].errorDetail).toContain(
"Missing required property 'topics' for article content type",
)
})
test('article with empty topics array fails', async () => {
const markdown = `---
title: 'Article title'
topics: []
---
# Content
`
const result = await runRule(frontmatterValidation, {
strings: { 'content/section/article.md': markdown },
...fmOptions,
})
expect(result['content/section/article.md']).toHaveLength(1)
expect(result['content/section/article.md'][0].errorDetail).toContain(
'Articles should have at least one topic',
)
})
test('article with topics as string fails', async () => {
const markdown = `---
title: 'Article title'
topics: 'Actions'
---
# Content
`
const result = await runRule(frontmatterValidation, {
strings: { 'content/section/article.md': markdown },
...fmOptions,
})
expect(result['content/section/article.md']).toHaveLength(1)
expect(result['content/section/article.md'][0].errorDetail).toContain('Topics must be an array')
})
test('article with topics as number fails', async () => {
const markdown = `---
title: 'Article title'
topics: 123
---
# Content
`
const result = await runRule(frontmatterValidation, {
strings: { 'content/section/article.md': markdown },
...fmOptions,
})
expect(result['content/section/article.md']).toHaveLength(1)
expect(result['content/section/article.md'][0].errorDetail).toContain('Topics must be an array')
})
test('article with intro too long shows warning', async () => {
const longIntro = 'A'.repeat(400) // Exceeds 354 char limit for articles
const markdown = `---
title: 'Article title'
intro: '${longIntro}'
topics:
- Actions
---
# Content
`
const result = await runRule(frontmatterValidation, {
strings: { 'content/section/article.md': markdown },
...fmOptions,
})
expect(result['content/section/article.md']).toHaveLength(1)
expect(result['content/section/article.md'][0].errorDetail).toContain(
'Intro exceeds maximum length of 354 characters',
)
})
test('article intro exceeds recommended but not maximum shows warning', async () => {
const mediumIntro = 'A'.repeat(300) // Exceeds 251 recommended but under 354 max
const markdown = `---
title: 'Article title'
intro: '${mediumIntro}'
topics:
- Actions
---
# Content
`
const result = await runRule(frontmatterValidation, {
strings: { 'content/section/article.md': markdown },
...fmOptions,
})
expect(result['content/section/article.md']).toHaveLength(1)
expect(result['content/section/article.md'][0].errorDetail).toContain(
'Intro exceeds recommended length of 251 characters',
)
})
// Combined validation tests
test('multiple violations show multiple errors', async () => {
const longIntro = 'A'.repeat(400)
const markdown = `---
title: 'This is exactly 71 characters long to exceed the maximum limit for catx'
intro: '${longIntro}'
shortTitle: 'Short title'
children:
- /path/to/child
---
# Content
`
const result = await runRule(frontmatterValidation, {
strings: { 'content/section/index.md': markdown },
...fmOptions,
})
expect(result['content/section/index.md']).toHaveLength(2)
expect(result['content/section/index.md'][0].errorDetail).toContain('Title exceeds')
expect(result['content/section/index.md'][1].errorDetail).toContain('Intro exceeds')
})
test('no frontmatter passes', async () => {
const markdown = `# Content without frontmatter`
const result = await runRule(frontmatterValidation, { strings: { markdown }, ...fmOptions })
expect(result.markdown).toEqual([])
})
test('content type detection works correctly', async () => {
// Test category detection
const categoryMarkdown = `---
title: 'Category'
intro: 'Category intro'
children:
- /child
---
# Content
`
const categoryResult = await runRule(frontmatterValidation, {
strings: { 'content/section/index.md': categoryMarkdown },
...fmOptions,
})
expect(categoryResult['content/section/index.md']).toEqual([])
// Test mapTopic detection
const mapTopicMarkdown = `---
title: 'Map Topic'
intro: 'Map topic intro'
mapTopic: true
---
# Content
`
const mapTopicResult = await runRule(frontmatterValidation, {
strings: { 'content/section/topic.md': mapTopicMarkdown },
...fmOptions,
})
expect(mapTopicResult['content/section/topic.md']).toEqual([])
// Test article detection
const articleMarkdown = `---
title: 'Article'
topics:
- Topic
---
# Content
`
const articleResult = await runRule(frontmatterValidation, {
strings: { 'content/section/article.md': articleMarkdown },
...fmOptions,
})
expect(articleResult['content/section/article.md']).toEqual([])
})
// Liquid variable handling tests
test('title with liquid variables counts characters correctly', async () => {
const markdown = `---
title: 'Getting started with {% data variables.product.prodname_github %}'
topics:
- GitHub
---
# Content
`
const result = await runRule(frontmatterValidation, {
strings: { 'content/section/article.md': markdown },
...fmOptions,
})
// 'Getting started with ' (21 chars) + liquid tag (0 chars) = 21 chars, should pass
expect(result['content/section/article.md']).toEqual([])
})
test('intro with liquid variables counts characters correctly', async () => {
const markdown = `---
title: 'Article title'
intro: 'Learn how to use {% data variables.product.prodname_copilot %} for {{ something }}'
topics:
- GitHub
---
# Content
`
const result = await runRule(frontmatterValidation, {
strings: { 'content/section/article.md': markdown },
...fmOptions,
})
// 'Learn how to use for ' (21 chars) should pass
expect(result['content/section/article.md']).toEqual([])
})
test('shortTitle with liquid variables counts characters correctly', async () => {
const markdown = `---
title: 'This article title is exactly fifty characters!!!!'
shortTitle: '{% data variables.product.prodname_copilot_short %}'
topics:
- GitHub
---
# Content
`
const result = await runRule(frontmatterValidation, {
strings: { 'content/section/article.md': markdown },
...fmOptions,
})
// Liquid tag should count as 0 characters, should pass
expect(result['content/section/article.md']).toEqual([])
})
test('long text with liquid variables still fails when limit exceeded', async () => {
const longText = 'A'.repeat(70) // 70 chars
const markdown = `---
title: '${longText} {% data variables.product.prodname_github %} extra text'
shortTitle: 'Short title'
topics:
- GitHub
---
# Content
`
const result = await runRule(frontmatterValidation, {
strings: { 'content/section/article.md': markdown },
...fmOptions,
})
// 70 A's + 1 space + 0 (liquid tag) + 1 space + 10 ('extra text') = 82 chars, should exceed 80 char limit for articles
expect(result['content/section/article.md']).toHaveLength(1)
expect(result['content/section/article.md'][0].errorDetail).toContain(
'exceeds maximum length of 80 characters',
)
})
})

View File

@@ -1,6 +1,15 @@
import { describe, expect, test } from 'vitest'
import { shouldIncludeResult } from '../../lib/helpers/should-include-result'
import { reportingConfig } from '../../style/github-docs'
import { getAllRuleNames } from '../../lib/helpers/rule-utils'
// Use static config objects for testing to avoid Commander.js conflicts
const globalConfig = {
excludePaths: ['content/contributing/'],
}
const reportingConfig = {
includeSeverities: ['error'],
includeRules: ['expired-content'],
}
interface LintFlaw {
severity: string
@@ -8,35 +17,47 @@ interface LintFlaw {
errorDetail?: string
}
describe('lint report exclusions', () => {
// Helper function to simulate the reporting logic from lint-report.ts
function shouldIncludeInReport(flaw: LintFlaw, filePath: string): boolean {
if (!flaw.ruleNames || !Array.isArray(flaw.ruleNames)) {
return false
describe('content linter configuration', () => {
describe('global path exclusions (lint-content.ts)', () => {
test('globalConfig.excludePaths is properly configured', () => {
expect(globalConfig.excludePaths).toBeDefined()
expect(Array.isArray(globalConfig.excludePaths)).toBe(true)
expect(globalConfig.excludePaths).toContain('content/contributing/')
})
test('simulates path exclusion logic', () => {
// Simulate the cleanPaths function logic from lint-content.ts
function isPathExcluded(filePath: string): boolean {
return globalConfig.excludePaths.some((excludePath) => filePath.startsWith(excludePath))
}
// First check exclusions using shared function
if (!shouldIncludeResult(flaw, filePath)) {
return false
}
// Files in contributing directory should be excluded
expect(isPathExcluded('content/contributing/README.md')).toBe(true)
expect(isPathExcluded('content/contributing/how-to-contribute.md')).toBe(true)
expect(isPathExcluded('content/contributing/collaborating-on-github-docs/file.md')).toBe(true)
// Extract all possible rule names including sub-rules from search-replace
const allRuleNames = [...flaw.ruleNames]
if (flaw.ruleNames.includes('search-replace') && flaw.errorDetail) {
const match = flaw.errorDetail.match(/^([^:]+):/)
if (match) {
allRuleNames.push(match[1])
}
}
// Files outside contributing directory should not be excluded
expect(isPathExcluded('content/actions/README.md')).toBe(false)
expect(isPathExcluded('content/copilot/getting-started.md')).toBe(false)
expect(isPathExcluded('data/variables/example.yml')).toBe(false)
// Edge case: partial matches should not be excluded
expect(isPathExcluded('content/contributing-guide.md')).toBe(false)
})
})
describe('report filtering (lint-report.ts)', () => {
// Helper function that matches the actual logic in lint-report.ts
function shouldIncludeInReport(flaw: LintFlaw): boolean {
const allRuleNames = getAllRuleNames(flaw)
// Apply reporting-specific filtering
// Check if severity should be included
if (reportingConfig.includeSeverities.includes(flaw.severity)) {
return true
}
// Check if any rule name is in the include list
const hasIncludedRule = allRuleNames.some((ruleName) =>
// Check if any rule name is in the include list that overrides severity
const hasIncludedRule = allRuleNames.some((ruleName: string) =>
reportingConfig.includeRules.includes(ruleName),
)
if (hasIncludedRule) {
@@ -46,121 +67,118 @@ describe('lint report exclusions', () => {
return false
}
test('TODOCS placeholder errors are excluded for documentation file', () => {
const flaw = {
test('reportingConfig is properly structured', () => {
expect(reportingConfig.includeSeverities).toBeDefined()
expect(Array.isArray(reportingConfig.includeSeverities)).toBe(true)
expect(reportingConfig.includeRules).toBeDefined()
expect(Array.isArray(reportingConfig.includeRules)).toBe(true)
})
test('includes errors by default (severity-based filtering)', () => {
const errorFlaw = {
severity: 'error',
ruleNames: ['some-rule'],
}
expect(shouldIncludeInReport(errorFlaw)).toBe(true)
})
test('excludes warnings by default (severity-based filtering)', () => {
const warningFlaw = {
severity: 'warning',
ruleNames: ['some-rule'],
}
expect(shouldIncludeInReport(warningFlaw)).toBe(false)
})
test('includes specific rules regardless of severity', () => {
const expiredContentWarning = {
severity: 'warning',
ruleNames: ['expired-content'],
}
// Should be included because expired-content is in includeRules
expect(shouldIncludeInReport(expiredContentWarning)).toBe(true)
})
test('handles search-replace sub-rules correctly', () => {
const searchReplaceFlaw = {
severity: 'warning',
ruleNames: ['search-replace'],
errorDetail: 'todocs-placeholder: Catch occurrences of TODOCS placeholder.',
}
const excludedFilePath =
'content/contributing/collaborating-on-github-docs/using-the-todocs-placeholder-to-leave-notes.md'
const regularFilePath = 'content/some-other-article.md'
// Should be excluded for the specific documentation file
expect(shouldIncludeInReport(flaw, excludedFilePath)).toBe(false)
// Should still be included for other files
expect(shouldIncludeInReport(flaw, regularFilePath)).toBe(true)
// Should extract 'todocs-placeholder' as a rule name and check against includeRules
// This will depend on your actual includeRules configuration
const result = shouldIncludeInReport(searchReplaceFlaw)
expect(typeof result).toBe('boolean')
})
test('TODOCS placeholder errors are excluded with different path formats', () => {
const flaw = {
severity: 'error',
ruleNames: ['search-replace'],
errorDetail: 'todocs-placeholder: Catch occurrences of TODOCS placeholder.',
}
// Test various path formats that should match
const pathVariants = [
'content/contributing/collaborating-on-github-docs/using-the-todocs-placeholder-to-leave-notes.md',
'./content/contributing/collaborating-on-github-docs/using-the-todocs-placeholder-to-leave-notes.md',
'/absolute/path/content/contributing/collaborating-on-github-docs/using-the-todocs-placeholder-to-leave-notes.md',
]
pathVariants.forEach((path) => {
expect(shouldIncludeInReport(flaw, path)).toBe(false)
})
})
test('other rules are not affected by TODOCS file exclusions', () => {
const flaw = {
severity: 'error',
ruleNames: ['docs-domain'],
}
const excludedFilePath =
'content/contributing/collaborating-on-github-docs/using-the-todocs-placeholder-to-leave-notes.md'
// Should still be included for other rules even in the excluded file
expect(shouldIncludeInReport(flaw, excludedFilePath)).toBe(true)
})
test('multiple rule names with mixed exclusions', () => {
const flaw = {
severity: 'error',
ruleNames: ['search-replace', 'docs-domain'],
errorDetail: 'todocs-placeholder: Catch occurrences of TODOCS placeholder.',
}
const excludedFilePath =
'content/contributing/collaborating-on-github-docs/using-the-todocs-placeholder-to-leave-notes.md'
// Should be excluded because one of the rules (todocs-placeholder) is excluded for this file
expect(shouldIncludeInReport(flaw, excludedFilePath)).toBe(false)
})
test('exclusion configuration exists and is properly structured', () => {
expect(reportingConfig.excludeFilesFromRules).toBeDefined()
expect(reportingConfig.excludeFilesFromRules['todocs-placeholder']).toBeDefined()
expect(Array.isArray(reportingConfig.excludeFilesFromRules['todocs-placeholder'])).toBe(true)
expect(
reportingConfig.excludeFilesFromRules['todocs-placeholder'].includes(
'content/contributing/collaborating-on-github-docs/using-the-todocs-placeholder-to-leave-notes.md',
),
).toBe(true)
})
describe('shared shouldIncludeResult function', () => {
test('excludes TODOCS placeholder errors for specific file', () => {
const flaw = {
severity: 'error',
ruleNames: ['search-replace'],
errorDetail: 'todocs-placeholder: Catch occurrences of TODOCS placeholder.',
}
const excludedFilePath =
'content/contributing/collaborating-on-github-docs/using-the-todocs-placeholder-to-leave-notes.md'
const regularFilePath = 'content/some-other-article.md'
// Should be excluded for the specific documentation file
expect(shouldIncludeResult(flaw, excludedFilePath)).toBe(false)
// Should be included for other files
expect(shouldIncludeResult(flaw, regularFilePath)).toBe(true)
})
test('includes flaws by default when no exclusions apply', () => {
const flaw = {
severity: 'error',
ruleNames: ['some-other-rule'],
}
const filePath = 'content/some-article.md'
expect(shouldIncludeResult(flaw, filePath)).toBe(true)
})
test('handles missing errorDetail gracefully', () => {
const flaw = {
severity: 'error',
test('handles missing errorDetail gracefully for search-replace', () => {
const searchReplaceFlawNoDetail = {
severity: 'warning',
ruleNames: ['search-replace'],
// no errorDetail
}
const filePath = 'content/some-article.md'
// Should not throw an error and return false (warning not in includeSeverities)
expect(shouldIncludeInReport(searchReplaceFlawNoDetail)).toBe(false)
})
expect(shouldIncludeResult(flaw, filePath)).toBe(true)
test('rule extraction logic works correctly', () => {
const regularFlaw = {
severity: 'error',
ruleNames: ['docs-domain'],
}
expect(getAllRuleNames(regularFlaw)).toEqual(['docs-domain'])
const searchReplaceFlaw = {
severity: 'error',
ruleNames: ['search-replace'],
errorDetail: 'todocs-placeholder: Catch occurrences of TODOCS placeholder.',
}
expect(getAllRuleNames(searchReplaceFlaw)).toEqual(['search-replace', 'todocs-placeholder'])
const multipleRulesFlaw = {
severity: 'error',
ruleNames: ['search-replace', 'another-rule'],
errorDetail: 'docs-domain: Some error message.',
}
expect(getAllRuleNames(multipleRulesFlaw)).toEqual([
'search-replace',
'another-rule',
'docs-domain',
])
})
})
describe('integration between systems', () => {
test('path exclusions happen before report filtering', () => {
// This is a conceptual test - in practice, files excluded by globalConfig.excludePaths
// never reach the reporting stage, so they never get filtered by reportingConfig
// Files in excluded paths should never be linted at all
const isExcluded = (path: string) =>
globalConfig.excludePaths.some((excludePath) => path.startsWith(excludePath))
expect(isExcluded('content/contributing/some-file.md')).toBe(true)
// If a file is excluded at the path level, it doesn't matter what the reportingConfig says
// because the file will never be processed for linting in the first place
})
test('configurations are independent', () => {
// globalConfig handles what gets linted
expect(globalConfig.excludePaths).toBeDefined()
// reportingConfig handles what gets reported
expect(reportingConfig.includeSeverities).toBeDefined()
expect(reportingConfig.includeRules).toBeDefined()
// They should not overlap or depend on each other
expect(globalConfig).not.toHaveProperty('includeSeverities')
expect(reportingConfig).not.toHaveProperty('excludePaths')
})
})
})

View File

@@ -1,100 +0,0 @@
import { describe, expect, test } from 'vitest'
import { runRule } from '../../lib/init-test'
import { listFirstWordCapitalization } from '../../lib/linting-rules/list-first-word-capitalization'
describe(listFirstWordCapitalization.names.join(' - '), () => {
test('ensure multi-level lists catch incorrect capitalization errors', async () => {
const markdown = [
'- List item',
' - `list` item',
' - list item',
'1. number item',
'1. Number 2 item',
'- `X` item',
'- always start `with code`',
'- remember to go to [foo](/bar)',
].join('\n')
const result = await runRule(listFirstWordCapitalization, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(4)
expect(errors[0].errorRange).toEqual([7, 4])
expect(errors[0].lineNumber).toBe(3)
expect(errors[0].fixInfo).toEqual({
deleteCount: 1,
editColumn: 7,
insertText: 'L',
lineNumber: 3,
})
expect(errors[1].errorRange).toEqual([4, 6])
expect(errors[1].lineNumber).toBe(4)
expect(errors[1].fixInfo).toEqual({
deleteCount: 1,
editColumn: 4,
insertText: 'N',
lineNumber: 4,
})
})
test('list items that start with special characters pass', async () => {
const markdown = [
'- `X-GitHub-Event`: Name of the event that triggered the delivery.',
'- **October 1, 2018**: GitHub discontinued allowing users to install services. We removed GitHub Services from the GitHub.com user interface.',
'- **boldness** is a cool thing',
'- Always start `with code`',
'- Remember to go to [foo](/bar)',
'- **{% data variables.product.prodname_oauth_apps %}**: Request either the `repo_hook` and/or `org_hook` scope(s) to manage the relevant events on behalf of users.',
'- "[AUTOTITLE](/billing/managing-billing-for-github-marketplace-apps)"',
"- '[AUTOTITLE](/billing/managing-billing-for-github-marketplace-apps)'",
'- [Viewing your sponsors and sponsorships](/sponsors/receiving-sponsorships-through-github-sponsors/viewing-your-sponsors-and-sponsorships)',
'- macOS',
'- [{% data variables.actions.test %}](/apple/test)',
'- {{ foo }} for example',
'- {% data variables.product.prodname_dotcom_the_website %} Services Continuity and Incident Management Plan',
'- {% data variables.product.prodname_dotcom_the_website %} Services Continuity and Incident Management Plan',
'- x64',
'- @mention your friends',
'- @hash tags',
'- 05:00',
'- "keyword" starts with a quotation sign',
].join('\n')
const result = await runRule(listFirstWordCapitalization, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(0)
})
test("list items that aren't simple lists", async () => {
const markdown = ['- > Blockquote in a list', '- ### Heading in a list'].join('\n')
const result = await runRule(listFirstWordCapitalization, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(0)
})
test('works on markdown that has no lists at all, actually', async () => {
const markdown = '- \n'
const result = await runRule(listFirstWordCapitalization, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(0)
})
test('skips site-policy directory files', async () => {
const markdown = [
'- list item should normally be flagged',
'- another uncapitalized item',
'- a. this is alphabetic numbering',
'- b. this is also alphabetic numbering',
].join('\n')
// Test normal behavior (should flag errors)
const normalResult = await runRule(listFirstWordCapitalization, { strings: { markdown } })
expect(normalResult.markdown.length).toBeGreaterThan(0)
// Test site-policy exclusion (should skip all errors)
const sitePolicyResult = await runRule(listFirstWordCapitalization, {
strings: {
'content/site-policy/some-policy.md': markdown,
},
})
expect(sitePolicyResult['content/site-policy/some-policy.md'].length).toBe(0)
})
})

View File

@@ -1,231 +0,0 @@
import { describe, expect, test } from 'vitest'
import { runRule } from '../../lib/init-test'
import { multipleEmphasisPatterns } from '../../lib/linting-rules/multiple-emphasis-patterns'
describe(multipleEmphasisPatterns.names.join(' - '), () => {
test('Single emphasis types pass', async () => {
const markdown = [
'This is **bold text** that is fine.',
'This is *italic text* that is okay.',
'This is `code text` that is acceptable.',
'This is a SCREAMING_CASE_WORD that is allowed.',
'This is __bold with underscores__ that works.',
'This is _italic with underscores_ that works.',
].join('\n')
const result = await runRule(multipleEmphasisPatterns, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(0)
})
test('Multiple emphasis types in same string are flagged', async () => {
const markdown = [
'This is **bold and `code`** in the same string.',
'This is ***bold and italic*** combined.',
'This is `code with **bold**` inside.',
'This is ___bold and italic___ with underscores.',
].join('\n')
const result = await runRule(multipleEmphasisPatterns, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(4)
expect(errors[0].lineNumber).toBe(1)
expect(errors[1].lineNumber).toBe(2)
expect(errors[2].lineNumber).toBe(3)
expect(errors[3].lineNumber).toBe(4)
})
test('Nested emphasis patterns are flagged', async () => {
const markdown = [
'This is **bold with `code` inside**.',
'This is `code with **bold** nested`.',
].join('\n')
const result = await runRule(multipleEmphasisPatterns, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(2)
expect(errors[0].lineNumber).toBe(1)
expect(errors[1].lineNumber).toBe(2)
})
test('Separate emphasis patterns on same line pass', async () => {
const markdown = [
'This is **bold** and this is *italic* but separate.',
'Here is `code` and here is UPPERCASE but apart.',
'First **bold**, then some text, then *italic*.',
].join('\n')
const result = await runRule(multipleEmphasisPatterns, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(0)
})
test('Code blocks are ignored', async () => {
const markdown = [
'```javascript',
'const text = "**bold** and `code` mixed";',
'const more = "***triple emphasis***";',
'```',
'',
' // Indented code block',
' const example = "**bold** with `code`";',
].join('\n')
const result = await runRule(multipleEmphasisPatterns, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(0)
})
test('Inline code prevents other emphasis detection', async () => {
const markdown = [
'Use `**bold**` to make text bold.',
'The `*italic*` syntax creates italic text.',
'Type `__bold__` for bold formatting.',
].join('\n')
const result = await runRule(multipleEmphasisPatterns, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(2) // Code with bold inside is detected
})
test('Complex mixed emphasis patterns', async () => {
const markdown = [
'This is **bold and `code`** mixed.',
'Here is ***bold italic*** combined.',
'Text with __bold and `code`__ together.',
].join('\n')
const result = await runRule(multipleEmphasisPatterns, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(3)
expect(errors[0].lineNumber).toBe(1)
expect(errors[1].lineNumber).toBe(2)
expect(errors[2].lineNumber).toBe(3)
})
test('Edge case: adjacent emphasis without overlap passes', async () => {
const markdown = [
'This is **bold**_italic_ adjacent but not overlapping.',
'Here is `code`**bold** touching but separate.',
'Text with UPPERCASE**bold** next to each other.',
].join('\n')
const result = await runRule(multipleEmphasisPatterns, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(0)
})
test('Triple asterisk bold+italic is flagged', async () => {
const markdown = [
'This is ***bold and italic*** combined.',
'Here is ___bold and italic___ with underscores.',
].join('\n')
const result = await runRule(multipleEmphasisPatterns, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(2)
expect(errors[0].lineNumber).toBe(1)
expect(errors[1].lineNumber).toBe(2)
})
test('Mixed adjacent emphasis types are allowed', async () => {
const markdown = [
'This has **bold** and normal text.',
'This has **bold** and other text.',
'The API key and **configuration** work.',
'The API key and **setup** process.',
].join('\n')
const result = await runRule(multipleEmphasisPatterns, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(0)
})
test('Autogenerated files are skipped', async () => {
const frontmatter = ['---', 'title: API Reference', 'autogenerated: rest', '---'].join('\n')
const markdown = [
'This is **bold and `code`** mixed.',
'This is ***bold italic*** combined.',
].join('\n')
const result = await runRule(multipleEmphasisPatterns, {
strings: {
markdown: frontmatter + '\n' + markdown,
},
})
const errors = result.markdown
expect(errors.length).toBe(0)
})
test('Links with emphasis are handled correctly', async () => {
const markdown = [
'See [**bold link**](http://example.com) for details.',
'Check [*italic link*](http://example.com) here.',
'Visit [`code link`](http://example.com) for info.',
'Go to [**bold and `code`**](http://example.com) - should be flagged.',
].join('\n')
const result = await runRule(multipleEmphasisPatterns, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(1)
expect(errors[0].lineNumber).toBe(4)
})
test('Headers with emphasis are checked', async () => {
const markdown = [
'# This is **bold** header',
'## This is *italic* header',
'### This is **bold and `code`** header',
'#### This is normal header',
].join('\n')
const result = await runRule(multipleEmphasisPatterns, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(1)
expect(errors[0].lineNumber).toBe(3)
})
test('List items with emphasis are checked', async () => {
const markdown = [
'- This is **bold** item',
'- This is *italic* item',
'- This is **bold and `code`** item',
'1. This is numbered **bold** item',
'2. This is numbered ***bold italic*** item',
].join('\n')
const result = await runRule(multipleEmphasisPatterns, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(2)
expect(errors[0].lineNumber).toBe(3)
expect(errors[1].lineNumber).toBe(5)
})
test('Escaped emphasis characters are ignored', async () => {
const markdown = [
'This has \\*\\*escaped\\*\\* asterisks.',
'This has \\`escaped\\` backticks.',
'This has \\_escaped\\_ underscores.',
].join('\n')
const result = await runRule(multipleEmphasisPatterns, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(0)
})
test('Rule has correct metadata', () => {
expect(multipleEmphasisPatterns.names).toEqual(['GHD050', 'multiple-emphasis-patterns'])
expect(multipleEmphasisPatterns.description).toContain('emphasis')
expect(multipleEmphasisPatterns.tags).toContain('formatting')
expect(multipleEmphasisPatterns.tags).toContain('emphasis')
expect(multipleEmphasisPatterns.tags).toContain('style')
expect(multipleEmphasisPatterns.severity).toBe('warning')
})
test('Empty content does not cause errors', async () => {
const markdown = ['', ' ', '\t'].join('\n')
const result = await runRule(multipleEmphasisPatterns, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(0)
})
test('Single character emphasis is handled', async () => {
const markdown = [
'This is **a** single letter.',
'This is *b* single letter.',
'This is `c` single letter.',
'This is **a** and *b* separate.',
'This is **`x`** nested single chars.',
].join('\n')
const result = await runRule(multipleEmphasisPatterns, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(1) // Nested single chars still flagged
expect(errors[0].lineNumber).toBe(5)
})
})

View File

@@ -1,324 +0,0 @@
import { describe, expect, test } from 'vitest'
import { runRule } from '../../lib/init-test'
import { noteWarningFormatting } from '../../lib/linting-rules/note-warning-formatting'
describe(noteWarningFormatting.names.join(' - '), () => {
test('Correctly formatted legacy notes pass', async () => {
const markdown = [
'This is a paragraph.',
'',
'{% note %}',
'',
'**Note:** This is a properly formatted note.',
'',
'{% endnote %}',
'',
'Another paragraph follows.',
].join('\n')
const result = await runRule(noteWarningFormatting, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(0)
})
test('Correctly formatted new-style callouts pass', async () => {
const markdown = [
'This is a paragraph.',
'',
'> [!NOTE]',
'> This is a properly formatted callout note.',
'',
'Another paragraph follows.',
].join('\n')
const result = await runRule(noteWarningFormatting, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(0)
})
test('Missing line break before legacy note is flagged', async () => {
const markdown = [
'This is a paragraph.',
'{% note %}',
'**Note:** This note needs a line break before it.',
'{% endnote %}',
].join('\n')
const result = await runRule(noteWarningFormatting, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(1)
expect(errors[0].lineNumber).toBe(2)
if (errors[0].errorDetail) {
expect(errors[0].errorDetail).toContain('Add a blank line before {% note %}')
}
})
test('Missing line break after legacy note is flagged', async () => {
const markdown = [
'',
'{% note %}',
'**Note:** This note needs a line break after it.',
'{% endnote %}',
'This paragraph is too close.',
].join('\n')
const result = await runRule(noteWarningFormatting, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(1)
expect(errors[0].lineNumber).toBe(4)
if (errors[0].errorDetail) {
expect(errors[0].errorDetail).toContain('Add a blank line after {% endnote %}')
}
})
test('Missing line break before new-style callout is flagged', async () => {
const markdown = [
'This is a paragraph.',
'> [!WARNING]',
'> This warning needs a line break before it.',
].join('\n')
const result = await runRule(noteWarningFormatting, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(1)
expect(errors[0].lineNumber).toBe(2)
if (errors[0].errorDetail) {
expect(errors[0].errorDetail).toContain('Add a blank line before > [!WARNING]')
}
})
test('Missing line break after new-style callout is flagged', async () => {
const markdown = [
'',
'> [!DANGER]',
'> This danger callout needs a line break after it.',
'This paragraph is too close.',
].join('\n')
const result = await runRule(noteWarningFormatting, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(1)
expect(errors[0].lineNumber).toBe(4)
if (errors[0].errorDetail) {
expect(errors[0].errorDetail).toContain('Add a blank line after > [!DANGER]')
}
})
test('Too many bullet points in legacy note is flagged', async () => {
const markdown = [
'',
'{% note %}',
'',
'**Note:** This note has too many bullets:',
'',
'* First bullet point',
'* Second bullet point',
'* Third bullet point (this should be flagged)',
'',
'{% endnote %}',
'',
].join('\n')
const result = await runRule(noteWarningFormatting, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(1)
expect(errors[0].lineNumber).toBe(8)
if (errors[0].errorDetail) {
expect(errors[0].errorDetail).toContain('Do not include more than 2 bullet points')
}
})
test('Too many bullet points in new-style callout is flagged', async () => {
const markdown = [
'',
'> [!NOTE]',
'> This callout has too many bullets:',
'>',
'> * First bullet point',
'> * Second bullet point',
'> * Third bullet point (this should be flagged)',
'',
].join('\n')
const result = await runRule(noteWarningFormatting, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(1)
expect(errors[0].lineNumber).toBe(7)
if (errors[0].errorDetail) {
expect(errors[0].errorDetail).toContain('Do not include more than 2 bullet points')
}
})
test('Missing prefix in legacy note is flagged and fixable', async () => {
const markdown = [
'',
'{% note %}',
'',
'This note is missing the proper prefix.',
'',
'{% endnote %}',
'',
].join('\n')
const result = await runRule(noteWarningFormatting, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(1)
expect(errors[0].lineNumber).toBe(4)
if (errors[0].errorDetail) {
expect(errors[0].errorDetail).toContain('should start with **Note:**')
}
expect(errors[0].fixInfo).toBeDefined()
if (errors[0].fixInfo) {
expect(errors[0].fixInfo?.insertText).toBe('**Note:** ')
}
})
test('Orphaned note prefix outside callout is flagged', async () => {
const markdown = [
'This is a regular paragraph.',
'',
'**Note:** This note prefix should be inside a callout block.',
'',
'Another paragraph.',
].join('\n')
const result = await runRule(noteWarningFormatting, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(1)
expect(errors[0].lineNumber).toBe(3)
if (errors[0].errorDetail) {
expect(errors[0].errorDetail).toContain('should be inside a callout block')
}
})
test('Orphaned warning prefix outside callout is flagged', async () => {
const markdown = [
'Regular content here.',
'',
'**Warning:** This warning should be in a proper callout.',
'',
'More content.',
].join('\n')
const result = await runRule(noteWarningFormatting, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(1)
expect(errors[0].lineNumber).toBe(3)
if (errors[0].errorDetail) {
expect(errors[0].errorDetail).toContain('Warning prefix should be inside a callout block')
}
})
test('Feedback forms in legacy notes are not flagged for missing prefix', async () => {
const markdown = [
'',
'{% note %}',
'',
'Did you successfully complete this task?',
'',
'<a href="https://example.com" class="btn">Yes</a>',
'',
'{% endnote %}',
'',
].join('\n')
const result = await runRule(noteWarningFormatting, { strings: { markdown } })
const errors = result.markdown
// Should only flag missing line breaks, not missing prefix for feedback forms
expect(errors.length).toBe(0)
})
test('Multiple formatting issues are all caught', async () => {
const markdown = [
'Paragraph without break.',
'{% note %}',
'Missing prefix and has bullets:',
'* First bullet',
'* Second bullet',
'* Third bullet (too many)',
'{% endnote %}',
'No break after note.',
'',
'**Danger:** Orphaned danger prefix.',
].join('\n')
const result = await runRule(noteWarningFormatting, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(5)
// Check we get all expected error types by line numbers and error count
const errorLines = errors.map((e) => e.lineNumber).sort((a, b) => a - b)
expect(errorLines).toEqual([2, 3, 6, 7, 10])
// Verify we have the expected number of different types of errors:
// 1. Missing line break before note (line 2)
// 2. Missing prefix in note content (line 3)
// 3. Too many bullet points (line 6)
// 4. Missing line break after note (line 7)
// 5. Orphaned danger prefix (line 10)
expect(errors.length).toBe(5)
})
test('Mixed legacy and new-style callouts work correctly', async () => {
const markdown = [
'Some content.',
'',
'{% note %}',
'**Note:** This is a legacy note.',
'{% endnote %}',
'',
'More content.',
'',
'> [!WARNING]',
'> This is a new-style warning.',
'',
'Final content.',
].join('\n')
const result = await runRule(noteWarningFormatting, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(0)
})
test('Different callout types are handled correctly', async () => {
const markdown = [
'',
'> [!NOTE]',
'> This is a note callout.',
'',
'> [!WARNING]',
'> This is a warning callout.',
'',
'> [!DANGER]',
'> This is a danger callout.',
'',
].join('\n')
const result = await runRule(noteWarningFormatting, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(0)
})
test('Autogenerated files are skipped', async () => {
const frontmatter = ['---', 'title: API Reference', 'autogenerated: rest', '---'].join('\n')
const markdown = [
'Content.',
'{% note %}',
'Badly formatted note.',
'{% endnote %}',
'More content.',
].join('\n')
const result = await runRule(noteWarningFormatting, {
strings: {
markdown: frontmatter + '\n' + markdown,
},
})
const errors = result.markdown
expect(errors.length).toBe(0)
})
test('Empty notes and callouts do not cause errors', async () => {
const markdown = ['', '{% note %}', '', '{% endnote %}', '', '> [!NOTE]', '>', ''].join('\n')
const result = await runRule(noteWarningFormatting, { strings: { markdown } })
const errors = result.markdown
expect(errors.length).toBe(0)
})
test('Warning severity is set correctly', () => {
expect(noteWarningFormatting.severity).toBe('warning')
})
test('Rule has correct metadata', () => {
expect(noteWarningFormatting.names).toEqual(['GHD049', 'note-warning-formatting'])
expect(noteWarningFormatting.description).toContain('style guide')
expect(noteWarningFormatting.tags).toContain('callouts')
expect(noteWarningFormatting.tags).toContain('notes')
expect(noteWarningFormatting.tags).toContain('warnings')
})
})

View File

@@ -19,8 +19,8 @@ vi.mock('../../lib/helpers/get-rules', () => ({
description: 'Headers must have content below them',
},
{
names: ['GHD030', 'code-fence-line-length'],
description: 'Code fence content should not exceed line length limit',
names: ['GHD001', 'link-punctuation'],
description: 'Internal link titles must not contain punctuation',
},
],
allConfig: {},
@@ -41,12 +41,12 @@ describe('shouldIncludeRule', () => {
test('includes custom rule by short code', () => {
expect(shouldIncludeRule('header-content-requirement', ['GHD053'])).toBe(true)
expect(shouldIncludeRule('code-fence-line-length', ['GHD030'])).toBe(true)
expect(shouldIncludeRule('link-punctuation', ['GHD001'])).toBe(true)
})
test('excludes rule not in list', () => {
expect(shouldIncludeRule('heading-increment', ['MD002'])).toBe(false)
expect(shouldIncludeRule('header-content-requirement', ['GHD030'])).toBe(false)
expect(shouldIncludeRule('header-content-requirement', ['GHD001'])).toBe(false)
})
test('handles multiple rules', () => {

View File

@@ -1,6 +1,5 @@
import fs from 'fs'
import path from 'path'
import { execSync } from 'child_process'
import { renderLiquid } from '@/content-render/liquid/index'
import shortVersionsMiddleware from '@/versions/middleware/short-versions'
@@ -83,7 +82,4 @@ for (const page of pages) {
console.log(err)
}
}
console.log('---\nWriting files done. Now linting content...\n')
// Content linter to remove any blank lines
execSync('npm run lint-content -- --paths content-copilot --rules no-multiple-blanks --fix')
console.log(`Finished - content is available in: ${contentCopilotDir}`)