Convert 27 JavaScript files to TypeScript (#57693)
This commit is contained in:
@@ -2,7 +2,29 @@ import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
import frontmatter from '@gr2m/gray-matter'
|
||||
import { getLogLevelNumber } from './src/observability/logger/lib/log-levels.js'
|
||||
// Hardcoded log level function since next.config.js cannot import from TypeScript files
|
||||
// Matches ./src/observability/logger/lib/log-levels
|
||||
function getLogLevelNumber() {
|
||||
const LOG_LEVELS = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 2,
|
||||
debug: 3,
|
||||
}
|
||||
|
||||
let defaultLogLevel = 'info'
|
||||
if (
|
||||
!process.env.LOG_LEVEL &&
|
||||
(process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test')
|
||||
) {
|
||||
defaultLogLevel = 'debug'
|
||||
}
|
||||
|
||||
const envLogLevel = process.env.LOG_LEVEL?.toLowerCase() || defaultLogLevel
|
||||
const logLevel = LOG_LEVELS[envLogLevel] !== undefined ? envLogLevel : defaultLogLevel
|
||||
|
||||
return LOG_LEVELS[logLevel]
|
||||
}
|
||||
|
||||
// Replace imports with hardcoded values
|
||||
const ROOT = process.env.ROOT || '.'
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
// This function takes an array of AJV errors and formats them
|
||||
// in a way that is more compatible with Markdownlint errors.
|
||||
export function formatAjvErrors(errors = []) {
|
||||
return errors.map((errorObj) => {
|
||||
const error = {}
|
||||
|
||||
// An instancePath is either blank or starts with a slash
|
||||
// and separates object properties with slashes. A more
|
||||
// common way to read object nesting is using dot notation.
|
||||
error.instancePath =
|
||||
errorObj.instancePath === ''
|
||||
? errorObj.instancePath
|
||||
: errorObj.instancePath.slice(1).replace('/', '.')
|
||||
|
||||
if (errorObj.keyword === 'additionalProperties') {
|
||||
error.detail = 'The frontmatter includes an unsupported property.'
|
||||
const pathContext = error.instancePath ? ` from \`${error.instancePath}\`` : ''
|
||||
error.context = `Remove the property \`${errorObj.params.additionalProperty}\`${pathContext}.`
|
||||
error.errorProperty = errorObj.params.additionalProperty
|
||||
error.searchProperty = error.errorProperty
|
||||
return error
|
||||
}
|
||||
|
||||
if (errorObj.keyword === 'required') {
|
||||
error.detail = 'The frontmatter has a missing required property'
|
||||
const pathContext = error.instancePath ? ` from \`${error.instancePath}\`` : ''
|
||||
error.context = `Add the missing property \`${errorObj.params.missingProperty}\`${pathContext}`
|
||||
error.errorProperty = errorObj.params.missingProperty
|
||||
error.searchProperty = error.instancePath.split('.').pop()
|
||||
return error
|
||||
}
|
||||
|
||||
// The two most common errors are required and additionalProperties.
|
||||
// This catches any other with a generic detail that uses the AJV wording.
|
||||
error.detail = `Frontmatter ${errorObj.message}.`
|
||||
error.context = Object.values(errorObj.params).join('')
|
||||
error.errorProperty = error.context
|
||||
error.searchProperty = error.errorProperty
|
||||
return error
|
||||
})
|
||||
}
|
||||
79
src/content-linter/lib/helpers/schema-utils.ts
Normal file
79
src/content-linter/lib/helpers/schema-utils.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { getFrontmatter } from './utils'
|
||||
|
||||
// AJV validation error object structure
|
||||
interface AjvValidationError {
|
||||
instancePath: string
|
||||
keyword: string
|
||||
message: string
|
||||
params: {
|
||||
additionalProperty?: string
|
||||
missingProperty?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
// Processed error object for markdown linting
|
||||
interface ProcessedValidationError {
|
||||
instancePath: string
|
||||
detail: string
|
||||
context: string
|
||||
errorProperty: string
|
||||
searchProperty: string
|
||||
}
|
||||
|
||||
export function formatAjvErrors(errors: AjvValidationError[] = []): ProcessedValidationError[] {
|
||||
const processedErrors: ProcessedValidationError[] = []
|
||||
|
||||
errors.forEach((errorObj: AjvValidationError) => {
|
||||
const error: Partial<ProcessedValidationError> = {}
|
||||
|
||||
error.instancePath =
|
||||
errorObj.instancePath === ''
|
||||
? errorObj.instancePath
|
||||
: errorObj.instancePath.slice(1).replace('/', '.')
|
||||
|
||||
if (errorObj.keyword === 'additionalProperties') {
|
||||
error.detail = 'The frontmatter includes an unsupported property.'
|
||||
const pathContext = error.instancePath ? ` from \`${error.instancePath}\`` : ''
|
||||
error.context = `Remove the property \`${errorObj.params.additionalProperty}\`${pathContext}.`
|
||||
error.errorProperty = errorObj.params.additionalProperty
|
||||
error.searchProperty = error.errorProperty
|
||||
}
|
||||
|
||||
// required rule
|
||||
if (errorObj.keyword === 'required') {
|
||||
error.detail = 'The frontmatter has a missing required property'
|
||||
const pathContext = error.instancePath ? ` from \`${error.instancePath}\`` : ''
|
||||
error.context = `Add the missing property \`${errorObj.params.missingProperty}\`${pathContext}`
|
||||
error.errorProperty = errorObj.params.missingProperty
|
||||
error.searchProperty = error.instancePath.split('.').pop()
|
||||
}
|
||||
|
||||
// all other rules
|
||||
if (!error.detail) {
|
||||
error.detail = `Frontmatter ${errorObj.message}.`
|
||||
error.context = Object.values(errorObj.params).join('')
|
||||
error.errorProperty = error.context
|
||||
error.searchProperty = error.errorProperty
|
||||
}
|
||||
|
||||
processedErrors.push(error as ProcessedValidationError)
|
||||
})
|
||||
|
||||
return processedErrors
|
||||
}
|
||||
|
||||
// Alias for backward compatibility
|
||||
export const processSchemaValidationErrors = formatAjvErrors
|
||||
|
||||
// Schema validator interface - generic due to different schema types (AJV, JSON Schema, etc.)
|
||||
interface SchemaValidator {
|
||||
validate(data: unknown): boolean
|
||||
}
|
||||
|
||||
export function getSchemaValidator(
|
||||
frontmatterLines: string[],
|
||||
): (schema: SchemaValidator) => boolean {
|
||||
const frontmatter = getFrontmatter(frontmatterLines)
|
||||
return (schema: SchemaValidator) => schema.validate(frontmatter)
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
// @ts-ignore - markdownlint-rule-helpers doesn't have TypeScript declarations
|
||||
import { addError } from 'markdownlint-rule-helpers'
|
||||
import type { RuleParams, RuleErrorCallback } from '../../types'
|
||||
|
||||
import { getFrontmatter } from '../helpers/utils'
|
||||
|
||||
@@ -7,7 +9,7 @@ export const frontmatterHiddenDocs = {
|
||||
description:
|
||||
'Articles with frontmatter property `hidden` can only be located in specific products',
|
||||
tags: ['frontmatter', 'feature', 'early-access'],
|
||||
function: (params, onError) => {
|
||||
function: (params: RuleParams, onError: RuleErrorCallback) => {
|
||||
const fm = getFrontmatter(params.lines)
|
||||
if (!fm || !fm.hidden) return
|
||||
|
||||
@@ -24,7 +26,8 @@ export const frontmatterHiddenDocs = {
|
||||
|
||||
if (allowedProductPaths.some((allowedPath) => params.name.includes(allowedPath))) return
|
||||
|
||||
const hiddenLine = params.lines.find((line) => line.startsWith('hidden:'))
|
||||
const hiddenLine = params.lines.find((line: string) => line.startsWith('hidden:'))
|
||||
if (!hiddenLine) return
|
||||
const lineNumber = params.lines.indexOf(hiddenLine) + 1
|
||||
|
||||
addError(
|
||||
@@ -1,4 +1,6 @@
|
||||
// @ts-ignore - markdownlint-rule-helpers doesn't have TypeScript declarations
|
||||
import { addError, ellipsify } from 'markdownlint-rule-helpers'
|
||||
import type { RuleParams, RuleErrorCallback } from '../../types'
|
||||
|
||||
import { getRange } from '../helpers/utils'
|
||||
/*
|
||||
@@ -10,7 +12,7 @@ export const githubOwnedActionReferences = {
|
||||
names: ['GHD013', 'github-owned-action-references'],
|
||||
description: 'GitHub-owned action references should not be hardcoded',
|
||||
tags: ['feature', 'actions'],
|
||||
function: (params, onError) => {
|
||||
function: (params: RuleParams, onError: RuleErrorCallback) => {
|
||||
const filepath = params.name
|
||||
if (filepath.startsWith('data/reusables/actions/action-')) return
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// @ts-ignore - markdownlint-rule-helpers doesn't have TypeScript declarations
|
||||
import { addError, ellipsify } from 'markdownlint-rule-helpers'
|
||||
import type { RuleParams, RuleErrorCallback } from '../../types'
|
||||
|
||||
import { getRange } from '../helpers/utils'
|
||||
import frontmatter from '@/frame/lib/read-frontmatter'
|
||||
@@ -13,7 +15,7 @@ export const hardcodedDataVariable = {
|
||||
description:
|
||||
'Strings that contain "personal access token" should use the product variable instead',
|
||||
tags: ['single-source'],
|
||||
function: (params, onError) => {
|
||||
function: (params: RuleParams, onError: RuleErrorCallback) => {
|
||||
if (params.name.startsWith('data/variables/product.yml')) return
|
||||
const frontmatterString = params.frontMatterLines.join('\n')
|
||||
const fm = frontmatter(frontmatterString).data
|
||||
@@ -5,14 +5,15 @@ import {
|
||||
isStringQuoted,
|
||||
isStringPunctuated,
|
||||
} from '../helpers/utils'
|
||||
import type { RuleParams, RuleErrorCallback } from '../../types'
|
||||
|
||||
export const imageAltTextEndPunctuation = {
|
||||
names: ['GHD032', 'image-alt-text-end-punctuation'],
|
||||
description: 'Alternate text for images should end with punctuation',
|
||||
tags: ['accessibility', 'images'],
|
||||
parser: 'markdownit',
|
||||
function: (params, onError) => {
|
||||
forEachInlineChild(params, 'image', function forToken(token) {
|
||||
function: (params: RuleParams, onError: RuleErrorCallback) => {
|
||||
forEachInlineChild(params, 'image', function forToken(token: any) {
|
||||
const imageAltText = token.content.trim()
|
||||
|
||||
// If the alt text is empty, there is nothing to check and you can't
|
||||
@@ -1,17 +1,26 @@
|
||||
// @ts-ignore - markdownlint-rule-helpers doesn't have TypeScript declarations
|
||||
import { addError } from 'markdownlint-rule-helpers'
|
||||
import type { RuleParams, RuleErrorCallback } from '../../types'
|
||||
|
||||
import { liquid } from '@/content-render/index'
|
||||
import { allVersions } from '@/versions/lib/all-versions'
|
||||
import { forEachInlineChild, getRange } from '../helpers/utils'
|
||||
|
||||
interface ImageToken {
|
||||
content: string
|
||||
lineNumber: number
|
||||
line: string
|
||||
range: [number, number]
|
||||
}
|
||||
|
||||
export const incorrectAltTextLength = {
|
||||
names: ['GHD033', 'incorrect-alt-text-length'],
|
||||
description: 'Images alternate text should be between 40-150 characters',
|
||||
tags: ['accessibility', 'images'],
|
||||
parser: 'markdownit',
|
||||
asynchronous: true,
|
||||
function: (params, onError) => {
|
||||
forEachInlineChild(params, 'image', async function forToken(token) {
|
||||
function: (params: RuleParams, onError: RuleErrorCallback) => {
|
||||
forEachInlineChild(params, 'image', async function forToken(token: ImageToken) {
|
||||
let renderedString = token.content
|
||||
|
||||
if (token.content.includes('{%') || token.content.includes('{{')) {
|
||||
@@ -1,4 +1,6 @@
|
||||
// @ts-ignore - markdownlint-rule-helpers doesn't have TypeScript declarations
|
||||
import { addError, filterTokens } from 'markdownlint-rule-helpers'
|
||||
import type { RuleParams, RuleErrorCallback } from '../../types'
|
||||
|
||||
import { doesStringEndWithPeriod, getRange, isStringQuoted } from '../helpers/utils'
|
||||
|
||||
@@ -7,8 +9,8 @@ export const linkPunctuation = {
|
||||
description: 'Internal link titles must not contain punctuation',
|
||||
tags: ['links', 'url'],
|
||||
parser: 'markdownit',
|
||||
function: (params, onError) => {
|
||||
filterTokens(params, 'inline', (token) => {
|
||||
function: (params: RuleParams, onError: RuleErrorCallback) => {
|
||||
filterTokens(params, 'inline', (token: any) => {
|
||||
const { children, line } = token
|
||||
let inLink = false
|
||||
for (const child of children) {
|
||||
@@ -18,7 +18,8 @@ describe('lint learning tracks', () => {
|
||||
if (yamlFileList.length < 1) return
|
||||
|
||||
describe.each(yamlFileList)('%s', (yamlAbsPath) => {
|
||||
let yamlContent
|
||||
// Using any type because YAML content structure is dynamic and varies per file
|
||||
let yamlContent: any
|
||||
|
||||
beforeAll(async () => {
|
||||
const fileContents = await readFile(yamlAbsPath, 'utf8')
|
||||
@@ -26,8 +27,10 @@ describe('lint learning tracks', () => {
|
||||
})
|
||||
|
||||
test('contains valid liquid', () => {
|
||||
const toLint = []
|
||||
Object.values(yamlContent).forEach(({ title, description }) => {
|
||||
// Using any[] for toLint since it contains mixed string content from various YAML properties
|
||||
const toLint: any[] = []
|
||||
// Using any for destructured params as YAML structure varies across different learning track files
|
||||
Object.values(yamlContent).forEach(({ title, description }: any) => {
|
||||
toLint.push(title)
|
||||
toLint.push(description)
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { runRule } from '../../lib/init-test'
|
||||
import { incorrectAltTextLength } from '../../lib/linting-rules/image-alt-text-length'
|
||||
import type { Rule } from '../../types'
|
||||
|
||||
describe(incorrectAltTextLength.names.join(' - '), () => {
|
||||
test('image with incorrect alt text length fails', async () => {
|
||||
@@ -9,7 +10,7 @@ describe(incorrectAltTextLength.names.join(' - '), () => {
|
||||
``,
|
||||
``,
|
||||
].join('\n')
|
||||
const result = await runRule(incorrectAltTextLength, { strings: { markdown } })
|
||||
const result = await runRule(incorrectAltTextLength as Rule, { strings: { markdown } })
|
||||
const errors = result.markdown
|
||||
expect(errors.length).toBe(2)
|
||||
expect(errors[0].lineNumber).toBe(1)
|
||||
@@ -22,7 +23,7 @@ describe(incorrectAltTextLength.names.join(' - '), () => {
|
||||
``,
|
||||
``,
|
||||
].join('\n')
|
||||
const result = await runRule(incorrectAltTextLength, { strings: { markdown } })
|
||||
const result = await runRule(incorrectAltTextLength as Rule, { strings: { markdown } })
|
||||
const errors = result.markdown
|
||||
expect(errors.length).toBe(0)
|
||||
})
|
||||
@@ -33,7 +34,7 @@ describe(incorrectAltTextLength.names.join(' - '), () => {
|
||||
// Completely empty
|
||||
'',
|
||||
].join('\n')
|
||||
const result = await runRule(incorrectAltTextLength, { strings: { markdown } })
|
||||
const result = await runRule(incorrectAltTextLength as Rule, { strings: { markdown } })
|
||||
const errors = result.markdown
|
||||
expect(errors.length).toBe(1)
|
||||
expect(errors[0].lineNumber).toBe(3)
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { runRule } from '../../lib/init-test'
|
||||
import { internalLinksNoLang } from '../../lib/linting-rules/internal-links-no-lang'
|
||||
import type { Rule } from '../../types'
|
||||
|
||||
describe(internalLinksNoLang.names.join(' - '), () => {
|
||||
test('internal links with hardcoded language codes fail', async () => {
|
||||
@@ -10,7 +11,7 @@ describe(internalLinksNoLang.names.join(' - '), () => {
|
||||
'[Link to just a landing page in english](/en)',
|
||||
'[Korean Docs](/ko/actions)',
|
||||
].join('\n')
|
||||
const result = await runRule(internalLinksNoLang, { strings: { markdown } })
|
||||
const result = await runRule(internalLinksNoLang as Rule, { strings: { markdown } })
|
||||
const errors = result.markdown
|
||||
expect(errors.length).toBe(3)
|
||||
expect(errors.map((error) => error.lineNumber)).toEqual([1, 2, 3])
|
||||
@@ -31,7 +32,7 @@ describe(internalLinksNoLang.names.join(' - '), () => {
|
||||
// A link that starts with a language code
|
||||
'[Enterprise](/enterprise/overview)',
|
||||
].join('\n')
|
||||
const result = await runRule(internalLinksNoLang, { strings: { markdown } })
|
||||
const result = await runRule(internalLinksNoLang as Rule, { strings: { markdown } })
|
||||
const errors = result.markdown
|
||||
expect(errors.length).toBe(0)
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { runRule } from '../../lib/init-test'
|
||||
import { internalLinksOldVersion } from '../../lib/linting-rules/internal-links-old-version'
|
||||
import type { Rule } from '../../types'
|
||||
|
||||
describe(internalLinksOldVersion.names.join(' - '), () => {
|
||||
test('links with old hardcoded versioning fail', async () => {
|
||||
@@ -10,7 +11,7 @@ describe(internalLinksOldVersion.names.join(' - '), () => {
|
||||
'[Link to Enterprise 11.10.340](https://docs.github.com/enterprise/11.10.340/admin/yes)',
|
||||
'[Enterprise 2.8](http://help.github.com/enterprise/2.8/admin/)',
|
||||
].join('\n')
|
||||
const result = await runRule(internalLinksOldVersion, { strings: { markdown } })
|
||||
const result = await runRule(internalLinksOldVersion as Rule, { strings: { markdown } })
|
||||
const errors = result.markdown
|
||||
expect(errors.length).toBe(3)
|
||||
expect(errors.map((error) => error.lineNumber)).toEqual([1, 2, 3])
|
||||
@@ -26,7 +27,7 @@ describe(internalLinksOldVersion.names.join(' - '), () => {
|
||||
// Current versioning links is excluded from this test
|
||||
'[New versioning](/github/site-policy/enterprise/2.2/yes)',
|
||||
].join('\n')
|
||||
const result = await runRule(internalLinksOldVersion, { strings: { markdown } })
|
||||
const result = await runRule(internalLinksOldVersion as Rule, { strings: { markdown } })
|
||||
const errors = result.markdown
|
||||
expect(errors.length).toBe(0)
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { runRule } from '../../lib/init-test'
|
||||
import { linkQuotation } from '../../lib/linting-rules/link-quotation'
|
||||
import type { Rule } from '../../types'
|
||||
|
||||
describe(linkQuotation.names.join(' - '), () => {
|
||||
test('links that are formatted correctly should not generate an error', async () => {
|
||||
@@ -9,7 +10,7 @@ describe(linkQuotation.names.join(' - '), () => {
|
||||
'Random stuff [A title](./image.png)',
|
||||
'"This is a direct quote" [A title](./image.png)',
|
||||
].join('\n')
|
||||
const result = await runRule(linkQuotation, { strings: { markdown } })
|
||||
const result = await runRule(linkQuotation as Rule, { strings: { markdown } })
|
||||
const errors = result.markdown
|
||||
expect(errors.length).toBe(0)
|
||||
})
|
||||
@@ -26,11 +27,11 @@ describe(linkQuotation.names.join(' - '), () => {
|
||||
'See "[AUTOTITLE](/foo/bar)," "[AUTOTITLE](/foo/bar2)," "[AUTOTITLE](/foo/bar3)," and "[AUTOTITLE](/foo/bar4)."',
|
||||
'See "[Anchor link](#anchor-link)."',
|
||||
].join('\n')
|
||||
const result = await runRule(linkQuotation, { strings: { markdown } })
|
||||
const result = await runRule(linkQuotation as Rule, { strings: { markdown } })
|
||||
const errors = result.markdown
|
||||
expect(errors.length).toBe(13)
|
||||
expect(errors[0].errorRange).toEqual([14, 25])
|
||||
expect(errors[0].fixInfo.insertText).toBe('[A title](./image.png).')
|
||||
expect(errors[1].fixInfo.insertText).toBe('[A title](./image.png)?')
|
||||
expect(errors[0].fixInfo?.insertText).toBe('[A title](./image.png).')
|
||||
expect(errors[1].fixInfo?.insertText).toBe('[A title](./image.png)?')
|
||||
})
|
||||
})
|
||||
@@ -1,36 +0,0 @@
|
||||
export const tags = {
|
||||
note: 'accent',
|
||||
tip: 'success',
|
||||
warning: 'attention',
|
||||
danger: 'danger',
|
||||
}
|
||||
|
||||
const template =
|
||||
'<div class="ghd-alert ghd-alert-{{ color }} ghd-spotlight-{{ color }}">{{ output }}</div>'
|
||||
|
||||
export const Spotlight = {
|
||||
type: 'block',
|
||||
|
||||
parse(tagToken, remainTokens) {
|
||||
this.tagName = tagToken.name
|
||||
this.templates = []
|
||||
|
||||
const stream = this.liquid.parser.parseStream(remainTokens)
|
||||
stream
|
||||
.on(`tag:end${this.tagName}`, () => stream.stop())
|
||||
.on('template', (tpl) => this.templates.push(tpl))
|
||||
.on('end', () => {
|
||||
throw new Error(`tag ${tagToken.getText()} not closed`)
|
||||
})
|
||||
stream.start()
|
||||
},
|
||||
|
||||
render: function* (scope) {
|
||||
const output = yield this.liquid.renderer.renderTemplates(this.templates, scope)
|
||||
|
||||
return yield this.liquid.parseAndRender(template, {
|
||||
color: tags[this.tagName],
|
||||
output,
|
||||
})
|
||||
},
|
||||
}
|
||||
64
src/content-render/liquid/spotlight.ts
Normal file
64
src/content-render/liquid/spotlight.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
interface LiquidToken {
|
||||
name: string
|
||||
getText(): string
|
||||
}
|
||||
|
||||
interface LiquidTemplate {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface LiquidStream {
|
||||
on(event: string, callback: () => void): LiquidStream
|
||||
stop(): void
|
||||
start(): void
|
||||
}
|
||||
|
||||
interface LiquidEngine {
|
||||
parser: {
|
||||
parseStream(tokens: LiquidToken[]): LiquidStream
|
||||
}
|
||||
renderer: {
|
||||
renderTemplates(templates: LiquidTemplate[], scope: Record<string, unknown>): string
|
||||
}
|
||||
parseAndRender(template: string, context: Record<string, string>): string
|
||||
}
|
||||
|
||||
export const tags: Record<string, string> = {
|
||||
note: 'accent',
|
||||
tip: 'success',
|
||||
warning: 'attention',
|
||||
danger: 'danger',
|
||||
}
|
||||
|
||||
const template: string =
|
||||
'<div class="ghd-alert ghd-alert-{{ color }} ghd-spotlight-{{ color }}">{{ output }}</div>'
|
||||
|
||||
export const Spotlight = {
|
||||
type: 'block' as const,
|
||||
tagName: '' as string,
|
||||
templates: [] as LiquidTemplate[],
|
||||
liquid: null as LiquidEngine | null,
|
||||
|
||||
parse(tagToken: LiquidToken, remainTokens: LiquidToken[]): void {
|
||||
this.tagName = tagToken.name
|
||||
this.templates = []
|
||||
|
||||
const stream = this.liquid!.parser.parseStream(remainTokens)
|
||||
stream
|
||||
.on(`tag:end${this.tagName}`, () => stream.stop())
|
||||
.on('template', (tpl: LiquidTemplate) => this.templates.push(tpl))
|
||||
.on('end', () => {
|
||||
throw new Error(`tag ${tagToken.getText()} not closed`)
|
||||
})
|
||||
stream.start()
|
||||
},
|
||||
|
||||
render: function* (scope: Record<string, unknown>): Generator<unknown, unknown, unknown> {
|
||||
const output = yield this.liquid!.renderer.renderTemplates(this.templates, scope)
|
||||
|
||||
return yield this.liquid!.parseAndRender(template, {
|
||||
color: tags[this.tagName],
|
||||
output,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { visitParents } from 'unist-util-visit-parents'
|
||||
|
||||
/**
|
||||
* Where it can mutate the AST to swap from:
|
||||
*
|
||||
* <thead>
|
||||
* <tr>
|
||||
* <th>...</th>
|
||||
* <th>...</th>
|
||||
*
|
||||
* to:
|
||||
* <thead>
|
||||
* <tr>
|
||||
* <th scope="col">...</th>
|
||||
* <th scope="col">...</th>
|
||||
*
|
||||
* */
|
||||
|
||||
function matcher(node) {
|
||||
return node.type === 'element' && node.tagName === 'th' && !('scope' in node.properties)
|
||||
}
|
||||
|
||||
function visitor(node, ancestors) {
|
||||
const parent = ancestors.at(-1)
|
||||
if (parent && parent.tagName === 'tr') {
|
||||
const grandParent = ancestors.at(-2)
|
||||
if (grandParent && grandParent.tagName === 'thead') {
|
||||
node.properties.scope = 'col'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function rewriteTheadThScope() {
|
||||
return (tree) => visitParents(tree, matcher, visitor)
|
||||
}
|
||||
42
src/content-render/unified/rewrite-thead-th-scope.ts
Normal file
42
src/content-render/unified/rewrite-thead-th-scope.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { visitParents } from 'unist-util-visit-parents'
|
||||
import type { Root } from 'hast'
|
||||
import type { Transformer } from 'unified'
|
||||
|
||||
/**
|
||||
* Where it can mutate the AST to swap from:
|
||||
*
|
||||
* <thead>
|
||||
* <tr>
|
||||
* <th>...</th>
|
||||
* <th>...</th>
|
||||
*
|
||||
* to:
|
||||
* <thead>
|
||||
* <tr>
|
||||
* <th scope="col">...</th>
|
||||
* <th scope="col">...</th>
|
||||
*
|
||||
* */
|
||||
|
||||
function matcher(node: any): boolean {
|
||||
// Using any type due to complex type conflicts between different versions of
|
||||
// @types/hast and @types/unist used by various dependencies. The node should be
|
||||
// an Element with tagName 'th' and no existing 'scope' property.
|
||||
return node.type === 'element' && node.tagName === 'th' && !('scope' in node.properties)
|
||||
}
|
||||
|
||||
function visitor(node: any, ancestors: any[]): void {
|
||||
// Using any type for the same reason as matcher - complex type conflicts between
|
||||
// hast/unist type definitions across different package versions
|
||||
const parent = ancestors.at(-1)
|
||||
if (parent && parent.tagName === 'tr') {
|
||||
const grandParent = ancestors.at(-2)
|
||||
if (grandParent && grandParent.tagName === 'thead') {
|
||||
node.properties.scope = 'col'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function rewriteTheadThScope(): Transformer<Root> {
|
||||
return (tree: Root) => visitParents(tree, matcher, visitor)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { schema } from '@/frame/lib/frontmatter'
|
||||
|
||||
// Some learning tracks have `versions` blocks that match `versions` frontmatter,
|
||||
// so we can import that part of the FM schema.
|
||||
const versionsProps = Object.assign({}, schema.properties.versions)
|
||||
const versionsProps = Object.assign({}, (schema.properties as any).versions)
|
||||
|
||||
// `versions` are not required in learning tracks the way they are in FM.
|
||||
delete versionsProps.required
|
||||
@@ -1,4 +1,15 @@
|
||||
import { productMap } from '@/products/lib/all-products'
|
||||
|
||||
interface TocItem {
|
||||
type: 'category' | 'subcategory' | 'article'
|
||||
href: string
|
||||
}
|
||||
|
||||
interface Page {
|
||||
relativePath: string
|
||||
markdown: string
|
||||
}
|
||||
|
||||
const productTOCs = Object.values(productMap)
|
||||
.filter((product) => !product.external)
|
||||
.map((product) => product.toc.replace('content/', ''))
|
||||
@@ -7,7 +18,7 @@ const linkString = /{% [^}]*?link.*? \/(.*?) ?%}/m
|
||||
const linksArray = new RegExp(linkString.source, 'gm')
|
||||
|
||||
// return an array of objects like { type: 'category|subcategory|article', href: 'path' }
|
||||
export default function getTocItems(page) {
|
||||
export default function getTocItems(page: Page): TocItem[] | undefined {
|
||||
// only process product and category tocs
|
||||
if (!page.relativePath.endsWith('index.md')) return
|
||||
if (page.relativePath === 'index.md') return
|
||||
@@ -23,19 +34,24 @@ export default function getTocItems(page) {
|
||||
return []
|
||||
}
|
||||
|
||||
return rawItems.map((item) => {
|
||||
const tocItem = {}
|
||||
return rawItems
|
||||
.map((item: string) => {
|
||||
const match = item.match(linkString)
|
||||
if (!match) return null
|
||||
|
||||
// a product's toc items are always categories
|
||||
// whereas a category's toc items can be either subcategories or articles
|
||||
tocItem.type = productTOCs.includes(page.relativePath)
|
||||
? 'category'
|
||||
: item.includes('topic_')
|
||||
? 'subcategory'
|
||||
: 'article'
|
||||
const tocItem: TocItem = {} as TocItem
|
||||
|
||||
tocItem.href = item.match(linkString)[1]
|
||||
// a product's toc items are always categories
|
||||
// whereas a category's toc items can be either subcategories or articles
|
||||
tocItem.type = productTOCs.includes(page.relativePath)
|
||||
? 'category'
|
||||
: page.relativePath.includes('/index.md')
|
||||
? 'subcategory'
|
||||
: 'article'
|
||||
|
||||
return tocItem
|
||||
})
|
||||
tocItem.href = match[1]
|
||||
|
||||
return tocItem
|
||||
})
|
||||
.filter((item): item is TocItem => item !== null)
|
||||
}
|
||||
@@ -5,26 +5,35 @@ import walk from 'walk-sync'
|
||||
|
||||
import createTree from '@/frame/lib/create-tree'
|
||||
|
||||
interface Page {
|
||||
relativePath: string
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
page: Page
|
||||
childPages?: TreeNode[]
|
||||
}
|
||||
|
||||
describe('content files', () => {
|
||||
test.each(['content', 'src/fixtures/fixtures/content'])(
|
||||
'no content files left orphaned without being in the tree in %s',
|
||||
async (contentDir) => {
|
||||
async (contentDir: string) => {
|
||||
const tree = await createTree(contentDir)
|
||||
|
||||
const traverse = (node) => {
|
||||
const traverse = (node: TreeNode): string[] => {
|
||||
const relativeFiles = [node.page.relativePath]
|
||||
for (const child of node.childPages || []) {
|
||||
relativeFiles.push(...traverse(child))
|
||||
}
|
||||
return relativeFiles
|
||||
}
|
||||
const relativeFiles = traverse(tree).map((p) => path.join(contentDir, p))
|
||||
const relativeFiles = tree ? traverse(tree).map((p: string) => path.join(contentDir, p)) : []
|
||||
const contentFiles = walk(contentDir, { includeBasePath: true, directories: false }).filter(
|
||||
(file) => {
|
||||
(file: string) => {
|
||||
return file.endsWith('.md') && !file.includes('README')
|
||||
},
|
||||
)
|
||||
const orphanedFiles = contentFiles.filter((file) => !relativeFiles.includes(file))
|
||||
) as string[]
|
||||
const orphanedFiles = contentFiles.filter((file: string) => !relativeFiles.includes(file))
|
||||
expect(
|
||||
orphanedFiles.length,
|
||||
`${orphanedFiles} orphaned files found on disk but not in site tree`,
|
||||
@@ -18,7 +18,10 @@ describe('find page', () => {
|
||||
languageCode: 'en',
|
||||
})
|
||||
|
||||
const englishPermalink = page.permalinks[0].href
|
||||
const englishPermalink = page?.permalinks[0].href
|
||||
if (!page || !englishPermalink) {
|
||||
throw new Error('Page or permalink not found')
|
||||
}
|
||||
const redirectToFind = '/some-old-path'
|
||||
|
||||
// add named keys
|
||||
@@ -26,7 +29,12 @@ describe('find page', () => {
|
||||
[englishPermalink]: page,
|
||||
}
|
||||
|
||||
const redirectedPage = findPage(redirectToFind, pageMap, page.buildRedirects())
|
||||
expect(typeof redirectedPage.title).toBe('string')
|
||||
const redirectedPage = findPage(
|
||||
redirectToFind,
|
||||
pageMap as any, // Using any due to type conflicts between different Page type definitions
|
||||
page.buildRedirects(),
|
||||
)
|
||||
expect(redirectedPage).toBeDefined()
|
||||
expect(typeof redirectedPage?.title).toBe('string')
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,27 @@
|
||||
import { sentenceCase } from 'change-case'
|
||||
import GithubSlugger from 'github-slugger'
|
||||
|
||||
interface RawPreview {
|
||||
title: string
|
||||
toggled_on: string[]
|
||||
toggled_by: string
|
||||
announcement?: unknown
|
||||
updates?: unknown
|
||||
}
|
||||
|
||||
interface ProcessedPreview extends Omit<RawPreview, 'announcement' | 'updates'> {
|
||||
accept_header: string
|
||||
href: string
|
||||
}
|
||||
|
||||
const slugger = new GithubSlugger()
|
||||
const inputOrPayload = /(Input|Payload)$/m
|
||||
|
||||
export default function processPreviews(previews) {
|
||||
export default function processPreviews(previews: RawPreview[]): ProcessedPreview[] {
|
||||
// clean up raw yml data
|
||||
previews.forEach((preview) => {
|
||||
// Using any type because we're mutating the preview object to add new properties
|
||||
// that don't exist in the RawPreview interface (accept_header, href)
|
||||
previews.forEach((preview: any) => {
|
||||
preview.title = sentenceCase(preview.title)
|
||||
.replace(/ -.+/, '') // remove any extra info that follows a hyphen
|
||||
.replace('it hub', 'itHub') // fix overcorrected `git hub` from sentenceCasing
|
||||
@@ -16,7 +32,7 @@ export default function processPreviews(previews) {
|
||||
|
||||
// filter out schema members that end in `Input` or `Payload`
|
||||
preview.toggled_on = preview.toggled_on.filter(
|
||||
(schemaMember) => !inputOrPayload.test(schemaMember),
|
||||
(schemaMember: string) => !inputOrPayload.test(schemaMember),
|
||||
)
|
||||
|
||||
// remove unnecessary leading colon
|
||||
@@ -32,5 +48,5 @@ export default function processPreviews(previews) {
|
||||
preview.href = `/graphql/overview/schema-previews#${slugger.slug(preview.title)}`
|
||||
})
|
||||
|
||||
return previews
|
||||
return previews as ProcessedPreview[]
|
||||
}
|
||||
@@ -12,6 +12,13 @@ export const LOG_LEVELS = {
|
||||
warn: 1,
|
||||
info: 2,
|
||||
debug: 3,
|
||||
} as const
|
||||
|
||||
type LogLevel = keyof typeof LOG_LEVELS
|
||||
type LogLevelValue = (typeof LOG_LEVELS)[LogLevel]
|
||||
|
||||
function isValidLogLevel(level: string): level is LogLevel {
|
||||
return level in LOG_LEVELS
|
||||
}
|
||||
|
||||
// We set the log level based on the LOG_LEVEL environment variable
|
||||
@@ -19,19 +26,22 @@ export const LOG_LEVELS = {
|
||||
// - 'info' in development
|
||||
// - 'debug' in production
|
||||
// - 'debug' in test - this is because `vitest` turns off logs unless --silent=false is passed
|
||||
export function getLogLevelNumber() {
|
||||
let defaultLogLevel = 'info'
|
||||
export function getLogLevelNumber(): LogLevelValue {
|
||||
let defaultLogLevel: LogLevel = 'info'
|
||||
if (
|
||||
!process.env.LOG_LEVEL &&
|
||||
(process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test')
|
||||
) {
|
||||
defaultLogLevel = 'debug'
|
||||
}
|
||||
const logLevel = process.env.LOG_LEVEL?.toLowerCase() || defaultLogLevel
|
||||
|
||||
const envLogLevel = process.env.LOG_LEVEL?.toLowerCase() || defaultLogLevel
|
||||
const logLevel = isValidLogLevel(envLogLevel) ? envLogLevel : defaultLogLevel
|
||||
|
||||
return LOG_LEVELS[logLevel]
|
||||
}
|
||||
|
||||
export const useProductionLogging = () => {
|
||||
export const useProductionLogging = (): boolean => {
|
||||
return (
|
||||
(process.env.NODE_ENV === 'production' && !process.env.CI) ||
|
||||
process.env.LOG_LIKE_PRODUCTION === 'true'
|
||||
@@ -1,10 +1,26 @@
|
||||
import { readFile, writeFile } from 'fs/promises'
|
||||
|
||||
const STATIC_REDIRECTS = 'src/rest/data/client-side-rest-api-redirects.json'
|
||||
const REST_API_OVERRIDES = 'src/rest/lib/rest-api-overrides.json'
|
||||
|
||||
interface OperationUrl {
|
||||
originalUrl: string
|
||||
category: string
|
||||
subcategory?: string
|
||||
}
|
||||
|
||||
interface RestApiOverrides {
|
||||
operationUrls: Record<string, OperationUrl>
|
||||
sectionUrls: Record<string, string>
|
||||
}
|
||||
|
||||
interface RedirectMap {
|
||||
[oldUrl: string]: string
|
||||
}
|
||||
|
||||
// This is way to add redirects from one fragment to another from the
|
||||
// client's browser.
|
||||
export async function syncRestRedirects() {
|
||||
export async function syncRestRedirects(): Promise<void> {
|
||||
const clientSideRedirects = await getClientSideRedirects()
|
||||
|
||||
await writeFile(STATIC_REDIRECTS, JSON.stringify(clientSideRedirects, null, 2), 'utf8')
|
||||
@@ -13,11 +29,13 @@ export async function syncRestRedirects() {
|
||||
|
||||
// Reads in src/rest/lib/rest-api-overrides.json and generates the
|
||||
// redirect file src/rest/data/client-side-rest-api-redirects.json
|
||||
async function getClientSideRedirects() {
|
||||
const { operationUrls, sectionUrls } = JSON.parse(await readFile(REST_API_OVERRIDES, 'utf8'))
|
||||
async function getClientSideRedirects(): Promise<RedirectMap> {
|
||||
const { operationUrls, sectionUrls }: RestApiOverrides = JSON.parse(
|
||||
await readFile(REST_API_OVERRIDES, 'utf8'),
|
||||
)
|
||||
|
||||
const operationRedirects = {}
|
||||
Object.values(operationUrls).forEach((value) => {
|
||||
const operationRedirects: RedirectMap = {}
|
||||
Object.values(operationUrls).forEach((value: OperationUrl) => {
|
||||
const oldUrl = value.originalUrl.replace('/rest/reference', '/rest')
|
||||
const anchor = oldUrl.split('#')[1]
|
||||
const subcategory = value.subcategory
|
||||
@@ -26,7 +44,7 @@ async function getClientSideRedirects() {
|
||||
: `/rest/${value.category}#${anchor}`
|
||||
operationRedirects[oldUrl] = redirectTo
|
||||
})
|
||||
const redirects = {
|
||||
const redirects: RedirectMap = {
|
||||
...operationRedirects,
|
||||
...sectionUrls,
|
||||
}
|
||||
Reference in New Issue
Block a user