import { addError, filterTokens } from 'markdownlint-rule-helpers' import matter from '@gr2m/gray-matter' import type { RuleParams, RuleErrorCallback, MarkdownToken } from '@/content-linter/types' // Adds an error object with details conditionally via the onError callback export function addFixErrorDetail( onError: RuleErrorCallback, lineNumber: number, expected: string, actual: string, // Using flexible type to accommodate different range formats from various linting rules range: [number, number] | number[] | null, // Using unknown for fixInfo as markdownlint-rule-helpers accepts various fix info structures fixInfo: unknown, ): void { addError(onError, lineNumber, `Expected: ${expected}`, ` Actual: ${actual}`, range, fixInfo) } export function forEachInlineChild( params: RuleParams, type: string, // Handler uses `any` for function parameter variance reasons. TypeScript's contravariance rules for function // parameters mean that a function accepting a specific type cannot be assigned to a parameter of type `unknown`. // Therefore, `unknown` cannot be used here, as different linting rules pass tokens with varying structures // beyond the base MarkdownToken interface, and some handlers are async. handler: (child: any, token?: any) => void | Promise, ): void { filterTokens(params, 'inline', (token: MarkdownToken) => { for (const child of token.children!.filter((c) => c.type === type)) { handler(child, token) } }) } export function getRange(line: string, content: string): [number, number] | null { if (content.length === 0) { // This function assumes that the content is something. If it's an // empty string it can never produce a valid range. throw new Error('invalid content (empty)') } const startColumnIndex = line.indexOf(content) return startColumnIndex !== -1 ? [startColumnIndex + 1, content.length] : null } export function isStringQuoted(text: string): boolean { // String starts with either a single or double quote // ends with either a single or double quote // and optionally ends with a question mark or exclamation point // because that punctuation can exist outside of the quoted string return /^['"].*['"][?!]?$/.test(text) } export function isStringPunctuated(text: string): boolean { // String ends with punctuation of either // . ? ! and optionally ends with single // or double quotes. This also allows // for single or double quotes before // the punctuation. return /^.*[.?!]['"]?$/.test(text) } export function doesStringEndWithPeriod(text: string): boolean { // String ends with punctuation of either // . ? ! and optionally ends with single // or double quotes. This also allows // for single or double quotes before // the punctuation. return /^.*\.['"]?$/.test(text) } export function quotePrecedesLinkOpen(text: string | undefined): boolean { if (!text) return false return text.endsWith('"') || text.endsWith("'") } // Filters a list of tokens by token type only when they match // a specific token type order. // For example, if a list of tokens contains: // // [ // { type: 'inline'}, // { type: 'list_item_close'}, // { type: 'list_item_open'}, // { type: 'paragraph_open'}, // { type: 'inline'}, // { type: 'paragraph_close'}, // ] // // And if the `tokenOrder` being looked for is: // // [ // 'list_item_open', // 'paragraph_open', // 'inline' // ] // // Then the return value would be the items that match that sequence: // Index 2-4: // [ // { type: 'inline'}, <-- Index 0 - NOT INCLUDED // { type: 'list_item_close'}, <-- Index 1 - NOT INCLUDED // { type: 'list_item_open'}, <-- Index 2 - INCLUDED // { type: 'paragraph_open'}, <-- Index 3 - INCLUDED // { type: 'inline'}, <-- Index 4 - INCLUDED // { type: 'paragraph_close'}, <-- Index 5 - NOT INCLUDED // ] // export function filterTokensByOrder( tokens: MarkdownToken[], tokenOrder: string[], ): MarkdownToken[] { const matches: MarkdownToken[] = [] // Get a list of token indexes that match the // first token (root) in the tokenOrder array const tokenRootIndexes: number[] = [] const firstTokenOrderType = tokenOrder[0] for (let index = 0; index < tokens.length; index++) { const token = tokens[index] if (token.type === firstTokenOrderType) { tokenRootIndexes.push(index) } } // Loop through each root token index and check if // the order matches the tokenOrder array for (const tokenRootIndex of tokenRootIndexes) { for (let i = 1; i < tokenOrder.length; i++) { if (tokens[tokenRootIndex + i].type !== tokenOrder[i]) { // This tokenRootIndex was a possible start, // but doesn't match the tokenOrder perfectly, so break out // of the inner loop before it reaches the end. break } if (i === tokenOrder.length - 1) { matches.push(...tokens.slice(tokenRootIndex, tokenRootIndex + i + 1)) } } } return matches } export const docsDomains = ['docs.github.com', 'help.github.com', 'developer.github.com'] // Lines is an array of strings read from a // Markdown file a split around new lines. // This is the format we get from Markdownlint. // Returns null if the lines do not contain // frontmatter properties. // Returns frontmatter as a Record with unknown values since YAML can contain various types export function getFrontmatter(lines: string[]): Record | null { const fmString = lines.join('\n') const { data } = matter(fmString) // If there is no frontmatter or the frontmatter contains // no keys, matter will return an empty object. if (Object.keys(data).length === 0) return null return data } export function getFrontmatterLines(lines: string[]): string[] { const indexStart = lines.indexOf('---') if (indexStart === -1) return [] const indexEnd = lines.indexOf('---', indexStart + 1) return lines.slice(indexStart, indexEnd + 1) }