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 |
|
| 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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
// @ts-ignore - @github/markdownlint-github doesn't provide TypeScript declarations
|
||||||
import markdownlintGitHub from '@github/markdownlint-github'
|
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 { 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 { imageFileKebabCase } from '@/content-linter/lib/linting-rules/image-file-kebab-case'
|
||||||
import { incorrectAltTextLength } from '@/content-linter/lib/linting-rules/image-alt-text-length'
|
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 { internalLinksNoLang } from '@/content-linter/lib/linting-rules/internal-links-no-lang'
|
||||||
import { internalLinksSlash } from '@/content-linter/lib/linting-rules/internal-links-slash'
|
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 { 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 { linkPunctuation } from '@/content-linter/lib/linting-rules/link-punctuation'
|
||||||
import {
|
import {
|
||||||
earlyAccessReferences,
|
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 { octiconAriaLabels } from '@/content-linter/lib/linting-rules/octicon-aria-labels'
|
||||||
import { liquidIfversionVersions } from '@/content-linter/lib/linting-rules/liquid-ifversion-versions'
|
import { liquidIfversionVersions } from '@/content-linter/lib/linting-rules/liquid-ifversion-versions'
|
||||||
import { outdatedReleasePhaseTerminology } from '@/content-linter/lib/linting-rules/outdated-release-phase-terminology'
|
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 { 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 { headerContentRequirement } from '@/content-linter/lib/linting-rules/header-content-requirement'
|
||||||
import { thirdPartyActionsReusable } from '@/content-linter/lib/linting-rules/third-party-actions-reusable'
|
import { thirdPartyActionsReusable } from '@/content-linter/lib/linting-rules/third-party-actions-reusable'
|
||||||
import { frontmatterLandingRecommended } from '@/content-linter/lib/linting-rules/frontmatter-landing-recommended'
|
import { frontmatterLandingRecommended } from '@/content-linter/lib/linting-rules/frontmatter-landing-recommended'
|
||||||
@@ -103,11 +97,9 @@ export const gitHubDocsMarkdownlint = {
|
|||||||
liquidIfVersionTags, // GHD020
|
liquidIfVersionTags, // GHD020
|
||||||
yamlScheduledJobs, // GHD021
|
yamlScheduledJobs, // GHD021
|
||||||
liquidIfversionVersions, // GHD022
|
liquidIfversionVersions, // GHD022
|
||||||
codeFenceLineLength, // GHD030
|
|
||||||
imageAltTextExcludeStartWords, // GHD031
|
imageAltTextExcludeStartWords, // GHD031
|
||||||
imageAltTextEndPunctuation, // GHD032
|
imageAltTextEndPunctuation, // GHD032
|
||||||
incorrectAltTextLength, // GHD033
|
incorrectAltTextLength, // GHD033
|
||||||
listFirstWordCapitalization, // GHD034
|
|
||||||
raiReusableUsage, // GHD035
|
raiReusableUsage, // GHD035
|
||||||
imageNoGif, // GHD036
|
imageNoGif, // GHD036
|
||||||
expiredContent, // GHD038
|
expiredContent, // GHD038
|
||||||
@@ -120,13 +112,9 @@ export const gitHubDocsMarkdownlint = {
|
|||||||
codeAnnotationCommentSpacing, // GHD045
|
codeAnnotationCommentSpacing, // GHD045
|
||||||
outdatedReleasePhaseTerminology, // GHD046
|
outdatedReleasePhaseTerminology, // GHD046
|
||||||
tableColumnIntegrity, // GHD047
|
tableColumnIntegrity, // GHD047
|
||||||
britishEnglishQuotes, // GHD048
|
|
||||||
noteWarningFormatting, // GHD049
|
|
||||||
multipleEmphasisPatterns, // GHD050
|
|
||||||
frontmatterVersionsWhitespace, // GHD051
|
frontmatterVersionsWhitespace, // GHD051
|
||||||
headerContentRequirement, // GHD053
|
headerContentRequirement, // GHD053
|
||||||
thirdPartyActionsReusable, // GHD054
|
thirdPartyActionsReusable, // GHD054
|
||||||
frontmatterValidation, // GHD055
|
|
||||||
frontmatterLandingRecommended, // GHD056
|
frontmatterLandingRecommended, // GHD056
|
||||||
ctasSchema, // GHD057
|
ctasSchema, // GHD057
|
||||||
journeyTracksLiquid, // GHD058
|
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'],
|
['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 {
|
interface CompiledRegex {
|
||||||
regex: RegExp
|
regex: RegExp
|
||||||
outdatedTerm: string
|
outdatedTerm: string
|
||||||
@@ -96,6 +116,13 @@ export const outdatedReleasePhaseTerminology = {
|
|||||||
tags: ['terminology', 'consistency', 'release-phases'],
|
tags: ['terminology', 'consistency', 'release-phases'],
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
function: (params: RuleParams, onError: RuleErrorCallback) => {
|
function: (params: RuleParams, onError: RuleErrorCallback) => {
|
||||||
|
// Skip excluded files
|
||||||
|
for (const filepath of EXCLUDED_PATHS) {
|
||||||
|
if (params.name.startsWith(filepath)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Skip autogenerated files
|
// Skip autogenerated files
|
||||||
const frontmatterString = params.frontMatterLines.join('\n')
|
const frontmatterString = params.frontMatterLines.join('\n')
|
||||||
const fm = frontmatter(frontmatterString).data
|
const fm = frontmatter(frontmatterString).data
|
||||||
|
|||||||
@@ -43,34 +43,46 @@ export const thirdPartyActionsReusable = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Find third-party actions in YAML content
|
* Find third-party actions in YAML content
|
||||||
* Third-party actions are identified by the pattern: owner/action@version
|
* Third-party actions are identified by actions that are not GitHub-owned or documentation examples
|
||||||
* where owner is not 'actions' or 'github'
|
|
||||||
*/
|
*/
|
||||||
function findThirdPartyActions(yamlContent: string): string[] {
|
function findThirdPartyActions(yamlContent: string): string[] {
|
||||||
const thirdPartyActions: 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
|
const actionPattern = /uses:\s+([^{\s]+\/[^@\s]+@[^\s]+)/g
|
||||||
|
|
||||||
let match
|
let match
|
||||||
while ((match = actionPattern.exec(yamlContent)) !== null) {
|
while ((match = actionPattern.exec(yamlContent)) !== null) {
|
||||||
const actionRef = match[1]
|
const actionRef = match[1]
|
||||||
|
|
||||||
// Extract owner from action reference
|
if (!isExampleOrGitHubAction(actionRef)) {
|
||||||
const parts = actionRef.split('/')
|
thirdPartyActions.push(actionRef)
|
||||||
if (parts.length >= 2) {
|
|
||||||
const owner = parts[0]
|
|
||||||
|
|
||||||
// Skip GitHub-owned actions (actions/* and github/*)
|
|
||||||
if (owner !== 'actions' && owner !== 'github') {
|
|
||||||
thirdPartyActions.push(actionRef)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return thirdPartyActions
|
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
|
* 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
|
* 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 { getLintableYml } from '@/content-linter/lib/helpers/get-lintable-yml'
|
||||||
import { printAnnotationResults } from '../lib/helpers/print-annotations'
|
import { printAnnotationResults } from '../lib/helpers/print-annotations'
|
||||||
import languages from '@/languages/lib/languages-server'
|
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
|
program
|
||||||
.description('Run GitHub Docs Markdownlint rules.')
|
.description('Run GitHub Docs Markdownlint rules.')
|
||||||
@@ -197,12 +204,7 @@ async function main() {
|
|||||||
|
|
||||||
if (printAnnotations) {
|
if (printAnnotations) {
|
||||||
printAnnotationResults(formattedResults, {
|
printAnnotationResults(formattedResults, {
|
||||||
skippableRules: [
|
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
|
|
||||||
],
|
|
||||||
skippableFlawProperties: [
|
skippableFlawProperties: [
|
||||||
// As of Feb 2024, we don't support reporting flaws for lines
|
// As of Feb 2024, we don't support reporting flaws for lines
|
||||||
// and columns numbers of YAML files. YAML files consist of one
|
// and columns numbers of YAML files. YAML files consist of one
|
||||||
@@ -349,7 +351,14 @@ function getFilesToLint(paths) {
|
|||||||
(!filePath.endsWith('.md') && !filePath.endsWith('.yml'))
|
(!filePath.endsWith('.md') && !filePath.endsWith('.yml'))
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
const relPath = path.relative(root, filePath)
|
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
|
if (seen.has(relPath)) continue
|
||||||
seen.add(relPath)
|
seen.add(relPath)
|
||||||
clean.push(relPath)
|
clean.push(relPath)
|
||||||
@@ -427,9 +436,7 @@ function getFormattedResults(allResults, isPrecommit) {
|
|||||||
if (verbose) {
|
if (verbose) {
|
||||||
output[key] = [...results]
|
output[key] = [...results]
|
||||||
} else {
|
} else {
|
||||||
const formattedResults = results
|
const formattedResults = results.map((flaw) => formatResult(flaw, isPrecommit))
|
||||||
.map((flaw) => formatResult(flaw, isPrecommit))
|
|
||||||
.filter((flaw) => shouldIncludeResult(flaw, key))
|
|
||||||
|
|
||||||
// Only add the file to output if there are results after filtering
|
// Only add the file to output if there are results after filtering
|
||||||
if (formattedResults.length > 0) {
|
if (formattedResults.length > 0) {
|
||||||
@@ -562,9 +569,6 @@ function getMarkdownLintConfig(errorsOnly, runRules) {
|
|||||||
// Check if the rule should be included based on user-specified rules
|
// Check if the rule should be included based on user-specified rules
|
||||||
if (runRules && !shouldIncludeRule(ruleName, runRules)) continue
|
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
|
// There are a subset of rules run on just the frontmatter in files
|
||||||
if (githubDocsFrontmatterConfig[ruleName]) {
|
if (githubDocsFrontmatterConfig[ruleName]) {
|
||||||
config.frontMatter[ruleName] = ruleConfig
|
config.frontMatter[ruleName] = ruleConfig
|
||||||
|
|||||||
@@ -5,12 +5,21 @@ import coreLib from '@actions/core'
|
|||||||
import github from '@/workflows/github'
|
import github from '@/workflows/github'
|
||||||
import { getEnvInputs } from '@/workflows/get-env-inputs'
|
import { getEnvInputs } from '@/workflows/get-env-inputs'
|
||||||
import { createReportIssue, linkReports } from '@/workflows/issue-report'
|
import { createReportIssue, linkReports } from '@/workflows/issue-report'
|
||||||
import { shouldIncludeResult } from '@/content-linter/lib/helpers/should-include-result'
|
import { getAllRuleNames } from '@/content-linter/lib/helpers/rule-utils'
|
||||||
import { reportingConfig } from '@/content-linter/style/github-docs'
|
|
||||||
|
|
||||||
// GitHub issue body size limit is ~65k characters, so we'll use 60k as a safe limit
|
// GitHub issue body size limit is ~65k characters, so we'll use 60k as a safe limit
|
||||||
const MAX_ISSUE_BODY_SIZE = 60000
|
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 {
|
interface LintFlaw {
|
||||||
severity: string
|
severity: string
|
||||||
ruleNames: string[]
|
ruleNames: string[]
|
||||||
@@ -19,34 +28,16 @@ interface LintFlaw {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines if a lint result should be included in the automated report
|
* 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 {
|
function shouldIncludeInReport(flaw: LintFlaw): boolean {
|
||||||
if (!flaw.ruleNames || !Array.isArray(flaw.ruleNames)) {
|
const allRuleNames = getAllRuleNames(flaw)
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
// Check if severity should be included
|
||||||
if (reportingConfig.includeSeverities.includes(flaw.severity)) {
|
if (reportingConfig.includeSeverities.includes(flaw.severity)) {
|
||||||
return true
|
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) =>
|
const hasIncludedRule = allRuleNames.some((ruleName: string) =>
|
||||||
reportingConfig.includeRules.includes(ruleName),
|
reportingConfig.includeRules.includes(ruleName),
|
||||||
)
|
)
|
||||||
@@ -101,7 +92,7 @@ async function main() {
|
|||||||
// Filter results based on reporting configuration
|
// Filter results based on reporting configuration
|
||||||
const filteredResults: Record<string, LintFlaw[]> = {}
|
const filteredResults: Record<string, LintFlaw[]> = {}
|
||||||
for (const [file, flaws] of Object.entries(parsedResults)) {
|
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
|
// Only include files that have remaining flaws after filtering
|
||||||
if (filteredFlaws.length > 0) {
|
if (filteredFlaws.length > 0) {
|
||||||
|
|||||||
@@ -33,32 +33,12 @@ export const baseConfig: BaseConfig = {
|
|||||||
'partial-markdown-files': false,
|
'partial-markdown-files': false,
|
||||||
'yml-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': {
|
'no-reversed-links': {
|
||||||
// MD011
|
// MD011
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
'partial-markdown-files': true,
|
'partial-markdown-files': true,
|
||||||
'yml-files': true,
|
'yml-files': true,
|
||||||
},
|
},
|
||||||
'no-multiple-blanks': {
|
|
||||||
// MD012
|
|
||||||
severity: 'error',
|
|
||||||
'partial-markdown-files': true,
|
|
||||||
'yml-files': true,
|
|
||||||
},
|
|
||||||
'commands-show-output': {
|
'commands-show-output': {
|
||||||
// MD014
|
// MD014
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
@@ -77,12 +57,6 @@ export const baseConfig: BaseConfig = {
|
|||||||
'partial-markdown-files': true,
|
'partial-markdown-files': true,
|
||||||
'yml-files': true,
|
'yml-files': true,
|
||||||
},
|
},
|
||||||
'blanks-around-headings': {
|
|
||||||
// MD022
|
|
||||||
severity: 'error',
|
|
||||||
'partial-markdown-files': false,
|
|
||||||
'yml-files': false,
|
|
||||||
},
|
|
||||||
'heading-start-left': {
|
'heading-start-left': {
|
||||||
// MD023
|
// MD023
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
@@ -140,19 +114,6 @@ export const baseConfig: BaseConfig = {
|
|||||||
'partial-markdown-files': true,
|
'partial-markdown-files': true,
|
||||||
'yml-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': {
|
'strong-style': {
|
||||||
// MD050
|
// MD050
|
||||||
severity: 'error',
|
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 = {
|
const githubDocsConfig = {
|
||||||
'link-punctuation': {
|
'link-punctuation': {
|
||||||
// GHD001
|
// GHD001
|
||||||
@@ -129,12 +101,6 @@ const githubDocsConfig = {
|
|||||||
'partial-markdown-files': true,
|
'partial-markdown-files': true,
|
||||||
'yml-files': true,
|
'yml-files': true,
|
||||||
},
|
},
|
||||||
'code-fence-line-length': {
|
|
||||||
// GHD030
|
|
||||||
severity: 'warning',
|
|
||||||
'partial-markdown-files': true,
|
|
||||||
'yml-files': true,
|
|
||||||
},
|
|
||||||
'image-alt-text-exclude-words': {
|
'image-alt-text-exclude-words': {
|
||||||
// GHD031
|
// GHD031
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
@@ -153,12 +119,6 @@ const githubDocsConfig = {
|
|||||||
'partial-markdown-files': true,
|
'partial-markdown-files': true,
|
||||||
'yml-files': true,
|
'yml-files': true,
|
||||||
},
|
},
|
||||||
'list-first-word-capitalization': {
|
|
||||||
// GHD034
|
|
||||||
severity: 'warning',
|
|
||||||
'partial-markdown-files': true,
|
|
||||||
'yml-files': true,
|
|
||||||
},
|
|
||||||
'rai-reusable-usage': {
|
'rai-reusable-usage': {
|
||||||
// GHD035
|
// GHD035
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
@@ -226,25 +186,6 @@ const githubDocsConfig = {
|
|||||||
'partial-markdown-files': true,
|
'partial-markdown-files': true,
|
||||||
'yml-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': {
|
'header-content-requirement': {
|
||||||
// GHD053
|
// GHD053
|
||||||
severity: 'warning',
|
severity: 'warning',
|
||||||
@@ -312,12 +253,6 @@ export const githubDocsFrontmatterConfig = {
|
|||||||
'partial-markdown-files': false,
|
'partial-markdown-files': false,
|
||||||
'yml-files': false,
|
'yml-files': false,
|
||||||
},
|
},
|
||||||
'frontmatter-validation': {
|
|
||||||
// GHD055
|
|
||||||
severity: 'warning',
|
|
||||||
'partial-markdown-files': false,
|
|
||||||
'yml-files': false,
|
|
||||||
},
|
|
||||||
'frontmatter-landing-recommended': {
|
'frontmatter-landing-recommended': {
|
||||||
// GHD056
|
// GHD056
|
||||||
severity: 'error',
|
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 { describe, expect, test } from 'vitest'
|
||||||
import { shouldIncludeResult } from '../../lib/helpers/should-include-result'
|
import { getAllRuleNames } from '../../lib/helpers/rule-utils'
|
||||||
import { reportingConfig } from '../../style/github-docs'
|
|
||||||
|
// 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 {
|
interface LintFlaw {
|
||||||
severity: string
|
severity: string
|
||||||
@@ -8,159 +17,168 @@ interface LintFlaw {
|
|||||||
errorDetail?: string
|
errorDetail?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('lint report exclusions', () => {
|
describe('content linter configuration', () => {
|
||||||
// Helper function to simulate the reporting logic from lint-report.ts
|
describe('global path exclusions (lint-content.ts)', () => {
|
||||||
function shouldIncludeInReport(flaw: LintFlaw, filePath: string): boolean {
|
test('globalConfig.excludePaths is properly configured', () => {
|
||||||
if (!flaw.ruleNames || !Array.isArray(flaw.ruleNames)) {
|
expect(globalConfig.excludePaths).toBeDefined()
|
||||||
return false
|
expect(Array.isArray(globalConfig.excludePaths)).toBe(true)
|
||||||
}
|
expect(globalConfig.excludePaths).toContain('content/contributing/')
|
||||||
|
})
|
||||||
|
|
||||||
// First check exclusions using shared function
|
test('simulates path exclusion logic', () => {
|
||||||
if (!shouldIncludeResult(flaw, filePath)) {
|
// Simulate the cleanPaths function logic from lint-content.ts
|
||||||
return false
|
function isPathExcluded(filePath: string): boolean {
|
||||||
}
|
return globalConfig.excludePaths.some((excludePath) => filePath.startsWith(excludePath))
|
||||||
|
|
||||||
// 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
|
// Files in contributing directory should be excluded
|
||||||
// Check if severity should be included
|
expect(isPathExcluded('content/contributing/README.md')).toBe(true)
|
||||||
if (reportingConfig.includeSeverities.includes(flaw.severity)) {
|
expect(isPathExcluded('content/contributing/how-to-contribute.md')).toBe(true)
|
||||||
return true
|
expect(isPathExcluded('content/contributing/collaborating-on-github-docs/file.md')).toBe(true)
|
||||||
}
|
|
||||||
|
|
||||||
// Check if any rule name is in the include list
|
// Files outside contributing directory should not be excluded
|
||||||
const hasIncludedRule = allRuleNames.some((ruleName) =>
|
expect(isPathExcluded('content/actions/README.md')).toBe(false)
|
||||||
reportingConfig.includeRules.includes(ruleName),
|
expect(isPathExcluded('content/copilot/getting-started.md')).toBe(false)
|
||||||
)
|
expect(isPathExcluded('data/variables/example.yml')).toBe(false)
|
||||||
if (hasIncludedRule) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
// Edge case: partial matches should not be excluded
|
||||||
}
|
expect(isPathExcluded('content/contributing-guide.md')).toBe(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)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('other rules are not affected by TODOCS file exclusions', () => {
|
describe('report filtering (lint-report.ts)', () => {
|
||||||
const flaw = {
|
// Helper function that matches the actual logic in lint-report.ts
|
||||||
severity: 'error',
|
function shouldIncludeInReport(flaw: LintFlaw): boolean {
|
||||||
ruleNames: ['docs-domain'],
|
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 =
|
test('reportingConfig is properly structured', () => {
|
||||||
'content/contributing/collaborating-on-github-docs/using-the-todocs-placeholder-to-leave-notes.md'
|
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
|
test('includes errors by default (severity-based filtering)', () => {
|
||||||
expect(shouldIncludeInReport(flaw, excludedFilePath)).toBe(true)
|
const errorFlaw = {
|
||||||
})
|
|
||||||
|
|
||||||
test('multiple rule names with mixed exclusions', () => {
|
|
||||||
const flaw = {
|
|
||||||
severity: 'error',
|
|
||||||
ruleNames: ['search-replace', 'docs-domain'],
|
|
||||||
errorDetail: 'todocs-placeholder: Catch occurrences of TODOCS placeholder.',
|
|
||||||
}
|
|
||||||
|
|
||||||
const excludedFilePath =
|
|
||||||
'content/contributing/collaborating-on-github-docs/using-the-todocs-placeholder-to-leave-notes.md'
|
|
||||||
|
|
||||||
// Should be excluded because one of the rules (todocs-placeholder) is excluded for this file
|
|
||||||
expect(shouldIncludeInReport(flaw, excludedFilePath)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('exclusion configuration exists and is properly structured', () => {
|
|
||||||
expect(reportingConfig.excludeFilesFromRules).toBeDefined()
|
|
||||||
expect(reportingConfig.excludeFilesFromRules['todocs-placeholder']).toBeDefined()
|
|
||||||
expect(Array.isArray(reportingConfig.excludeFilesFromRules['todocs-placeholder'])).toBe(true)
|
|
||||||
expect(
|
|
||||||
reportingConfig.excludeFilesFromRules['todocs-placeholder'].includes(
|
|
||||||
'content/contributing/collaborating-on-github-docs/using-the-todocs-placeholder-to-leave-notes.md',
|
|
||||||
),
|
|
||||||
).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('shared shouldIncludeResult function', () => {
|
|
||||||
test('excludes TODOCS placeholder errors for specific file', () => {
|
|
||||||
const flaw = {
|
|
||||||
severity: 'error',
|
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'],
|
ruleNames: ['search-replace'],
|
||||||
errorDetail: 'todocs-placeholder: Catch occurrences of TODOCS placeholder.',
|
errorDetail: 'todocs-placeholder: Catch occurrences of TODOCS placeholder.',
|
||||||
}
|
}
|
||||||
|
|
||||||
const excludedFilePath =
|
// Should extract 'todocs-placeholder' as a rule name and check against includeRules
|
||||||
'content/contributing/collaborating-on-github-docs/using-the-todocs-placeholder-to-leave-notes.md'
|
// This will depend on your actual includeRules configuration
|
||||||
const regularFilePath = 'content/some-other-article.md'
|
const result = shouldIncludeInReport(searchReplaceFlaw)
|
||||||
|
expect(typeof result).toBe('boolean')
|
||||||
// Should be excluded for the specific documentation file
|
|
||||||
expect(shouldIncludeResult(flaw, excludedFilePath)).toBe(false)
|
|
||||||
|
|
||||||
// Should be included for other files
|
|
||||||
expect(shouldIncludeResult(flaw, regularFilePath)).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('includes flaws by default when no exclusions apply', () => {
|
test('handles missing errorDetail gracefully for search-replace', () => {
|
||||||
const flaw = {
|
const searchReplaceFlawNoDetail = {
|
||||||
severity: 'error',
|
severity: 'warning',
|
||||||
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',
|
|
||||||
ruleNames: ['search-replace'],
|
ruleNames: ['search-replace'],
|
||||||
// no errorDetail
|
// 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',
|
description: 'Headers must have content below them',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
names: ['GHD030', 'code-fence-line-length'],
|
names: ['GHD001', 'link-punctuation'],
|
||||||
description: 'Code fence content should not exceed line length limit',
|
description: 'Internal link titles must not contain punctuation',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
allConfig: {},
|
allConfig: {},
|
||||||
@@ -41,12 +41,12 @@ describe('shouldIncludeRule', () => {
|
|||||||
|
|
||||||
test('includes custom rule by short code', () => {
|
test('includes custom rule by short code', () => {
|
||||||
expect(shouldIncludeRule('header-content-requirement', ['GHD053'])).toBe(true)
|
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', () => {
|
test('excludes rule not in list', () => {
|
||||||
expect(shouldIncludeRule('heading-increment', ['MD002'])).toBe(false)
|
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', () => {
|
test('handles multiple rules', () => {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { execSync } from 'child_process'
|
|
||||||
|
|
||||||
import { renderLiquid } from '@/content-render/liquid/index'
|
import { renderLiquid } from '@/content-render/liquid/index'
|
||||||
import shortVersionsMiddleware from '@/versions/middleware/short-versions'
|
import shortVersionsMiddleware from '@/versions/middleware/short-versions'
|
||||||
@@ -83,7 +82,4 @@ for (const page of pages) {
|
|||||||
console.log(err)
|
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}`)
|
console.log(`Finished - content is available in: ${contentCopilotDir}`)
|
||||||
|
|||||||
Reference in New Issue
Block a user