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

Migrate 13 JS files to TypeScript (#57881)

This commit is contained in:
Kevin Heis
2025-10-08 14:23:21 -07:00
committed by GitHub
parent 4a17001993
commit dc91be0a89
14 changed files with 228 additions and 97 deletions

View File

@@ -24,8 +24,8 @@ import ajv from '@/tests/lib/validate-json-schema'
// mdDict will be populated with: // mdDict will be populated with:
// //
// { '/foo/bar/0': 'item 1', '/foo/bar/1': 'item 2' } // { '/foo/bar/0': 'item 1', '/foo/bar/1': 'item 2' }
const mdDict = new Map() const mdDict = new Map<string, string>()
const lintableData = Object.keys(dataSchemas) const lintableData: string[] = Object.keys(dataSchemas)
// To redefine a custom keyword, you must remove it // To redefine a custom keyword, you must remove it
// then re-add it with the new definition. The default // then re-add it with the new definition. The default
@@ -37,7 +37,8 @@ ajv.addKeyword({
type: 'string', type: 'string',
// For docs on defining validate see // For docs on defining validate see
// https://ajv.js.org/keywords.html#define-keyword-with-validate-function // https://ajv.js.org/keywords.html#define-keyword-with-validate-function
validate: (compiled, data, schema, parentInfo) => { // Using any for validate function params because AJV's type definitions for custom keywords are complex
validate: (compiled: any, data: any, schema: any, parentInfo: any): boolean => {
mdDict.set(parentInfo.instancePath, data) mdDict.set(parentInfo.instancePath, data)
return true return true
}, },
@@ -55,13 +56,14 @@ ajv.addKeyword({
// back to the location in the original schema file, // back to the location in the original schema file,
// so we also need the parent path of the `lintable` // so we also need the parent path of the `lintable`
// property in the schema. // property in the schema.
export async function getLintableYml(dataFilePath) { export async function getLintableYml(dataFilePath: string): Promise<Record<string, string> | null> {
const matchingDataPath = lintableData.find( const matchingDataPath = lintableData.find(
(ref) => dataFilePath === ref || dataFilePath.startsWith(ref), (ref) => dataFilePath === ref || dataFilePath.startsWith(ref),
) )
if (!matchingDataPath) return null if (!matchingDataPath) return null
const schemaFilePath = dataSchemas[matchingDataPath] const schemaFilePath = dataSchemas[matchingDataPath]
if (!schemaFilePath) return null
const schema = (await import(schemaFilePath)).default const schema = (await import(schemaFilePath)).default
if (!schema) return null if (!schema) return null
@@ -78,13 +80,15 @@ export async function getLintableYml(dataFilePath) {
// back to a file in the data directory. // back to a file in the data directory.
// The resulting key looks like: // The resulting key looks like:
// 'data/variables/product.yml /pat_v1_caps' // 'data/variables/product.yml /pat_v1_caps'
function addPathToKey(mdDict, dataFilePath) { function addPathToKey(mdDict: Map<string, string>, dataFilePath: string): Map<string, string> {
const keys = Array.from(mdDict.keys()) const keys = Array.from(mdDict.keys())
keys.forEach((key) => { keys.forEach((key) => {
const newKey = `${dataFilePath} ${key}` const newKey = `${dataFilePath} ${key}`
const value = mdDict.get(key) const value = mdDict.get(key)
if (value !== undefined) {
mdDict.delete(key) mdDict.delete(key)
mdDict.set(newKey, value) mdDict.set(newKey, value)
}
}) })
return mdDict return mdDict
} }

View File

@@ -1,7 +1,15 @@
// @ts-ignore - markdownlint-rule-helpers doesn't provide TypeScript declarations
import { addError } from 'markdownlint-rule-helpers' import { addError } from 'markdownlint-rule-helpers'
import yaml from 'js-yaml' import yaml from 'js-yaml'
import { getRange, getFrontmatter } from '../helpers/utils' import { getRange, getFrontmatter } from '../helpers/utils'
import type { RuleParams, RuleErrorCallback, Rule } from '@/content-linter/types'
interface Frontmatter {
redirect_from?: string | string[]
children?: string[]
[key: string]: any
}
const ERROR_MESSAGE = const ERROR_MESSAGE =
'An early access reference appears to be used in a non-early access doc. Remove early access references or disable this rule.' 'An early access reference appears to be used in a non-early access doc. Remove early access references or disable this rule.'
@@ -10,20 +18,20 @@ const ERROR_MESSAGE =
// There are several existing allowed references to `early access` // There are several existing allowed references to `early access`
// as a GitHub feature. This rule focuses on references to early // as a GitHub feature. This rule focuses on references to early
// access pages. // access pages.
const isEarlyAccessFilepath = (filepath) => filepath.includes('early-access') const isEarlyAccessFilepath = (filepath: string): boolean => filepath.includes('early-access')
const EARLY_ACCESS_REGEX = /early-access/gi const EARLY_ACCESS_REGEX = /early-access/gi
// This is a pattern seen in link paths for articles about // This is a pattern seen in link paths for articles about
// early access. This pattern is ok. // early access. This pattern is ok.
const EARLY_ACCESS_ARTICLE_REGEX = /-early-access-/ const EARLY_ACCESS_ARTICLE_REGEX = /-early-access-/
export const earlyAccessReferences = { export const earlyAccessReferences: Rule = {
names: ['GHD008', 'early-access-references'], names: ['GHD008', 'early-access-references'],
description: description:
'Files that are not early access should not reference early-access or early-access files', 'Files that are not early access should not reference early-access or early-access files',
tags: ['feature', 'early-access'], tags: ['feature', 'early-access'],
severity: 'error', severity: 'error',
function: (params, onError) => { function: (params: RuleParams, onError: RuleErrorCallback) => {
if (isEarlyAccessFilepath(params.name)) return if (isEarlyAccessFilepath(params.name)) return
// Find errors in content // Find errors in content
@@ -44,17 +52,17 @@ export const earlyAccessReferences = {
}, },
} }
export const frontmatterEarlyAccessReferences = { export const frontmatterEarlyAccessReferences: Rule = {
names: ['GHD009', 'frontmatter-early-access-references'], names: ['GHD009', 'frontmatter-early-access-references'],
description: description:
'Files that are not early access should not have frontmatter that references early-access', 'Files that are not early access should not have frontmatter that references early-access',
tags: ['frontmatter', 'feature', 'early-access'], tags: ['frontmatter', 'feature', 'early-access'],
function: (params, onError) => { function: (params: RuleParams, onError: RuleErrorCallback) => {
const filepath = params.name const filepath = params.name
if (isEarlyAccessFilepath(filepath)) return if (isEarlyAccessFilepath(filepath)) return
// Find errors in frontmatter // Find errors in frontmatter
const fm = getFrontmatter(params.lines) const fm = getFrontmatter(params.lines) as Frontmatter | null
if (!fm) return if (!fm) return
// The redirect_from property is allowed to contain early-access paths // The redirect_from property is allowed to contain early-access paths

View File

@@ -1,5 +1,8 @@
// @ts-ignore - markdownlint-rule-helpers doesn't provide TypeScript declarations
import { addError, newLineRe } from 'markdownlint-rule-helpers' import { addError, newLineRe } from 'markdownlint-rule-helpers'
import type { RuleParams, RuleErrorCallback, MarkdownToken, Rule } from '@/content-linter/types'
// This rule looks for opening and closing HTML comment tags that // This rule looks for opening and closing HTML comment tags that
// contain an expiration date in the format: // contain an expiration date in the format:
// //
@@ -8,20 +11,20 @@ import { addError, newLineRe } from 'markdownlint-rule-helpers'
// //
// The `end expires` closing tag closes the content that is expired // The `end expires` closing tag closes the content that is expired
// and must be removed. // and must be removed.
export const expiredContent = { export const expiredContent: Rule = {
names: ['GHD038', 'expired-content'], names: ['GHD038', 'expired-content'],
description: 'Expired content must be remediated.', description: 'Expired content must be remediated.',
tags: ['expired'], tags: ['expired'],
function: (params, onError) => { function: (params: RuleParams, onError: RuleErrorCallback) => {
const tokensToCheck = params.tokens.filter( const tokensToCheck = (params.tokens || []).filter(
(token) => token.type === 'inline' || token.type === 'html_block', (token: MarkdownToken) => token.type === 'inline' || token.type === 'html_block',
) )
tokensToCheck.forEach((token) => { tokensToCheck.forEach((token: MarkdownToken) => {
// Looking for just opening tag with format: // Looking for just opening tag with format:
// <!-- expires yyyy-mm-dd --> // <!-- expires yyyy-mm-dd -->
const match = token.content.match(/<!--\s*expires\s(\d\d\d\d)-(\d\d)-(\d\d)\s*-->/) const match = token.content?.match(/<!--\s*expires\s(\d\d\d\d)-(\d\d)-(\d\d)\s*-->/)
if (!match) return if (!match || !token.content) return
const expireDate = new Date(match.splice(1, 3).join(' ')) const expireDate = new Date(match.splice(1, 3).join(' '))
const today = new Date() const today = new Date()
@@ -57,20 +60,20 @@ export const DAYS_TO_WARN_BEFORE_EXPIRED = 14
// //
// The `end expires` closing tag closes the content that is expired // The `end expires` closing tag closes the content that is expired
// and must be removed. // and must be removed.
export const expiringSoon = { export const expiringSoon: Rule = {
names: ['GHD039', 'expiring-soon'], names: ['GHD039', 'expiring-soon'],
description: 'Content that expires soon should be proactively addressed.', description: 'Content that expires soon should be proactively addressed.',
tags: ['expired'], tags: ['expired'],
function: (params, onError) => { function: (params: RuleParams, onError: RuleErrorCallback) => {
const tokensToCheck = params.tokens.filter( const tokensToCheck = (params.tokens || []).filter(
(token) => token.type === 'inline' || token.type === 'html_block', (token: MarkdownToken) => token.type === 'inline' || token.type === 'html_block',
) )
tokensToCheck.forEach((token) => { tokensToCheck.forEach((token: MarkdownToken) => {
// Looking for just opening tag with format: // Looking for just opening tag with format:
// <!-- expires yyyy-mm-dd --> // <!-- expires yyyy-mm-dd -->
const match = token.content.match(/<!--\s*expires\s(\d\d\d\d)-(\d\d)-(\d\d)\s*-->/) const match = token.content?.match(/<!--\s*expires\s(\d\d\d\d)-(\d\d)-(\d\d)\s*-->/)
if (!match) return if (!match || !token.content) return
const expireDate = new Date(match.splice(1, 3).join(' ')) const expireDate = new Date(match.splice(1, 3).join(' '))
const today = new Date() const today = new Date()

View File

@@ -1,12 +1,19 @@
// @ts-ignore - markdownlint-rule-helpers doesn't provide TypeScript declarations
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, Rule } from '@/content-linter/types'
export const frontmatterVersionsWhitespace = { interface Frontmatter {
versions?: Record<string, string | string[]>
[key: string]: any
}
export const frontmatterVersionsWhitespace: Rule = {
names: ['GHD051', 'frontmatter-versions-whitespace'], names: ['GHD051', 'frontmatter-versions-whitespace'],
description: 'Versions frontmatter should not contain unnecessary whitespace', description: 'Versions frontmatter should not contain unnecessary whitespace',
tags: ['frontmatter', 'versions'], tags: ['frontmatter', 'versions'],
function: (params, onError) => { function: (params: RuleParams, onError: RuleErrorCallback) => {
const fm = getFrontmatter(params.lines) const fm = getFrontmatter(params.lines) as Frontmatter | null
if (!fm || !fm.versions) return if (!fm || !fm.versions) return
const versionsObj = fm.versions const versionsObj = fm.versions
@@ -58,7 +65,7 @@ export const frontmatterVersionsWhitespace = {
* Allows whitespace in complex expressions like '<3.6 >3.8' * Allows whitespace in complex expressions like '<3.6 >3.8'
* but disallows leading/trailing whitespace * but disallows leading/trailing whitespace
*/ */
function checkForUnwantedWhitespace(value) { function checkForUnwantedWhitespace(value: string): boolean {
// Don't flag if the value is just whitespace or empty // Don't flag if the value is just whitespace or empty
if (!value || value.trim() === '') return false if (!value || value.trim() === '') return false
@@ -82,7 +89,7 @@ function checkForUnwantedWhitespace(value) {
/** /**
* Get the cleaned version of a value by removing appropriate whitespace * Get the cleaned version of a value by removing appropriate whitespace
*/ */
function getCleanedValue(value) { function getCleanedValue(value: string): string {
// For values with operators, just trim leading/trailing whitespace // For values with operators, just trim leading/trailing whitespace
const hasOperators = /[<>=]/.test(value) const hasOperators = /[<>=]/.test(value)
if (hasOperators) { if (hasOperators) {

View File

@@ -1,16 +1,26 @@
// @ts-ignore - markdownlint-rule-helpers doesn't provide TypeScript declarations
import { addError } from 'markdownlint-rule-helpers' import { addError } from 'markdownlint-rule-helpers'
import path from 'path' import path from 'path'
import { getFrontmatter } from '../helpers/utils' import { getFrontmatter } from '../helpers/utils'
import type { RuleParams, RuleErrorCallback, Rule } from '@/content-linter/types'
export const frontmatterVideoTranscripts = { interface Frontmatter {
product_video?: string
product_video_transcript?: string
title?: string
layout?: string
[key: string]: any
}
export const frontmatterVideoTranscripts: Rule = {
names: ['GHD011', 'frontmatter-video-transcripts'], names: ['GHD011', 'frontmatter-video-transcripts'],
description: 'Video transcript must be configured correctly', description: 'Video transcript must be configured correctly',
tags: ['frontmatter', 'feature', 'video-transcripts'], tags: ['frontmatter', 'feature', 'video-transcripts'],
function: (params, onError) => { function: (params: RuleParams, onError: RuleErrorCallback) => {
const filepath = params.name const filepath = params.name
const fm = getFrontmatter(params.lines) const fm = getFrontmatter(params.lines) as Frontmatter | null
if (!fm) return if (!fm) return
const isTranscriptContent = const isTranscriptContent =
@@ -29,7 +39,7 @@ export const frontmatterVideoTranscripts = {
null, // No fix possible null, // No fix possible
) )
} }
if (!fm.title.startsWith('Transcript - ')) { if (fm.title && !fm.title.startsWith('Transcript - ')) {
const lineNumber = params.lines.findIndex((line) => line.startsWith('title:')) + 1 const lineNumber = params.lines.findIndex((line) => line.startsWith('title:')) + 1
const lineContent = params.lines[lineNumber - 1] const lineContent = params.lines[lineNumber - 1]
addError( addError(

View File

@@ -1,16 +1,23 @@
// @ts-ignore - markdownlint-rule-helpers doesn't provide TypeScript declarations
import { addError } from 'markdownlint-rule-helpers' import { addError } from 'markdownlint-rule-helpers'
import { getRange } from '../helpers/utils' import { getRange } from '../helpers/utils'
import frontmatter from '@/frame/lib/read-frontmatter' import frontmatter from '@/frame/lib/read-frontmatter'
import type { RuleParams, RuleErrorCallback, Rule } from '@/content-linter/types'
export const multipleEmphasisPatterns = { interface Frontmatter {
autogenerated?: boolean
[key: string]: any
}
export const multipleEmphasisPatterns: Rule = {
names: ['GHD050', 'multiple-emphasis-patterns'], names: ['GHD050', 'multiple-emphasis-patterns'],
description: 'Do not use more than one emphasis/strong, italics, or uppercase for a string', description: 'Do not use more than one emphasis/strong, italics, or uppercase for a string',
tags: ['formatting', 'emphasis', 'style'], tags: ['formatting', 'emphasis', 'style'],
severity: 'warning', severity: 'warning',
function: (params, onError) => { function: (params: RuleParams, onError: RuleErrorCallback) => {
// Skip autogenerated files // Skip autogenerated files
const frontmatterString = params.frontMatterLines.join('\n') const frontmatterString = params.frontMatterLines.join('\n')
const fm = frontmatter(frontmatterString).data const fm = frontmatter(frontmatterString).data as Frontmatter
if (fm && fm.autogenerated) return if (fm && fm.autogenerated) return
const lines = params.lines const lines = params.lines
@@ -38,9 +45,9 @@ export const multipleEmphasisPatterns = {
/** /**
* Check for multiple emphasis types in a single text segment * Check for multiple emphasis types in a single text segment
*/ */
function checkMultipleEmphasis(line, lineNumber, onError) { function checkMultipleEmphasis(line: string, lineNumber: number, onError: RuleErrorCallback): void {
// Focus on the clearest violations of the style guide // Focus on the clearest violations of the style guide
const multipleEmphasisPatterns = [ const multipleEmphasisPatterns: Array<{ regex: RegExp; types: string[] }> = [
// Bold + italic combinations (***text***) // Bold + italic combinations (***text***)
{ regex: /\*\*\*([^*]+)\*\*\*/g, types: ['bold', 'italic'] }, { regex: /\*\*\*([^*]+)\*\*\*/g, types: ['bold', 'italic'] },
{ regex: /___([^_]+)___/g, types: ['bold', 'italic'] }, { regex: /___([^_]+)___/g, types: ['bold', 'italic'] },
@@ -76,7 +83,7 @@ function checkMultipleEmphasis(line, lineNumber, onError) {
/** /**
* Determine if a match should be skipped (likely intentional formatting) * Determine if a match should be skipped (likely intentional formatting)
*/ */
function shouldSkipMatch(fullMatch, content) { function shouldSkipMatch(fullMatch: string, content: string): boolean {
// Skip common false positives // Skip common false positives
if (!content) return true if (!content) return true

View File

@@ -1,4 +1,7 @@
import { addError, filterTokens } from 'markdownlint-rule-helpers' // @ts-ignore - markdownlint-rule-helpers doesn't provide TypeScript declarations
import { addError } from 'markdownlint-rule-helpers'
import type { RuleParams, RuleErrorCallback, Rule } from '@/content-linter/types'
// Detects a Markdown table delimiter row // Detects a Markdown table delimiter row
const delimiterRegexPure = /(\s)*(:)?(-+)(:)?(\s)*(\|)/ const delimiterRegexPure = /(\s)*(:)?(-+)(:)?(\s)*(\|)/
@@ -9,13 +12,13 @@ const liquidRegex = /^{%-?\s*(ifversion|else|endif).*-?%}/
// Detects a Markdown table row with a Liquid versioning tag // Detects a Markdown table row with a Liquid versioning tag
const liquidAfterRowRegex = /(\|{1}).*(\|{1}).*{%\s*(ifversion|else|endif).*%}$/ const liquidAfterRowRegex = /(\|{1}).*(\|{1}).*{%\s*(ifversion|else|endif).*%}$/
export const tableLiquidVersioning = { export const tableLiquidVersioning: Rule = {
names: ['GHD040', 'table-liquid-versioning'], names: ['GHD040', 'table-liquid-versioning'],
description: 'Tables must use the correct liquid versioning format', description: 'Tables must use the correct liquid versioning format',
severity: 'error', severity: 'error',
tags: ['tables'], tags: ['tables'],
function: function GHD040(params, onError) { function: function GHD040(params: RuleParams, onError: RuleErrorCallback) {
const lines = params.lines const lines = params.lines
let inTable = false let inTable = false
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
@@ -75,7 +78,7 @@ export const tableLiquidVersioning = {
}, },
} }
function isPreviousLineIndented(line, previousLine) { function isPreviousLineIndented(line: string, previousLine: string): boolean {
if (!line || !previousLine) return false if (!line || !previousLine) return false
const numWhitespaceLine = line.length - line.trimLeft().length const numWhitespaceLine = line.length - line.trimLeft().length
const numWhitespacePrevLine = previousLine.length - previousLine.trimLeft().length const numWhitespacePrevLine = previousLine.length - previousLine.trimLeft().length

View File

@@ -1,8 +1,10 @@
import yaml from 'js-yaml' // @ts-ignore - markdownlint-rule-helpers doesn't provide TypeScript declarations
import { addError, filterTokens } from 'markdownlint-rule-helpers' import { addError, filterTokens } from 'markdownlint-rule-helpers'
import yaml from 'js-yaml'
import { liquid } from '@/content-render/index' import { liquid } from '@/content-render/index'
import { allVersions } from '@/versions/lib/all-versions' import { allVersions } from '@/versions/lib/all-versions'
import type { RuleParams, RuleErrorCallback, MarkdownToken, Rule } from '@/content-linter/types'
// Detects third-party actions in the format `owner/repo@ref` // Detects third-party actions in the format `owner/repo@ref`
const actionRegex = /[\w-]+\/[\w-]+@[\w-]+/ const actionRegex = /[\w-]+\/[\w-]+@[\w-]+/
@@ -11,16 +13,33 @@ const shaRegex = /[\w-]+\/[\w-]+@[0-9a-fA-F]{40}/
// Detects first-party actions // Detects first-party actions
const firstPartyPrefixes = ['actions/', './.github/actions/', 'github/', 'octo-org/', 'OWNER/'] const firstPartyPrefixes = ['actions/', './.github/actions/', 'github/', 'octo-org/', 'OWNER/']
export const thirdPartyActionPinning = { interface WorkflowStep {
uses?: string
[key: string]: any
}
interface WorkflowJob {
steps?: WorkflowStep[]
[key: string]: any
}
interface WorkflowYaml {
jobs?: Record<string, WorkflowJob>
steps?: WorkflowStep[]
[key: string]: any
}
export const thirdPartyActionPinning: Rule = {
names: ['GHD041', 'third-party-action-pinning'], names: ['GHD041', 'third-party-action-pinning'],
description: description:
'Code examples that use third-party actions must always pin to a full length commit SHA', 'Code examples that use third-party actions must always pin to a full length commit SHA',
tags: ['feature', 'actions'], tags: ['feature', 'actions'],
parser: 'markdownit', parser: 'markdownit',
asynchronous: true, asynchronous: true,
function: (params, onError) => { function: (params: RuleParams, onError: RuleErrorCallback) => {
filterTokens(params, 'fence', async (token) => { filterTokens(params, 'fence', async (token: MarkdownToken) => {
const lang = token.info.trim().split(/\s+/u).shift().toLowerCase() if (!token.info || !token.content) return
const lang = token.info.trim().split(/\s+/u).shift()?.toLowerCase()
if (lang !== 'yaml' && lang !== 'yml') return if (lang !== 'yaml' && lang !== 'yml') return
if (!token.content.includes('steps:')) return if (!token.content.includes('steps:')) return
if (!token.content.includes('uses:')) return if (!token.content.includes('uses:')) return
@@ -32,7 +51,7 @@ export const thirdPartyActionPinning = {
// If we don't parse the Liquid first, yaml loading chokes on {% raw %} tags // If we don't parse the Liquid first, yaml loading chokes on {% raw %} tags
const renderedYaml = await liquid.parseAndRender(token.content, context) const renderedYaml = await liquid.parseAndRender(token.content, context)
try { try {
const yamlObj = yaml.load(renderedYaml) const yamlObj = yaml.load(renderedYaml) as WorkflowYaml
const steps = getWorkflowSteps(yamlObj) const steps = getWorkflowSteps(yamlObj)
if (!steps.some((step) => step.uses)) return if (!steps.some((step) => step.uses)) return
@@ -40,11 +59,13 @@ export const thirdPartyActionPinning = {
if (step.uses) { if (step.uses) {
const actionMatch = step.uses.match(actionRegex) const actionMatch = step.uses.match(actionRegex)
if (actionMatch) { if (actionMatch) {
const isFirstParty = firstPartyPrefixes.some((prefix) => step.uses.startsWith(prefix)) const isFirstParty = firstPartyPrefixes.some((prefix) =>
step.uses!.startsWith(prefix),
)
if (!isFirstParty && !shaRegex.test(step.uses)) { if (!isFirstParty && !shaRegex.test(step.uses)) {
addError( addError(
onError, onError,
getLineNumber(token.content, step.uses) + token.lineNumber, getLineNumber(token.content!, step.uses) + token.lineNumber,
'Code examples that use third-party actions must always pin to a full length commit SHA', 'Code examples that use third-party actions must always pin to a full length commit SHA',
step.uses, step.uses,
) )
@@ -64,7 +85,7 @@ export const thirdPartyActionPinning = {
}, },
} }
function getWorkflowSteps(yamlObj) { function getWorkflowSteps(yamlObj: WorkflowYaml): WorkflowStep[] {
if (yamlObj?.jobs) { if (yamlObj?.jobs) {
const jobs = Object.values(yamlObj.jobs) const jobs = Object.values(yamlObj.jobs)
return jobs.flatMap((job) => job.steps || []) return jobs.flatMap((job) => job.steps || [])
@@ -74,7 +95,7 @@ function getWorkflowSteps(yamlObj) {
return [] return []
} }
function getLineNumber(tokenContent, step) { function getLineNumber(tokenContent: string, step: string): number {
const contentLines = tokenContent.split('\n') const contentLines = tokenContent.split('\n')
return contentLines.findIndex((line) => line.includes(step)) + 1 return contentLines.findIndex((line) => line.includes(step)) + 1
} }

View File

@@ -1,4 +1,5 @@
import { TokenizationError } from 'liquidjs' import { TokenizationError } from 'liquidjs'
import type { TagToken, Liquid, Template } from 'liquidjs'
import { THROW_ON_EMPTY, DataReferenceError } from './error-handling' import { THROW_ON_EMPTY, DataReferenceError } from './error-handling'
import { getDataByLanguage } from '@/data-directory/lib/get-data' import { getDataByLanguage } from '@/data-directory/lib/get-data'
@@ -6,8 +7,22 @@ import { getDataByLanguage } from '@/data-directory/lib/get-data'
const Syntax = /([a-z0-9/\\_.\-[\]]+)/i const Syntax = /([a-z0-9/\\_.\-[\]]+)/i
const SyntaxHelp = "Syntax Error in 'data' - Valid syntax: data [path]" const SyntaxHelp = "Syntax Error in 'data' - Valid syntax: data [path]"
// Using any for scope because it has custom environments property not in Liquid's Scope type
interface CustomScope {
environments: any
[key: string]: any
}
interface DataTag {
path: string
tagToken: TagToken
liquid: Liquid
parse(tagToken: TagToken): void
render(scope: CustomScope): Promise<Template | undefined>
}
export default { export default {
parse(tagToken) { parse(tagToken: TagToken) {
if (!tagToken || !Syntax.test(tagToken.args)) { if (!tagToken || !Syntax.test(tagToken.args)) {
throw new TokenizationError(SyntaxHelp, tagToken) throw new TokenizationError(SyntaxHelp, tagToken)
} }
@@ -16,7 +31,7 @@ export default {
this.tagToken = tagToken this.tagToken = tagToken
}, },
async render(scope) { async render(scope: CustomScope) {
let text = getDataByLanguage(this.path, scope.environments.currentLanguage) let text = getDataByLanguage(this.path, scope.environments.currentLanguage)
if (text === undefined) { if (text === undefined) {
if (scope.environments.currentLanguage === 'en') { if (scope.environments.currentLanguage === 'en') {
@@ -35,9 +50,9 @@ export default {
return this.liquid.parseAndRender(text, scope.environments) return this.liquid.parseAndRender(text, scope.environments)
}, },
} } as DataTag
function handleIndent(tagToken, text) { function handleIndent(tagToken: TagToken, text: string): string {
// Any time what we're about to replace in here has more than one line, // Any time what we're about to replace in here has more than one line,
// if the use of `{% data ... %}` was itself indented, from the left, // if the use of `{% data ... %}` was itself indented, from the left,
// keep *that* indentation, in replaced output, for every line. // keep *that* indentation, in replaced output, for every line.
@@ -67,17 +82,20 @@ function handleIndent(tagToken, text) {
// When a reusable has multiple lines, and the input line is a blockquote, // When a reusable has multiple lines, and the input line is a blockquote,
// keep the blockquote character on every successive line. // keep the blockquote character on every successive line.
const blockquoteRegexp = /^\n?([ \t]*>[ \t]?)/ const blockquoteRegexp = /^\n?([ \t]*>[ \t]?)/
function handleBlockquote(tagToken, text) { function handleBlockquote(tagToken: TagToken, text: string): string {
// If the text isn't multiline, skip // If the text isn't multiline, skip
if (text.split('\n').length <= 1) return text if (text.split('\n').length <= 1) return text
// If the line with the liquid tag starts with a blockquote... // If the line with the liquid tag starts with a blockquote...
const { input, content } = tagToken const { input, content } = tagToken
if (!content) return text
const inputLine = input.split('\n').find((line) => line.includes(content)) const inputLine = input.split('\n').find((line) => line.includes(content))
if (!blockquoteRegexp.test(inputLine)) return text if (!inputLine || !blockquoteRegexp.test(inputLine)) return text
// Keep the character on successive lines // Keep the character on successive lines
const start = inputLine.match(blockquoteRegexp)[0] const match = inputLine.match(blockquoteRegexp)
if (!match) return text
const start = match[0]
return text return text
.split('\n') .split('\n')
.map((line, i) => { .map((line, i) => {

View File

@@ -1,27 +1,42 @@
import path from 'path' import path from 'path'
import { existsSync } from 'fs' import { existsSync } from 'fs'
import type { Response, NextFunction } from 'express'
import { ROOT } from '@/frame/lib/constants' import { ROOT } from '@/frame/lib/constants'
import Page from '@/frame/lib/page' import Page from '@/frame/lib/page'
import { languagePrefixPathRegex } from '@/languages/lib/languages' import { languagePrefixPathRegex } from '@/languages/lib/languages'
import type { ExtendedRequest } from '@/types'
interface FindPageOptions {
isDev?: boolean
contentRoot?: string
}
const englishPrefixRegex = /^\/en(\/|$)/ const englishPrefixRegex = /^\/en(\/|$)/
const CONTENT_ROOT = path.join(ROOT, 'content') const CONTENT_ROOT = path.join(ROOT, 'content')
export default async function findPage( export default async function findPage(
req, req: ExtendedRequest,
res, res: Response,
next, next: NextFunction,
// Express won't execute these but it makes it easier to unit test // Express won't execute these but it makes it easier to unit test
// the middleware. // the middleware.
{ isDev = process.env.NODE_ENV === 'development', contentRoot = CONTENT_ROOT } = {}, {
) { isDev = process.env.NODE_ENV === 'development',
contentRoot = CONTENT_ROOT,
}: FindPageOptions = {},
): Promise<any> {
// Filter out things like `/will/redirect` or `/_next/data/...` // Filter out things like `/will/redirect` or `/_next/data/...`
if (!languagePrefixPathRegex.test(req.pagePath)) { if (!req.pagePath || !languagePrefixPathRegex.test(req.pagePath)) {
return next() return next()
} }
let page = req.context.pages[req.pagePath] if (!req.context?.pages) {
return next()
}
// Using any for page because it's dynamically assigned properties (like version) that aren't in the Page type
let page: any = req.context.pages[req.pagePath]
if (page && isDev && englishPrefixRegex.test(req.pagePath)) { if (page && isDev && englishPrefixRegex.test(req.pagePath)) {
// The .applicableVersions and .permalinks properties are computed // The .applicableVersions and .permalinks properties are computed
// when the page is read in from disk. But when the initial tree // when the page is read in from disk. But when the initial tree
@@ -32,7 +47,14 @@ export default async function findPage(
const oldApplicableVersions = page.applicableVersions const oldApplicableVersions = page.applicableVersions
const oldPermalinks = page.permalinks const oldPermalinks = page.permalinks
page = await rereadByPath(req.pagePath, contentRoot, req.context.currentVersion) const rereadPage = await rereadByPath(
req.pagePath,
contentRoot,
req.context?.currentVersion || '',
)
if (rereadPage) {
page = rereadPage
}
if (reuseOldVersions) { if (reuseOldVersions) {
page.applicableVersions = oldApplicableVersions page.applicableVersions = oldApplicableVersions
page.permalinks = oldPermalinks page.permalinks = oldPermalinks
@@ -41,24 +63,28 @@ export default async function findPage(
// This can happen if the page we just re-read has changed which // This can happen if the page we just re-read has changed which
// versions it's available in (the `versions` frontmatter) meaning // versions it's available in (the `versions` frontmatter) meaning
// it might no longer be available on the current URL. // it might no longer be available on the current URL.
if (!page.applicableVersions.includes(req.context.currentVersion)) { if (
req.context?.currentVersion &&
!page.applicableVersions.includes(req.context.currentVersion)
) {
return res return res
.status(404) .status(404)
.send( .send(
`After re-reading the page, '${req.context.currentVersion}' is no longer an applicable version. ` + `After re-reading the page, '${req.context?.currentVersion}' is no longer an applicable version. ` +
'A restart is required.', 'A restart is required.',
) )
} }
} }
if (page) { if (page && req.context) {
req.context.page = page req.context.page = page
req.context.page.version = req.context.currentVersion // Note: Page doesn't have a version property, this might be setting it dynamically
;(req.context.page as any).version = req.context.currentVersion
// We can't depend on `page.hidden` because the dedicated search // We can't depend on `page.hidden` because the dedicated search
// results page is a hidden page but it needs to offer all possible // results page is a hidden page but it needs to offer all possible
// languages. // languages.
if (page.relativePath.startsWith('early-access')) { if (page.relativePath.startsWith('early-access') && req.context?.languages?.en) {
// Override the languages to be only English // Override the languages to be only English
req.context.languages = { req.context.languages = {
en: req.context.languages.en, en: req.context.languages.en,
@@ -69,8 +95,14 @@ export default async function findPage(
return next() return next()
} }
async function rereadByPath(uri, contentRoot, currentVersion) { async function rereadByPath(
const languageCode = uri.match(languagePrefixPathRegex)[1] uri: string,
contentRoot: string,
currentVersion: string,
): Promise<Page | null> {
const match = uri.match(languagePrefixPathRegex)
if (!match) return null
const languageCode = match[1]
const withoutLanguage = uri.replace(languagePrefixPathRegex, '/') const withoutLanguage = uri.replace(languagePrefixPathRegex, '/')
const withoutVersion = withoutLanguage.replace(`/${currentVersion}`, '') const withoutVersion = withoutLanguage.replace(`/${currentVersion}`, '')
// TODO: Support loading translations the same way. // TODO: Support loading translations the same way.
@@ -86,9 +118,10 @@ async function rereadByPath(uri, contentRoot, currentVersion) {
// if it can't read the file in from disk. E.g. a request for /en/non/existent. // if it can't read the file in from disk. E.g. a request for /en/non/existent.
// In other words, it's fine if it can't be read from disk. It'll get // In other words, it's fine if it can't be read from disk. It'll get
// handled and turned into a nice 404 message. // handled and turned into a nice 404 message.
return await Page.init({ const page = await Page.init({
basePath, basePath,
relativePath, relativePath,
languageCode, languageCode,
}) })
return page || null
} }

View File

@@ -2,8 +2,8 @@ import { describe, expect, test } from 'vitest'
import getMiniTocItems from '@/frame/lib/get-mini-toc-items' import getMiniTocItems from '@/frame/lib/get-mini-toc-items'
function generateHeading(h) { function generateHeading(h: string): (slug: string) => string {
return (slug) => `<${h} id="${slug}"> return (slug: string) => `<${h} id="${slug}">
<a href="${slug}" class="heading-link"> <a href="${slug}" class="heading-link">
${slug} ${slug}
</a> </a>

View File

@@ -129,7 +129,7 @@ async function renderInnerHTML(page: Page, permalink: Permalink) {
} }
await contextualize(req as ExtendedRequest, res as Response, next) await contextualize(req as ExtendedRequest, res as Response, next)
await shortVersions(req as ExtendedRequest, res as Response, next) await shortVersions(req as ExtendedRequest, res as Response, next)
await findPage(req, res, next) await findPage(req as ExtendedRequest, res as Response, next)
features(req as ExtendedRequest, res as Response, next) features(req as ExtendedRequest, res as Response, next)
const markdown = await liquid.parseAndRender(page.markdown, req.context) const markdown = await liquid.parseAndRender(page.markdown, req.context)

View File

@@ -4,6 +4,10 @@ import path from 'path'
import yaml from 'js-yaml' import yaml from 'js-yaml'
interface DataStructure {
[key: string]: string | DataStructure
}
// This helper class exists so you can create a temporary root directory // This helper class exists so you can create a temporary root directory
// full of data files. // full of data files.
// For example, if you want to unit test with files that are not part // For example, if you want to unit test with files that are not part
@@ -41,17 +45,24 @@ import yaml from 'js-yaml'
// will create a single <tempdir>/data/ui.yml file. // will create a single <tempdir>/data/ui.yml file.
// //
export class DataDirectory { export class DataDirectory {
constructor(data, root) { root: string
constructor(data: DataStructure, root?: string) {
this.root = root || this.createTempRoot('data-directory') this.root = root || this.createTempRoot('data-directory')
this.create(data) this.create(data)
} }
createTempRoot(prefix) { createTempRoot(prefix: string): string {
const fullPath = path.join(os.tmpdir(), prefix) const fullPath = path.join(os.tmpdir(), prefix)
return fs.mkdtempSync(fullPath) return fs.mkdtempSync(fullPath)
} }
create(data, root = null, isVariables = false, isReusables = false) { create(
data: DataStructure,
root: string | null = null,
isVariables = false,
isReusables = false,
): void {
const here = root || this.root const here = root || this.root
for (const [key, value] of Object.entries(data)) { for (const [key, value] of Object.entries(data)) {
@@ -60,7 +71,8 @@ export class DataDirectory {
fs.writeFileSync(path.join(here, `${key}.md`), value, 'utf-8') fs.writeFileSync(path.join(here, `${key}.md`), value, 'utf-8')
} else { } else {
fs.mkdirSync(path.join(here, key)) fs.mkdirSync(path.join(here, key))
this.create(value, path.join(here, key), false, true) // Using 'as' assertion because we know value must be an object when it's not a string in reusables context
this.create(value as DataStructure, path.join(here, key), false, true)
} }
} else if (isVariables) { } else if (isVariables) {
fs.writeFileSync(path.join(here, `${key}.yml`), yaml.dump(value), 'utf-8') fs.writeFileSync(path.join(here, `${key}.yml`), yaml.dump(value), 'utf-8')
@@ -70,19 +82,20 @@ export class DataDirectory {
} else { } else {
const there = path.join(here, key) const there = path.join(here, key)
fs.mkdirSync(there) fs.mkdirSync(there)
// Using 'as' assertions because nested directory values are always objects, not strings
if (key === 'reusables') { if (key === 'reusables') {
this.create(value, there, false, true) this.create(value as DataStructure, there, false, true)
} else if (key === 'variables') { } else if (key === 'variables') {
this.create(value, there, true, false) this.create(value as DataStructure, there, true, false)
} else { } else {
this.create(value, there) this.create(value as DataStructure, there)
} }
} }
} }
} }
} }
destroy() { destroy(): void {
fs.rmSync(this.root, { recursive: true }) fs.rmSync(this.root, { recursive: true })
} }
} }

View File

@@ -7,9 +7,13 @@ import { allVersions } from '@/versions/lib/all-versions'
import getApplicableVersions from '@/versions/lib/get-applicable-versions' import getApplicableVersions from '@/versions/lib/get-applicable-versions'
import { latest } from '@/versions/lib/enterprise-server-releases' import { latest } from '@/versions/lib/enterprise-server-releases'
interface Versions {
[key: string]: string | string[]
}
describe('Versions frontmatter', () => { describe('Versions frontmatter', () => {
test('wildcard', async () => { test('wildcard', async () => {
const versions = { const versions: Versions = {
fpt: '*', fpt: '*',
ghes: '*', ghes: '*',
} }
@@ -19,7 +23,7 @@ describe('Versions frontmatter', () => {
}) })
test('greater than', async () => { test('greater than', async () => {
const versions = { const versions: Versions = {
fpt: '*', fpt: '*',
ghes: '>3.2', ghes: '>3.2',
} }
@@ -28,7 +32,7 @@ describe('Versions frontmatter', () => {
}) })
test('less than', async () => { test('less than', async () => {
const versions = { const versions: Versions = {
fpt: '*', fpt: '*',
ghes: '<3.2', ghes: '<3.2',
} }
@@ -43,7 +47,7 @@ describe('general cases', () => {
expect.assertions(2) expect.assertions(2)
try { try {
getApplicableVersions('*') getApplicableVersions('*')
} catch (e) { } catch (e: any) {
expect(e).toBeInstanceOf(Error) expect(e).toBeInstanceOf(Error)
expect(e).toHaveProperty( expect(e).toHaveProperty(
'message', 'message',
@@ -57,13 +61,13 @@ describe('general cases', () => {
.filter((name) => name !== 'README.md') .filter((name) => name !== 'README.md')
.map((name) => path.basename(name, '.yml')) .map((name) => path.basename(name, '.yml'))
for (const possibleFeature of possibleFeatures) { for (const possibleFeature of possibleFeatures) {
const versions = { feature: possibleFeature } const versions: Versions = { feature: possibleFeature }
const applicableVersions = getApplicableVersions(versions) const applicableVersions = getApplicableVersions(versions)
expect(applicableVersions.every((v) => Object.keys(allVersions).includes(v))) expect(applicableVersions.every((v) => Object.keys(allVersions).includes(v)))
} }
// Same thing but as an array each time // Same thing but as an array each time
for (const possibleFeature of possibleFeatures) { for (const possibleFeature of possibleFeatures) {
const versions = { feature: [possibleFeature] } const versions: Versions = { feature: [possibleFeature] }
const applicableVersions = getApplicableVersions(versions) const applicableVersions = getApplicableVersions(versions)
expect(applicableVersions.every((v) => Object.keys(allVersions).includes(v))) expect(applicableVersions.every((v) => Object.keys(allVersions).includes(v)))
} }
@@ -78,7 +82,7 @@ describe('invalid versions', () => {
}) })
test('no valid versions found at all', () => { test('no valid versions found at all', () => {
const versions = { const versions: Versions = {
never: '*', never: '*',
heard: 'of', heard: 'of',
} }