Migrate 10 JavaScript files to TypeScript (#57971)
This commit is contained in:
@@ -1,9 +1,27 @@
|
||||
// @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) {
|
||||
if (typeof text !== 'string') return text
|
||||
function stripLiquidTags(text: unknown): string {
|
||||
if (typeof text !== 'string') return text as string
|
||||
// Remove both {% %} and {{ }} liquid tags
|
||||
return text.replace(/\{%.*?%\}/g, '').replace(/\{\{.*?\}\}/g, '')
|
||||
}
|
||||
@@ -13,15 +31,15 @@ export const frontmatterValidation = {
|
||||
description:
|
||||
'Frontmatter properties must meet character limits and required property requirements',
|
||||
tags: ['frontmatter', 'character-limits', 'required-properties'],
|
||||
function: (params, onError) => {
|
||||
const fm = getFrontmatter(params.lines)
|
||||
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 = {
|
||||
const contentRules: Record<string, ContentRules> = {
|
||||
category: {
|
||||
title: { max: 70, recommended: 67 },
|
||||
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
|
||||
|
||||
// Check required properties
|
||||
@@ -61,14 +79,21 @@ export const frontmatterValidation = {
|
||||
|
||||
// Check title length
|
||||
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
|
||||
if (fm.shortTitle) {
|
||||
validatePropertyLength(
|
||||
onError,
|
||||
params.lines,
|
||||
params.lines as string[],
|
||||
'shortTitle',
|
||||
fm.shortTitle,
|
||||
rules.shortTitle,
|
||||
@@ -78,17 +103,24 @@ export const frontmatterValidation = {
|
||||
|
||||
// Check intro length if it exists
|
||||
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
|
||||
const strippedTitle = stripLiquidTags(fm.title)
|
||||
if (fm.title && strippedTitle.length > rules.shortTitle.max && !fm.shortTitle) {
|
||||
const titleLine = findPropertyLine(params.lines, '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.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,
|
||||
null,
|
||||
null,
|
||||
@@ -98,10 +130,10 @@ export const frontmatterValidation = {
|
||||
// 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, '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, 'topics')
|
||||
const topicsLine = findPropertyLine(params.lines as string[], 'topics')
|
||||
addError(
|
||||
onError,
|
||||
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 propertyLength = strippedValue.length
|
||||
const propertyLength = (strippedValue as string).length
|
||||
const propertyLine = findPropertyLine(lines, propertyName)
|
||||
|
||||
// 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
|
||||
if (!filePath || !filePath.endsWith('.md')) {
|
||||
return null
|
||||
@@ -168,7 +208,7 @@ function detectContentType(frontmatter, filePath) {
|
||||
return 'article'
|
||||
}
|
||||
|
||||
function findPropertyLine(lines, property) {
|
||||
function findPropertyLine(lines: string[], property: string): number {
|
||||
const line = lines.find((line) => line.trim().startsWith(`${property}:`))
|
||||
return line ? lines.indexOf(line) + 1 : 1
|
||||
}
|
||||
@@ -1,19 +1,36 @@
|
||||
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)
|
||||
}
|
||||
|
||||
function shorten(text, length = 70) {
|
||||
function shorten(text: string, length = 70): string {
|
||||
if (text.length <= length) return text
|
||||
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 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')
|
||||
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 = [
|
||||
'red',
|
||||
'yellow',
|
||||
@@ -126,19 +144,21 @@ function chalkFunColors(text) {
|
||||
'greenBright',
|
||||
'magentaBright',
|
||||
'cyanBright',
|
||||
].sort(() => Math.random() - 0.5)
|
||||
] as const
|
||||
const shuffledColors = [...colors].sort(() => Math.random() - 0.5)
|
||||
let colorIndex = 0
|
||||
return text
|
||||
.split('')
|
||||
.map((char) => {
|
||||
const color = colors[colorIndex]
|
||||
colorIndex = (colorIndex + 1) % colors.length
|
||||
return chalk[color](char)
|
||||
const color = shuffledColors[colorIndex]
|
||||
colorIndex = (colorIndex + 1) % shuffledColors.length
|
||||
// Chalk's TypeScript types don't support dynamic property access, but these are valid color methods
|
||||
return (chalk as any)[color](char)
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
function indentWrappedString(str, startingIndent) {
|
||||
function indentWrappedString(str: string, startingIndent: number): string {
|
||||
const NEW_LINE_PADDING = ' '.repeat(16)
|
||||
const width = process.stdout.columns || 80 // Use terminal width, default to 80 if not available
|
||||
let indentedString = ''
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest'
|
||||
import cheerio from 'cheerio'
|
||||
|
||||
import { renderContent } from '@/content-render/index'
|
||||
import type { Context } from '@/types'
|
||||
|
||||
const example = `
|
||||
\`\`\`yaml annotate
|
||||
@@ -131,7 +132,7 @@ on: [push]
|
||||
`
|
||||
|
||||
// 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': {
|
||||
href: '/get-started/start-your-journey/hello-world',
|
||||
rawTitle: 'Hello World',
|
||||
@@ -147,7 +148,8 @@ on: [push]
|
||||
currentVersion: 'free-pro-team@latest',
|
||||
pages: mockPages,
|
||||
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 $ = cheerio.load(res)
|
||||
@@ -1,16 +1,21 @@
|
||||
import path from 'path'
|
||||
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
|
||||
|
||||
// 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.
|
||||
// Otherwise, assume it's a child .md file and add `.md` to the path.
|
||||
let filepath
|
||||
let mtime
|
||||
let filepath: string
|
||||
let mtime: number
|
||||
// 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
|
||||
// we also collect it's modification time.
|
||||
@@ -18,7 +23,7 @@ export default async function createTree(originalPath, rootPath, previousTree) {
|
||||
filepath = `${originalPath}.md`
|
||||
mtime = await getMtime(filepath)
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
filepath = `${originalPath}/index.md`
|
||||
@@ -30,7 +35,7 @@ export default async function createTree(originalPath, rootPath, previousTree) {
|
||||
try {
|
||||
mtime = await getMtime(filepath)
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
// 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
|
||||
// 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.
|
||||
let page
|
||||
let page: Page
|
||||
if (previousTree && previousTree.page.mtime === mtime) {
|
||||
// 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
|
||||
@@ -61,20 +66,22 @@ export default async function createTree(originalPath, rootPath, previousTree) {
|
||||
} else {
|
||||
// Either the previous tree doesn't exist yet or the modification time
|
||||
// of the file on disk has changed.
|
||||
page = await Page.init({
|
||||
const newPage = await PageClass.init({
|
||||
basePath,
|
||||
relativePath,
|
||||
languageCode: 'en',
|
||||
mtime,
|
||||
})
|
||||
}
|
||||
|
||||
if (!page) {
|
||||
// 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 (!newPage) {
|
||||
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.
|
||||
const item = {
|
||||
const item: UnversionedTree = {
|
||||
page,
|
||||
// This is only here for the sake of reloading the tree later which
|
||||
// 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.
|
||||
// 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.
|
||||
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.
|
||||
if (item.page.children) {
|
||||
assertUniqueChildren(item.page)
|
||||
// Page class has dynamic frontmatter properties like 'children' that aren't in the type definition
|
||||
if ((page as any).children) {
|
||||
assertUniqueChildren(page as any)
|
||||
item.childPages = (
|
||||
await Promise.all(
|
||||
item.page.children.map(async (child, i) => {
|
||||
let childPreviousTree
|
||||
// Page class has dynamic frontmatter properties like 'children' that aren't in the type definition
|
||||
((page as any).children as string[]).map(async (child: string, i: number) => {
|
||||
let childPreviousTree: UnversionedTree | undefined
|
||||
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
|
||||
// from the array if we're confident the names are the same
|
||||
// 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
|
||||
// mutate the `page.children` so we can benefit from the
|
||||
// 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
|
||||
}),
|
||||
)
|
||||
).filter(Boolean)
|
||||
).filter((tree): tree is UnversionedTree => tree !== undefined)
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
|
||||
async function getMtime(filePath) {
|
||||
async function getMtime(filePath: string): Promise<number> {
|
||||
// Use mtimeMs, which is a regular floating point number, instead of the
|
||||
// mtime which is a Date based on that same number.
|
||||
// Otherwise, if we use the Date instances, we have to compare
|
||||
@@ -150,10 +165,11 @@ async function getMtime(filePath) {
|
||||
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) {
|
||||
const count = {}
|
||||
page.children.forEach((entry) => (count[entry] = 1 + (count[entry] || 0)))
|
||||
const count: Record<string, number> = {}
|
||||
page.children.forEach((entry: string) => (count[entry] = 1 + (count[entry] || 0)))
|
||||
let msg = `${page.relativePath} has duplicates in the 'children' key.`
|
||||
for (const [entry, times] of Object.entries(count)) {
|
||||
if (times > 1) msg += ` '${entry}' is repeated ${times} times. `
|
||||
@@ -10,6 +10,7 @@ import libLanguages from '@/languages/lib/languages'
|
||||
import { liquid } from '@/content-render/index'
|
||||
import patterns from '@/frame/lib/patterns'
|
||||
import removeFPTFromPath from '@/versions/lib/remove-fpt-from-path'
|
||||
import type { Page } from '@/types'
|
||||
|
||||
const languageCodes = Object.keys(libLanguages)
|
||||
const slugger = new GithubSlugger()
|
||||
@@ -17,7 +18,7 @@ const slugger = new GithubSlugger()
|
||||
describe('pages module', () => {
|
||||
vi.setConfig({ testTimeout: 60 * 1000 })
|
||||
|
||||
let pages
|
||||
let pages: Page[]
|
||||
|
||||
beforeAll(async () => {
|
||||
pages = await loadPages()
|
||||
@@ -50,29 +51,30 @@ describe('pages module', () => {
|
||||
const englishPages = chain(pages)
|
||||
.filter(['languageCode', 'en'])
|
||||
.filter('redirect_from')
|
||||
.map((pages) => pick(pages, ['redirect_from', 'applicableVersions', 'fullPath']))
|
||||
.map((page) => pick(page, ['redirect_from', 'applicableVersions', 'fullPath']))
|
||||
.value()
|
||||
|
||||
// Map from redirect path to Set of file paths
|
||||
const redirectToFiles = new Map()
|
||||
const versionedRedirects = []
|
||||
const redirectToFiles = new Map<string, Set<string>>()
|
||||
const versionedRedirects: Array<{ path: string; file: string }> = []
|
||||
|
||||
englishPages.forEach((page) => {
|
||||
page.redirect_from.forEach((redirect) => {
|
||||
page.applicableVersions.forEach((version) => {
|
||||
// Page objects have dynamic properties from chain/lodash that aren't fully typed
|
||||
englishPages.forEach((page: any) => {
|
||||
page.redirect_from.forEach((redirect: string) => {
|
||||
page.applicableVersions.forEach((version: string) => {
|
||||
const versioned = removeFPTFromPath(path.posix.join('/', version, redirect))
|
||||
versionedRedirects.push({ path: versioned, file: page.fullPath })
|
||||
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
|
||||
const duplicates = Array.from(redirectToFiles.entries())
|
||||
.filter(([_, files]) => files.size > 1)
|
||||
.filter(([, files]) => files.size > 1)
|
||||
.map(([path]) => path)
|
||||
|
||||
// Build a detailed message with sources for each duplicate
|
||||
@@ -96,7 +98,8 @@ describe('pages module', () => {
|
||||
return (
|
||||
page.languageCode === 'en' && // only check English
|
||||
!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.shortTitle || '')) !== path.basename(page.relativePath, '.md')
|
||||
)
|
||||
@@ -127,7 +130,8 @@ describe('pages module', () => {
|
||||
test('every page has valid frontmatter', async () => {
|
||||
const frontmatterErrors = chain(pages)
|
||||
// .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)
|
||||
.flatten()
|
||||
.value()
|
||||
@@ -141,17 +145,18 @@ describe('pages module', () => {
|
||||
})
|
||||
|
||||
test('every page has valid Liquid templating', async () => {
|
||||
const liquidErrors = []
|
||||
const liquidErrors: Array<{ filename: string; error: string }> = []
|
||||
|
||||
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
|
||||
try {
|
||||
await liquid.parse(markdown)
|
||||
} catch (error) {
|
||||
liquidErrors.push({
|
||||
filename: page.fullPath,
|
||||
error: error.message,
|
||||
error: (error as Error).message,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,9 @@ versions:
|
||||
describe('frontmatter', () => {
|
||||
test('parses frontmatter and content in a given string (no options required)', () => {
|
||||
const { data, content, errors } = parse(fixture1)
|
||||
expect(data.title).toBe('Hello, World')
|
||||
expect(data.meaning_of_life).toBe(42)
|
||||
expect(content.trim()).toBe('I am content.')
|
||||
expect(data!.title).toBe('Hello, World')
|
||||
expect(data!.meaning_of_life).toBe(42)
|
||||
expect(content!.trim()).toBe('I am content.')
|
||||
expect(errors.length).toBe(0)
|
||||
})
|
||||
|
||||
@@ -85,9 +85,9 @@ I am content.
|
||||
}
|
||||
|
||||
const { data, content, errors } = parse(fixture1, { schema })
|
||||
expect(data.title).toBe('Hello, World')
|
||||
expect(data.meaning_of_life).toBe(42)
|
||||
expect(content.trim()).toBe('I am content.')
|
||||
expect(data!.title).toBe('Hello, World')
|
||||
expect(data!.meaning_of_life).toBe(42)
|
||||
expect(content!.trim()).toBe('I am content.')
|
||||
expect(errors.length).toBe(0)
|
||||
})
|
||||
|
||||
@@ -102,9 +102,9 @@ I am content.
|
||||
}
|
||||
|
||||
const { data, content, errors } = parse(fixture1, { schema })
|
||||
expect(data.title).toBe('Hello, World')
|
||||
expect(data.meaning_of_life).toBe(42)
|
||||
expect(content.trim()).toBe('I am content.')
|
||||
expect(data!.title).toBe('Hello, World')
|
||||
expect(data!.meaning_of_life).toBe(42)
|
||||
expect(content!.trim()).toBe('I am content.')
|
||||
expect(errors.length).toBe(1)
|
||||
const expectedError = {
|
||||
instancePath: '/meaning_of_life',
|
||||
@@ -121,7 +121,10 @@ I am content.
|
||||
|
||||
test('creates errors if versions frontmatter does not match semver format', () => {
|
||||
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 expectedError = {
|
||||
@@ -1,6 +1,8 @@
|
||||
// @ts-ignore - no types available
|
||||
import httpStatusCodes from 'http-status-code'
|
||||
import { get, isPlainObject } from 'lodash-es'
|
||||
import { parseTemplate } from 'url-template'
|
||||
// @ts-ignore - no types available
|
||||
import mergeAllOf from 'json-schema-merge-allof'
|
||||
|
||||
import { renderContent } from './render-content'
|
||||
@@ -10,19 +12,41 @@ import { validateJson } from '@/tests/lib/validate-json-schema'
|
||||
import { getBodyParams } from './get-body-params'
|
||||
|
||||
export default class Operation {
|
||||
#operation
|
||||
constructor(verb, requestPath, operation, globalServers) {
|
||||
// OpenAPI operation object - schema is dynamic and varies by endpoint
|
||||
#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
|
||||
// The global server object sets metadata including the base url for
|
||||
// all operations in a version. Individual operations can override
|
||||
// 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
|
||||
? operation.servers[0].variables
|
||||
: globalServers[0].variables
|
||||
: globalServers?.[0]?.variables
|
||||
if (serverVariables) {
|
||||
const templateVariables = {}
|
||||
// Template variables structure comes from OpenAPI server variables
|
||||
const templateVariables: Record<string, any> = {}
|
||||
Object.keys(serverVariables).forEach(
|
||||
(key) => (templateVariables[key] = serverVariables[key].default),
|
||||
)
|
||||
@@ -47,9 +71,10 @@ export default class Operation {
|
||||
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([
|
||||
this.codeExamples(),
|
||||
this.renderCodeExamples(),
|
||||
this.renderDescription(),
|
||||
this.renderStatusCodes(),
|
||||
this.renderParameterDescriptions(),
|
||||
@@ -65,7 +90,7 @@ export default class Operation {
|
||||
}
|
||||
}
|
||||
|
||||
async renderDescription() {
|
||||
async renderDescription(): Promise<this> {
|
||||
try {
|
||||
this.descriptionHTML = await renderContent(this.#operation.description)
|
||||
return this
|
||||
@@ -75,25 +100,27 @@ export default class Operation {
|
||||
}
|
||||
}
|
||||
|
||||
async codeExamples() {
|
||||
this.codeExamples = await getCodeSamples(this.#operation)
|
||||
async renderCodeExamples(): Promise<any[]> {
|
||||
const codeExamples = await getCodeSamples(this.#operation)
|
||||
try {
|
||||
return await Promise.all(
|
||||
this.codeExamples.map(async (codeExample) => {
|
||||
this.codeExamples = await Promise.all(
|
||||
// Code example structure varies by endpoint and language
|
||||
codeExamples.map(async (codeExample: any) => {
|
||||
codeExample.response.description = await renderContent(codeExample.response.description)
|
||||
return codeExample
|
||||
}),
|
||||
)
|
||||
return this.codeExamples
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
throw new Error(`Error generating code examples for ${this.verb} ${this.requestPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
async renderStatusCodes() {
|
||||
async renderStatusCodes(): Promise<void> {
|
||||
const responses = this.#operation.responses
|
||||
const responseKeys = Object.keys(responses)
|
||||
if (responseKeys.length === 0) return []
|
||||
if (responseKeys.length === 0) return
|
||||
|
||||
try {
|
||||
this.statusCodes = await Promise.all(
|
||||
@@ -121,7 +148,7 @@ export default class Operation {
|
||||
}
|
||||
}
|
||||
|
||||
async renderParameterDescriptions() {
|
||||
async renderParameterDescriptions(): Promise<any[]> {
|
||||
try {
|
||||
return Promise.all(
|
||||
this.parameters.map(async (param) => {
|
||||
@@ -135,8 +162,8 @@ export default class Operation {
|
||||
}
|
||||
}
|
||||
|
||||
async renderBodyParameterDescriptions() {
|
||||
if (!this.#operation.requestBody) return []
|
||||
async renderBodyParameterDescriptions(): Promise<void> {
|
||||
if (!this.#operation.requestBody) return
|
||||
|
||||
// There is currently only one operation with more than one content type
|
||||
// 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', [])
|
||||
try {
|
||||
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
|
||||
// remove extra leading and trailing newlines
|
||||
.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]
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
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', () => {
|
||||
const mockOperation = {
|
||||
@@ -9,14 +11,16 @@ describe('CLI examples generation', () => {
|
||||
serverUrl: 'https://api.github.com',
|
||||
subcategory: 'code-scanning',
|
||||
parameters: [],
|
||||
}
|
||||
// Partial mock object for testing - 'as unknown as' bypasses strict type checking for missing properties
|
||||
} as unknown as Operation
|
||||
|
||||
const mockVersions = {
|
||||
'free-pro-team@latest': {
|
||||
apiVersions: ['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', () => {
|
||||
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.",
|
||||
},
|
||||
},
|
||||
}
|
||||
// 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)
|
||||
|
||||
@@ -61,7 +66,8 @@ describe('CLI examples generation', () => {
|
||||
},
|
||||
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)
|
||||
|
||||
@@ -83,7 +89,8 @@ describe('CLI examples generation', () => {
|
||||
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 = {
|
||||
verb: 'post',
|
||||
@@ -91,7 +98,8 @@ describe('CLI examples generation', () => {
|
||||
serverUrl: 'https://api.github.com',
|
||||
subcategory: 'issues',
|
||||
parameters: [],
|
||||
}
|
||||
// Partial mock object for testing - 'as unknown as' bypasses strict type checking for missing properties
|
||||
} as unknown as Operation
|
||||
|
||||
const result = getGHExample(
|
||||
mockSimpleOperation,
|
||||
@@ -121,7 +129,8 @@ describe('CLI examples generation', () => {
|
||||
'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)
|
||||
|
||||
@@ -143,7 +152,8 @@ describe('CLI examples generation', () => {
|
||||
},
|
||||
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 = {
|
||||
verb: 'post',
|
||||
@@ -151,7 +161,8 @@ describe('CLI examples generation', () => {
|
||||
serverUrl: 'https://api.github.com',
|
||||
subcategory: 'pulls',
|
||||
parameters: [],
|
||||
}
|
||||
// Partial mock object for testing - 'as unknown as' bypasses strict type checking for missing properties
|
||||
} as unknown as Operation
|
||||
|
||||
const result = getShellExample(
|
||||
mockSimpleOperation,
|
||||
@@ -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'
|
||||
for (const schema in differences) {
|
||||
errorMessage += 'Version: ' + schema + '\n'
|
||||
Reference in New Issue
Block a user