Batch linter updates (#58270)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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 |
|
||||
|
||||
22
src/content-linter/lib/helpers/rule-utils.ts
Normal file
22
src/content-linter/lib/helpers/rule-utils.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
)
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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') {
|
||||
thirdPartyActions.push(actionRef)
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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,159 +17,168 @@ 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/')
|
||||
})
|
||||
|
||||
// First check exclusions using shared function
|
||||
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])
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
// Apply reporting-specific filtering
|
||||
// Check if severity should be included
|
||||
if (reportingConfig.includeSeverities.includes(flaw.severity)) {
|
||||
return true
|
||||
}
|
||||
// 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)
|
||||
|
||||
// Check if any rule name is in the include list
|
||||
const hasIncludedRule = allRuleNames.some((ruleName) =>
|
||||
reportingConfig.includeRules.includes(ruleName),
|
||||
)
|
||||
if (hasIncludedRule) {
|
||||
return true
|
||||
}
|
||||
// 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)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
test('TODOCS placeholder errors are excluded for documentation 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(shouldIncludeInReport(flaw, excludedFilePath)).toBe(false)
|
||||
|
||||
// Should still be included for other files
|
||||
expect(shouldIncludeInReport(flaw, regularFilePath)).toBe(true)
|
||||
})
|
||||
|
||||
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)
|
||||
// Edge case: partial matches should not be excluded
|
||||
expect(isPathExcluded('content/contributing-guide.md')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test('other rules are not affected by TODOCS file exclusions', () => {
|
||||
const flaw = {
|
||||
severity: 'error',
|
||||
ruleNames: ['docs-domain'],
|
||||
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)
|
||||
|
||||
// Check if severity should be included
|
||||
if (reportingConfig.includeSeverities.includes(flaw.severity)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const excludedFilePath =
|
||||
'content/contributing/collaborating-on-github-docs/using-the-todocs-placeholder-to-leave-notes.md'
|
||||
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)
|
||||
})
|
||||
|
||||
// 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 = {
|
||||
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(shouldIncludeResult(flaw, excludedFilePath)).toBe(false)
|
||||
|
||||
// Should be included for other files
|
||||
expect(shouldIncludeResult(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('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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
Reference in New Issue
Block a user