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

Migrate 10 JavaScript files to TypeScript (#57971)

This commit is contained in:
Kevin Heis
2025-10-14 13:50:09 -07:00
committed by GitHub
parent 0cf4aabd12
commit a79730e268
10 changed files with 238 additions and 112 deletions

View File

@@ -1,9 +1,27 @@
// @ts-ignore - no types available for markdownlint-rule-helpers
import { addError } from 'markdownlint-rule-helpers' import { addError } from 'markdownlint-rule-helpers'
import { getFrontmatter } from '@/content-linter/lib/helpers/utils' 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 // Strip liquid tags from text for character counting purposes
function stripLiquidTags(text) { function stripLiquidTags(text: unknown): string {
if (typeof text !== 'string') return text if (typeof text !== 'string') return text as string
// Remove both {% %} and {{ }} liquid tags // Remove both {% %} and {{ }} liquid tags
return text.replace(/\{%.*?%\}/g, '').replace(/\{\{.*?\}\}/g, '') return text.replace(/\{%.*?%\}/g, '').replace(/\{\{.*?\}\}/g, '')
} }
@@ -13,15 +31,15 @@ export const frontmatterValidation = {
description: description:
'Frontmatter properties must meet character limits and required property requirements', 'Frontmatter properties must meet character limits and required property requirements',
tags: ['frontmatter', 'character-limits', 'required-properties'], tags: ['frontmatter', 'character-limits', 'required-properties'],
function: (params, onError) => { function: (params: RuleParams, onError: RuleErrorCallback) => {
const fm = getFrontmatter(params.lines) const fm = getFrontmatter(params.lines as string[])
if (!fm) return if (!fm) return
// Detect content type based on frontmatter properties and file path // Detect content type based on frontmatter properties and file path
const contentType = detectContentType(fm, params.name) const contentType = detectContentType(fm, params.name)
// Define character limits and requirements for different content types // Define character limits and requirements for different content types
const contentRules = { const contentRules: Record<string, ContentRules> = {
category: { category: {
title: { max: 70, recommended: 67 }, title: { max: 70, recommended: 67 },
shortTitle: { max: 30, recommended: 27 }, shortTitle: { max: 30, recommended: 27 },
@@ -42,7 +60,7 @@ export const frontmatterValidation = {
}, },
} }
const rules = contentRules[contentType] const rules = contentType ? contentRules[contentType] : null
if (!rules) return if (!rules) return
// Check required properties // Check required properties
@@ -61,14 +79,21 @@ export const frontmatterValidation = {
// Check title length // Check title length
if (fm.title) { if (fm.title) {
validatePropertyLength(onError, params.lines, 'title', fm.title, rules.title, 'Title') validatePropertyLength(
onError,
params.lines as string[],
'title',
fm.title,
rules.title,
'Title',
)
} }
// Check shortTitle length // Check shortTitle length
if (fm.shortTitle) { if (fm.shortTitle) {
validatePropertyLength( validatePropertyLength(
onError, onError,
params.lines, params.lines as string[],
'shortTitle', 'shortTitle',
fm.shortTitle, fm.shortTitle,
rules.shortTitle, rules.shortTitle,
@@ -78,17 +103,24 @@ export const frontmatterValidation = {
// Check intro length if it exists // Check intro length if it exists
if (fm.intro && rules.intro) { if (fm.intro && rules.intro) {
validatePropertyLength(onError, params.lines, 'intro', fm.intro, rules.intro, '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 // Cross-property validation: if title is longer than shortTitle limit, shortTitle must exist
const strippedTitle = stripLiquidTags(fm.title) const strippedTitle = stripLiquidTags(fm.title)
if (fm.title && strippedTitle.length > rules.shortTitle.max && !fm.shortTitle) { if (fm.title && (strippedTitle as string).length > rules.shortTitle.max && !fm.shortTitle) {
const titleLine = findPropertyLine(params.lines, 'title') const titleLine = findPropertyLine(params.lines as string[], 'title')
addError( addError(
onError, onError,
titleLine, titleLine,
`Title is ${strippedTitle.length} characters, which exceeds the shortTitle limit of ${rules.shortTitle.max} characters. A shortTitle must be provided.`, `Title is ${(strippedTitle as string).length} characters, which exceeds the shortTitle limit of ${rules.shortTitle.max} characters. A shortTitle must be provided.`,
fm.title, fm.title,
null, null,
null, null,
@@ -98,10 +130,10 @@ export const frontmatterValidation = {
// Special validation for articles: should have at least one topic // Special validation for articles: should have at least one topic
if (contentType === 'article' && fm.topics) { if (contentType === 'article' && fm.topics) {
if (!Array.isArray(fm.topics)) { if (!Array.isArray(fm.topics)) {
const topicsLine = findPropertyLine(params.lines, 'topics') const topicsLine = findPropertyLine(params.lines as string[], 'topics')
addError(onError, topicsLine, 'Topics must be an array', String(fm.topics), null, null) addError(onError, topicsLine, 'Topics must be an array', String(fm.topics), null, null)
} else if (fm.topics.length === 0) { } else if (fm.topics.length === 0) {
const topicsLine = findPropertyLine(params.lines, 'topics') const topicsLine = findPropertyLine(params.lines as string[], 'topics')
addError( addError(
onError, onError,
topicsLine, topicsLine,
@@ -115,9 +147,16 @@ export const frontmatterValidation = {
}, },
} }
function validatePropertyLength(onError, lines, propertyName, propertyValue, limits, displayName) { function validatePropertyLength(
onError: RuleErrorCallback,
lines: string[],
propertyName: string,
propertyValue: string,
limits: PropertyLimits,
displayName: string,
): void {
const strippedValue = stripLiquidTags(propertyValue) const strippedValue = stripLiquidTags(propertyValue)
const propertyLength = strippedValue.length const propertyLength = (strippedValue as string).length
const propertyLine = findPropertyLine(lines, propertyName) const propertyLine = findPropertyLine(lines, propertyName)
// Only report the most severe error - maximum takes precedence over recommended // Only report the most severe error - maximum takes precedence over recommended
@@ -142,7 +181,8 @@ function validatePropertyLength(onError, lines, propertyName, propertyValue, lim
} }
} }
function detectContentType(frontmatter, filePath) { // 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 // Only apply validation to markdown files
if (!filePath || !filePath.endsWith('.md')) { if (!filePath || !filePath.endsWith('.md')) {
return null return null
@@ -168,7 +208,7 @@ function detectContentType(frontmatter, filePath) {
return 'article' return 'article'
} }
function findPropertyLine(lines, property) { function findPropertyLine(lines: string[], property: string): number {
const line = lines.find((line) => line.trim().startsWith(`${property}:`)) const line = lines.find((line) => line.trim().startsWith(`${property}:`))
return line ? lines.indexOf(line) + 1 : 1 return line ? lines.indexOf(line) + 1 : 1
} }

View File

@@ -1,19 +1,36 @@
import chalk from 'chalk' import chalk from 'chalk'
function isNumber(value) { interface LintResult {
ruleDescription: string
ruleNames: string[]
lineNumber: number
columnNumber?: number
severity: string
errorDetail?: string
errorContext?: string
context?: string
fixable?: boolean
}
type LintResults = Record<string, LintResult[]>
function isNumber(value: unknown): value is number {
return typeof value === 'number' && !isNaN(value) return typeof value === 'number' && !isNaN(value)
} }
function shorten(text, length = 70) { function shorten(text: string, length = 70): string {
if (text.length <= length) return text if (text.length <= length) return text
return `${text.slice(0, length - 3)}` return `${text.slice(0, length - 3)}`
} }
export function prettyPrintResults(results, { fixed = false } = {}) { export function prettyPrintResults(
results: LintResults,
{ fixed = false }: { fixed?: boolean } = {},
): void {
const PREFIX_PADDING = ' '.repeat(4) const PREFIX_PADDING = ' '.repeat(4)
const columnPadding = 'Description'.length // The longest column header word const columnPadding = 'Description'.length // The longest column header word
function label(text, padding = columnPadding) { function label(text: string, padding = columnPadding): string {
if (padding < text.length) throw new Error('Padding must be greater than text length') if (padding < text.length) throw new Error('Padding must be greater than text length')
return `${PREFIX_PADDING}${chalk.dim(text.padEnd(padding))}` return `${PREFIX_PADDING}${chalk.dim(text.padEnd(padding))}`
} }
@@ -114,7 +131,8 @@ export function prettyPrintResults(results, { fixed = false } = {}) {
} }
} }
function chalkFunColors(text) { function chalkFunColors(text: string): string {
// Valid chalk color method names for terminal output
const colors = [ const colors = [
'red', 'red',
'yellow', 'yellow',
@@ -126,19 +144,21 @@ function chalkFunColors(text) {
'greenBright', 'greenBright',
'magentaBright', 'magentaBright',
'cyanBright', 'cyanBright',
].sort(() => Math.random() - 0.5) ] as const
const shuffledColors = [...colors].sort(() => Math.random() - 0.5)
let colorIndex = 0 let colorIndex = 0
return text return text
.split('') .split('')
.map((char) => { .map((char) => {
const color = colors[colorIndex] const color = shuffledColors[colorIndex]
colorIndex = (colorIndex + 1) % colors.length colorIndex = (colorIndex + 1) % shuffledColors.length
return chalk[color](char) // Chalk's TypeScript types don't support dynamic property access, but these are valid color methods
return (chalk as any)[color](char)
}) })
.join('') .join('')
} }
function indentWrappedString(str, startingIndent) { function indentWrappedString(str: string, startingIndent: number): string {
const NEW_LINE_PADDING = ' '.repeat(16) const NEW_LINE_PADDING = ' '.repeat(16)
const width = process.stdout.columns || 80 // Use terminal width, default to 80 if not available const width = process.stdout.columns || 80 // Use terminal width, default to 80 if not available
let indentedString = '' let indentedString = ''

View File

@@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest'
import cheerio from 'cheerio' import cheerio from 'cheerio'
import { renderContent } from '@/content-render/index' import { renderContent } from '@/content-render/index'
import type { Context } from '@/types'
const example = ` const example = `
\`\`\`yaml annotate \`\`\`yaml annotate
@@ -131,7 +132,7 @@ on: [push]
` `
// Create a mock context with pages for AUTOTITLE resolution // Create a mock context with pages for AUTOTITLE resolution
const mockPages = { const mockPages: Record<string, { href: string; rawTitle: string }> = {
'/get-started/start-your-journey/hello-world': { '/get-started/start-your-journey/hello-world': {
href: '/get-started/start-your-journey/hello-world', href: '/get-started/start-your-journey/hello-world',
rawTitle: 'Hello World', rawTitle: 'Hello World',
@@ -147,7 +148,8 @@ on: [push]
currentVersion: 'free-pro-team@latest', currentVersion: 'free-pro-team@latest',
pages: mockPages, pages: mockPages,
redirects: {}, redirects: {},
} // Mock test object doesn't need all Context properties, using 'as unknown as' to bypass strict type checking
} as unknown as Context
const res = await renderContent(example, mockContext) const res = await renderContent(example, mockContext)
const $ = cheerio.load(res) const $ = cheerio.load(res)

View File

@@ -1,16 +1,21 @@
import path from 'path' import path from 'path'
import fs from 'fs/promises' import fs from 'fs/promises'
import Page from './page' import PageClass from './page'
import type { UnversionedTree, Page } from '@/types'
export default async function createTree(originalPath, rootPath, previousTree) { export default async function createTree(
originalPath: string,
rootPath?: string,
previousTree?: UnversionedTree,
): Promise<UnversionedTree | undefined> {
const basePath = rootPath || originalPath const basePath = rootPath || originalPath
// On recursive runs, this is processing page.children items in `/<link>` format. // On recursive runs, this is processing page.children items in `/<link>` format.
// If the path exists as is, assume this is a directory with a child index.md. // If the path exists as is, assume this is a directory with a child index.md.
// Otherwise, assume it's a child .md file and add `.md` to the path. // Otherwise, assume it's a child .md file and add `.md` to the path.
let filepath let filepath: string
let mtime let mtime: number
// This kills two birds with one stone. We (attempt to) read it as a file, // This kills two birds with one stone. We (attempt to) read it as a file,
// to find out if it's a directory or a file and whence we know that // to find out if it's a directory or a file and whence we know that
// we also collect it's modification time. // we also collect it's modification time.
@@ -18,7 +23,7 @@ export default async function createTree(originalPath, rootPath, previousTree) {
filepath = `${originalPath}.md` filepath = `${originalPath}.md`
mtime = await getMtime(filepath) mtime = await getMtime(filepath)
} catch (error) { } catch (error) {
if (error.code !== 'ENOENT') { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error throw error
} }
filepath = `${originalPath}/index.md` filepath = `${originalPath}/index.md`
@@ -30,7 +35,7 @@ export default async function createTree(originalPath, rootPath, previousTree) {
try { try {
mtime = await getMtime(filepath) mtime = await getMtime(filepath)
} catch (error) { } catch (error) {
if (error.code !== 'ENOENT') { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error throw error
} }
// Throw an error if we can't find a content file associated with the children: entry. // Throw an error if we can't find a content file associated with the children: entry.
@@ -51,7 +56,7 @@ export default async function createTree(originalPath, rootPath, previousTree) {
// Reading in a file from disk is slow and best avoided if we can be // Reading in a file from disk is slow and best avoided if we can be
// certain it isn't necessary. If the previous tree is known and that // certain it isn't necessary. If the previous tree is known and that
// tree's page node's `mtime` hasn't changed, we can use that instead. // tree's page node's `mtime` hasn't changed, we can use that instead.
let page let page: Page
if (previousTree && previousTree.page.mtime === mtime) { if (previousTree && previousTree.page.mtime === mtime) {
// A save! We can use the same exact Page instance from the previous // A save! We can use the same exact Page instance from the previous
// tree because the assumption is that since the `.md` file it was // tree because the assumption is that since the `.md` file it was
@@ -61,20 +66,22 @@ export default async function createTree(originalPath, rootPath, previousTree) {
} else { } else {
// Either the previous tree doesn't exist yet or the modification time // Either the previous tree doesn't exist yet or the modification time
// of the file on disk has changed. // of the file on disk has changed.
page = await Page.init({ const newPage = await PageClass.init({
basePath, basePath,
relativePath, relativePath,
languageCode: 'en', languageCode: 'en',
mtime, mtime,
}) // PageInitOptions doesn't include mtime in its type definition, but PageReadResult uses `& any`
} // which allows additional properties to be passed through to the Page constructor
} as any)
if (!page) { if (!newPage) {
throw Error(`Cannot initialize page for ${filepath}`) throw Error(`Cannot initialize page for ${filepath}`)
}
page = newPage as unknown as Page
} }
// Create the root tree object on the first run, and create children recursively. // Create the root tree object on the first run, and create children recursively.
const item = { const item: UnversionedTree = {
page, page,
// This is only here for the sake of reloading the tree later which // This is only here for the sake of reloading the tree later which
// only happens in development mode. // only happens in development mode.
@@ -86,18 +93,23 @@ export default async function createTree(originalPath, rootPath, previousTree) {
// this value now will be different from what it was before. // this value now will be different from what it was before.
// It's not enough to rely on *length* of the array before and after // It's not enough to rely on *length* of the array before and after
// because the change could have been to remove one and add another. // because the change could have been to remove one and add another.
children: page.children, // Page class has dynamic frontmatter properties like 'children' that aren't in the type definition
children: (page as any).children || [],
childPages: [],
} }
// Process frontmatter children recursively. // Process frontmatter children recursively.
if (item.page.children) { // Page class has dynamic frontmatter properties like 'children' that aren't in the type definition
assertUniqueChildren(item.page) if ((page as any).children) {
assertUniqueChildren(page as any)
item.childPages = ( item.childPages = (
await Promise.all( await Promise.all(
item.page.children.map(async (child, i) => { // Page class has dynamic frontmatter properties like 'children' that aren't in the type definition
let childPreviousTree ((page as any).children as string[]).map(async (child: string, i: number) => {
let childPreviousTree: UnversionedTree | undefined
if (previousTree && previousTree.childPages) { if (previousTree && previousTree.childPages) {
if (equalArray(item.page.children, previousTree.children)) { // Page class has dynamic frontmatter properties like 'children' that aren't in the type definition
if (equalArray((page as any).children, previousTree.children)) {
// We can only safely rely on picking the same "n'th" item // We can only safely rely on picking the same "n'th" item
// from the array if we're confident the names are the same // from the array if we're confident the names are the same
// as they were before. // as they were before.
@@ -119,22 +131,25 @@ export default async function createTree(originalPath, rootPath, previousTree) {
// (early exit instead of returning a tree). So let's // (early exit instead of returning a tree). So let's
// mutate the `page.children` so we can benefit from the // mutate the `page.children` so we can benefit from the
// ability to reload the site tree on consecutive requests. // ability to reload the site tree on consecutive requests.
item.page.children = item.page.children.filter((c) => c !== child) // Page class has dynamic frontmatter properties like 'children' that aren't in the type definition
;(page as any).children = ((page as any).children as string[]).filter(
(c: string) => c !== child,
)
} }
return subTree return subTree
}), }),
) )
).filter(Boolean) ).filter((tree): tree is UnversionedTree => tree !== undefined)
} }
return item return item
} }
function equalArray(arr1, arr2) { function equalArray(arr1: string[], arr2: string[]): boolean {
return arr1.length === arr2.length && arr1.every((value, i) => value === arr2[i]) return arr1.length === arr2.length && arr1.every((value, i) => value === arr2[i])
} }
async function getMtime(filePath) { async function getMtime(filePath: string): Promise<number> {
// Use mtimeMs, which is a regular floating point number, instead of the // Use mtimeMs, which is a regular floating point number, instead of the
// mtime which is a Date based on that same number. // mtime which is a Date based on that same number.
// Otherwise, if we use the Date instances, we have to compare // Otherwise, if we use the Date instances, we have to compare
@@ -150,10 +165,11 @@ async function getMtime(filePath) {
return Math.round(mtimeMs) return Math.round(mtimeMs)
} }
function assertUniqueChildren(page) { // Page class has dynamic frontmatter properties that aren't in the type definition
function assertUniqueChildren(page: any): void {
if (page.children.length !== new Set(page.children).size) { if (page.children.length !== new Set(page.children).size) {
const count = {} const count: Record<string, number> = {}
page.children.forEach((entry) => (count[entry] = 1 + (count[entry] || 0))) page.children.forEach((entry: string) => (count[entry] = 1 + (count[entry] || 0)))
let msg = `${page.relativePath} has duplicates in the 'children' key.` let msg = `${page.relativePath} has duplicates in the 'children' key.`
for (const [entry, times] of Object.entries(count)) { for (const [entry, times] of Object.entries(count)) {
if (times > 1) msg += ` '${entry}' is repeated ${times} times. ` if (times > 1) msg += ` '${entry}' is repeated ${times} times. `

View File

@@ -10,6 +10,7 @@ import libLanguages from '@/languages/lib/languages'
import { liquid } from '@/content-render/index' import { liquid } from '@/content-render/index'
import patterns from '@/frame/lib/patterns' import patterns from '@/frame/lib/patterns'
import removeFPTFromPath from '@/versions/lib/remove-fpt-from-path' import removeFPTFromPath from '@/versions/lib/remove-fpt-from-path'
import type { Page } from '@/types'
const languageCodes = Object.keys(libLanguages) const languageCodes = Object.keys(libLanguages)
const slugger = new GithubSlugger() const slugger = new GithubSlugger()
@@ -17,7 +18,7 @@ const slugger = new GithubSlugger()
describe('pages module', () => { describe('pages module', () => {
vi.setConfig({ testTimeout: 60 * 1000 }) vi.setConfig({ testTimeout: 60 * 1000 })
let pages let pages: Page[]
beforeAll(async () => { beforeAll(async () => {
pages = await loadPages() pages = await loadPages()
@@ -50,29 +51,30 @@ describe('pages module', () => {
const englishPages = chain(pages) const englishPages = chain(pages)
.filter(['languageCode', 'en']) .filter(['languageCode', 'en'])
.filter('redirect_from') .filter('redirect_from')
.map((pages) => pick(pages, ['redirect_from', 'applicableVersions', 'fullPath'])) .map((page) => pick(page, ['redirect_from', 'applicableVersions', 'fullPath']))
.value() .value()
// Map from redirect path to Set of file paths // Map from redirect path to Set of file paths
const redirectToFiles = new Map() const redirectToFiles = new Map<string, Set<string>>()
const versionedRedirects = [] const versionedRedirects: Array<{ path: string; file: string }> = []
englishPages.forEach((page) => { // Page objects have dynamic properties from chain/lodash that aren't fully typed
page.redirect_from.forEach((redirect) => { englishPages.forEach((page: any) => {
page.applicableVersions.forEach((version) => { page.redirect_from.forEach((redirect: string) => {
page.applicableVersions.forEach((version: string) => {
const versioned = removeFPTFromPath(path.posix.join('/', version, redirect)) const versioned = removeFPTFromPath(path.posix.join('/', version, redirect))
versionedRedirects.push({ path: versioned, file: page.fullPath }) versionedRedirects.push({ path: versioned, file: page.fullPath })
if (!redirectToFiles.has(versioned)) { if (!redirectToFiles.has(versioned)) {
redirectToFiles.set(versioned, new Set()) redirectToFiles.set(versioned, new Set<string>())
} }
redirectToFiles.get(versioned).add(page.fullPath) redirectToFiles.get(versioned)!.add(page.fullPath)
}) })
}) })
}) })
// Only consider as duplicate if more than one unique file defines the same redirect // Only consider as duplicate if more than one unique file defines the same redirect
const duplicates = Array.from(redirectToFiles.entries()) const duplicates = Array.from(redirectToFiles.entries())
.filter(([_, files]) => files.size > 1) .filter(([, files]) => files.size > 1)
.map(([path]) => path) .map(([path]) => path)
// Build a detailed message with sources for each duplicate // Build a detailed message with sources for each duplicate
@@ -96,7 +98,8 @@ describe('pages module', () => {
return ( return (
page.languageCode === 'en' && // only check English page.languageCode === 'en' && // only check English
!page.relativePath.includes('index.md') && // ignore TOCs !page.relativePath.includes('index.md') && // ignore TOCs
!page.allowTitleToDifferFromFilename && // ignore docs with override // Page class has dynamic frontmatter properties like 'allowTitleToDifferFromFilename' not in type definition
!(page as any).allowTitleToDifferFromFilename && // ignore docs with override
slugger.slug(decode(page.title)) !== path.basename(page.relativePath, '.md') && slugger.slug(decode(page.title)) !== path.basename(page.relativePath, '.md') &&
slugger.slug(decode(page.shortTitle || '')) !== path.basename(page.relativePath, '.md') slugger.slug(decode(page.shortTitle || '')) !== path.basename(page.relativePath, '.md')
) )
@@ -127,7 +130,8 @@ describe('pages module', () => {
test('every page has valid frontmatter', async () => { test('every page has valid frontmatter', async () => {
const frontmatterErrors = chain(pages) const frontmatterErrors = chain(pages)
// .filter(page => page.languageCode === 'en') // .filter(page => page.languageCode === 'en')
.map((page) => page.frontmatterErrors) // Page class has dynamic error properties like 'frontmatterErrors' not in type definition
.map((page) => (page as any).frontmatterErrors)
.filter(Boolean) .filter(Boolean)
.flatten() .flatten()
.value() .value()
@@ -141,17 +145,18 @@ describe('pages module', () => {
}) })
test('every page has valid Liquid templating', async () => { test('every page has valid Liquid templating', async () => {
const liquidErrors = [] const liquidErrors: Array<{ filename: string; error: string }> = []
for (const page of pages) { for (const page of pages) {
const markdown = page.raw // Page class has dynamic properties like 'raw' markdown not in type definition
const markdown = (page as any).raw
if (!patterns.hasLiquid.test(markdown)) continue if (!patterns.hasLiquid.test(markdown)) continue
try { try {
await liquid.parse(markdown) await liquid.parse(markdown)
} catch (error) { } catch (error) {
liquidErrors.push({ liquidErrors.push({
filename: page.fullPath, filename: page.fullPath,
error: error.message, error: (error as Error).message,
}) })
} }
} }

View File

@@ -22,9 +22,9 @@ versions:
describe('frontmatter', () => { describe('frontmatter', () => {
test('parses frontmatter and content in a given string (no options required)', () => { test('parses frontmatter and content in a given string (no options required)', () => {
const { data, content, errors } = parse(fixture1) const { data, content, errors } = parse(fixture1)
expect(data.title).toBe('Hello, World') expect(data!.title).toBe('Hello, World')
expect(data.meaning_of_life).toBe(42) expect(data!.meaning_of_life).toBe(42)
expect(content.trim()).toBe('I am content.') expect(content!.trim()).toBe('I am content.')
expect(errors.length).toBe(0) expect(errors.length).toBe(0)
}) })
@@ -85,9 +85,9 @@ I am content.
} }
const { data, content, errors } = parse(fixture1, { schema }) const { data, content, errors } = parse(fixture1, { schema })
expect(data.title).toBe('Hello, World') expect(data!.title).toBe('Hello, World')
expect(data.meaning_of_life).toBe(42) expect(data!.meaning_of_life).toBe(42)
expect(content.trim()).toBe('I am content.') expect(content!.trim()).toBe('I am content.')
expect(errors.length).toBe(0) expect(errors.length).toBe(0)
}) })
@@ -102,9 +102,9 @@ I am content.
} }
const { data, content, errors } = parse(fixture1, { schema }) const { data, content, errors } = parse(fixture1, { schema })
expect(data.title).toBe('Hello, World') expect(data!.title).toBe('Hello, World')
expect(data.meaning_of_life).toBe(42) expect(data!.meaning_of_life).toBe(42)
expect(content.trim()).toBe('I am content.') expect(content!.trim()).toBe('I am content.')
expect(errors.length).toBe(1) expect(errors.length).toBe(1)
const expectedError = { const expectedError = {
instancePath: '/meaning_of_life', instancePath: '/meaning_of_life',
@@ -121,7 +121,10 @@ I am content.
test('creates errors if versions frontmatter does not match semver format', () => { test('creates errors if versions frontmatter does not match semver format', () => {
const schema = { type: 'object', required: ['versions'], properties: {} } const schema = { type: 'object', required: ['versions'], properties: {} }
schema.properties.versions = Object.assign({}, frontmatterSchema.properties.versions) ;(schema.properties as any).versions = Object.assign(
{},
(frontmatterSchema.properties as any).versions,
)
const { errors } = parse(fixture2, { schema }) const { errors } = parse(fixture2, { schema })
const expectedError = { const expectedError = {

View File

@@ -1,6 +1,8 @@
// @ts-ignore - no types available
import httpStatusCodes from 'http-status-code' import httpStatusCodes from 'http-status-code'
import { get, isPlainObject } from 'lodash-es' import { get, isPlainObject } from 'lodash-es'
import { parseTemplate } from 'url-template' import { parseTemplate } from 'url-template'
// @ts-ignore - no types available
import mergeAllOf from 'json-schema-merge-allof' import mergeAllOf from 'json-schema-merge-allof'
import { renderContent } from './render-content' import { renderContent } from './render-content'
@@ -10,19 +12,41 @@ import { validateJson } from '@/tests/lib/validate-json-schema'
import { getBodyParams } from './get-body-params' import { getBodyParams } from './get-body-params'
export default class Operation { export default class Operation {
#operation // OpenAPI operation object - schema is dynamic and varies by endpoint
constructor(verb, requestPath, operation, globalServers) { #operation: any
serverUrl: string
verb: string
requestPath: string
title: string
category: string
subcategory: string
// OpenAPI parameters vary by endpoint, no fixed schema available
parameters: any[]
// Body parameters are dynamically generated from OpenAPI schema
bodyParameters: any[]
descriptionHTML?: string
// Code examples structure varies by language and endpoint
codeExamples?: any[]
// Status codes are dynamically generated from OpenAPI responses
statusCodes?: any[]
previews?: any[]
// Programmatic access data structure varies by operation
progAccess?: any
// OpenAPI operation and globalServers objects have dynamic schema
constructor(verb: string, requestPath: string, operation: any, globalServers?: any[]) {
this.#operation = operation this.#operation = operation
// The global server object sets metadata including the base url for // The global server object sets metadata including the base url for
// all operations in a version. Individual operations can override // all operations in a version. Individual operations can override
// the global server url at the operation level. // the global server url at the operation level.
this.serverUrl = operation.servers ? operation.servers[0].url : globalServers[0].url this.serverUrl = operation.servers ? operation.servers[0].url : globalServers?.[0]?.url
const serverVariables = operation.servers const serverVariables = operation.servers
? operation.servers[0].variables ? operation.servers[0].variables
: globalServers[0].variables : globalServers?.[0]?.variables
if (serverVariables) { if (serverVariables) {
const templateVariables = {} // Template variables structure comes from OpenAPI server variables
const templateVariables: Record<string, any> = {}
Object.keys(serverVariables).forEach( Object.keys(serverVariables).forEach(
(key) => (templateVariables[key] = serverVariables[key].default), (key) => (templateVariables[key] = serverVariables[key].default),
) )
@@ -47,9 +71,10 @@ export default class Operation {
return this return this
} }
async process(progAccessData) { // Programmatic access data structure varies by operation and is not strongly typed
async process(progAccessData: any): Promise<void> {
await Promise.all([ await Promise.all([
this.codeExamples(), this.renderCodeExamples(),
this.renderDescription(), this.renderDescription(),
this.renderStatusCodes(), this.renderStatusCodes(),
this.renderParameterDescriptions(), this.renderParameterDescriptions(),
@@ -65,7 +90,7 @@ export default class Operation {
} }
} }
async renderDescription() { async renderDescription(): Promise<this> {
try { try {
this.descriptionHTML = await renderContent(this.#operation.description) this.descriptionHTML = await renderContent(this.#operation.description)
return this return this
@@ -75,25 +100,27 @@ export default class Operation {
} }
} }
async codeExamples() { async renderCodeExamples(): Promise<any[]> {
this.codeExamples = await getCodeSamples(this.#operation) const codeExamples = await getCodeSamples(this.#operation)
try { try {
return await Promise.all( this.codeExamples = await Promise.all(
this.codeExamples.map(async (codeExample) => { // Code example structure varies by endpoint and language
codeExamples.map(async (codeExample: any) => {
codeExample.response.description = await renderContent(codeExample.response.description) codeExample.response.description = await renderContent(codeExample.response.description)
return codeExample return codeExample
}), }),
) )
return this.codeExamples
} catch (error) { } catch (error) {
console.error(error) console.error(error)
throw new Error(`Error generating code examples for ${this.verb} ${this.requestPath}`) throw new Error(`Error generating code examples for ${this.verb} ${this.requestPath}`)
} }
} }
async renderStatusCodes() { async renderStatusCodes(): Promise<void> {
const responses = this.#operation.responses const responses = this.#operation.responses
const responseKeys = Object.keys(responses) const responseKeys = Object.keys(responses)
if (responseKeys.length === 0) return [] if (responseKeys.length === 0) return
try { try {
this.statusCodes = await Promise.all( this.statusCodes = await Promise.all(
@@ -121,7 +148,7 @@ export default class Operation {
} }
} }
async renderParameterDescriptions() { async renderParameterDescriptions(): Promise<any[]> {
try { try {
return Promise.all( return Promise.all(
this.parameters.map(async (param) => { this.parameters.map(async (param) => {
@@ -135,8 +162,8 @@ export default class Operation {
} }
} }
async renderBodyParameterDescriptions() { async renderBodyParameterDescriptions(): Promise<void> {
if (!this.#operation.requestBody) return [] if (!this.#operation.requestBody) return
// There is currently only one operation with more than one content type // There is currently only one operation with more than one content type
// and the request body parameter types are the same for both. // and the request body parameter types are the same for both.
@@ -161,11 +188,12 @@ export default class Operation {
} }
} }
async renderPreviewNotes() { async renderPreviewNotes(): Promise<void> {
const previews = get(this.#operation, 'x-github.previews', []) const previews = get(this.#operation, 'x-github.previews', [])
try { try {
this.previews = await Promise.all( this.previews = await Promise.all(
previews.map(async (preview) => { // Preview note structure from OpenAPI x-github extension is dynamic
previews.map(async (preview: any) => {
const note = preview.note const note = preview.note
// remove extra leading and trailing newlines // remove extra leading and trailing newlines
.replace(/```\n\n\n/gm, '```\n') .replace(/```\n\n\n/gm, '```\n')
@@ -186,7 +214,8 @@ export default class Operation {
} }
} }
programmaticAccess(progAccessData) { // Programmatic access data structure varies by operation and is not strongly typed
programmaticAccess(progAccessData: any): void {
this.progAccess = progAccessData[this.#operation.operationId] this.progAccess = progAccessData[this.#operation.operationId]
} }
} }

View File

@@ -1,6 +1,8 @@
import { describe, expect, test } from 'vitest' import { describe, expect, test } from 'vitest'
import { getGHExample, getShellExample } from '../components/get-rest-code-samples' import { getGHExample, getShellExample } from '../components/get-rest-code-samples'
import type { CodeSample, Operation } from '@/rest/components/types'
import type { VersionItem } from '@/frame/components/context/MainContext'
describe('CLI examples generation', () => { describe('CLI examples generation', () => {
const mockOperation = { const mockOperation = {
@@ -9,14 +11,16 @@ describe('CLI examples generation', () => {
serverUrl: 'https://api.github.com', serverUrl: 'https://api.github.com',
subcategory: 'code-scanning', subcategory: 'code-scanning',
parameters: [], parameters: [],
} // Partial mock object for testing - 'as unknown as' bypasses strict type checking for missing properties
} as unknown as Operation
const mockVersions = { const mockVersions = {
'free-pro-team@latest': { 'free-pro-team@latest': {
apiVersions: ['2022-11-28'], apiVersions: ['2022-11-28'],
latestApiVersion: '2022-11-28', latestApiVersion: '2022-11-28',
}, },
} // Partial mock object for testing - 'as unknown as' bypasses strict type checking for missing properties
} as unknown as Record<string, VersionItem>
test('GitHub CLI example properly escapes contractions in string values', () => { test('GitHub CLI example properly escapes contractions in string values', () => {
const codeSample = { const codeSample = {
@@ -33,7 +37,8 @@ describe('CLI examples generation', () => {
"This alert is not actually correct, because there's a sanitizer included in the library.", "This alert is not actually correct, because there's a sanitizer included in the library.",
}, },
}, },
} // Partial mock object for testing - 'as unknown as' bypasses strict type checking for missing properties
} as unknown as CodeSample
const result = getGHExample(mockOperation, codeSample, 'free-pro-team@latest', mockVersions) const result = getGHExample(mockOperation, codeSample, 'free-pro-team@latest', mockVersions)
@@ -61,7 +66,8 @@ describe('CLI examples generation', () => {
}, },
contentType: 'application/json', contentType: 'application/json',
}, },
} // Partial mock object for testing - 'as unknown as' bypasses strict type checking for missing properties
} as unknown as CodeSample
const result = getShellExample(mockOperation, codeSample, 'free-pro-team@latest', mockVersions) const result = getShellExample(mockOperation, codeSample, 'free-pro-team@latest', mockVersions)
@@ -83,7 +89,8 @@ describe('CLI examples generation', () => {
body: "It's not working because there's an issue and we can't fix it", body: "It's not working because there's an issue and we can't fix it",
}, },
}, },
} // Partial mock object for testing - 'as unknown as' bypasses strict type checking for missing properties
} as unknown as CodeSample
const mockSimpleOperation = { const mockSimpleOperation = {
verb: 'post', verb: 'post',
@@ -91,7 +98,8 @@ describe('CLI examples generation', () => {
serverUrl: 'https://api.github.com', serverUrl: 'https://api.github.com',
subcategory: 'issues', subcategory: 'issues',
parameters: [], parameters: [],
} // Partial mock object for testing - 'as unknown as' bypasses strict type checking for missing properties
} as unknown as Operation
const result = getGHExample( const result = getGHExample(
mockSimpleOperation, mockSimpleOperation,
@@ -121,7 +129,8 @@ describe('CLI examples generation', () => {
'This alert is not actually correct because there is a sanitizer included in the library.', 'This alert is not actually correct because there is a sanitizer included in the library.',
}, },
}, },
} // Partial mock object for testing - 'as unknown as' bypasses strict type checking for missing properties
} as unknown as CodeSample
const result = getGHExample(mockOperation, codeSample, 'free-pro-team@latest', mockVersions) const result = getGHExample(mockOperation, codeSample, 'free-pro-team@latest', mockVersions)
@@ -143,7 +152,8 @@ describe('CLI examples generation', () => {
}, },
contentType: 'application/x-www-form-urlencoded', contentType: 'application/x-www-form-urlencoded',
}, },
} // Partial mock object for testing - 'as unknown as' bypasses strict type checking for missing properties
} as unknown as CodeSample
const mockSimpleOperation = { const mockSimpleOperation = {
verb: 'post', verb: 'post',
@@ -151,7 +161,8 @@ describe('CLI examples generation', () => {
serverUrl: 'https://api.github.com', serverUrl: 'https://api.github.com',
subcategory: 'pulls', subcategory: 'pulls',
parameters: [], parameters: [],
} // Partial mock object for testing - 'as unknown as' bypasses strict type checking for missing properties
} as unknown as Operation
const result = getShellExample( const result = getShellExample(
mockSimpleOperation, mockSimpleOperation,

View File

@@ -145,7 +145,7 @@ describe('REST references docs', () => {
}) })
}) })
function formatErrors(differences) { function formatErrors(differences: Record<string, any>): string {
let errorMessage = 'There are differences in Categories/Subcategories in:\n' let errorMessage = 'There are differences in Categories/Subcategories in:\n'
for (const schema in differences) { for (const schema in differences) {
errorMessage += 'Version: ' + schema + '\n' errorMessage += 'Version: ' + schema + '\n'