diff --git a/src/content-linter/lib/diff-files.js b/src/content-linter/lib/diff-files.ts similarity index 94% rename from src/content-linter/lib/diff-files.js rename to src/content-linter/lib/diff-files.ts index 1617ca5668..a029dcb02d 100644 --- a/src/content-linter/lib/diff-files.js +++ b/src/content-linter/lib/diff-files.ts @@ -5,14 +5,14 @@ import fs from 'fs' // is unpredictable in GitHub Actions because of how it does `git clone`. // So we rely on environment variables instead. -export function getDiffFiles() { +export function getDiffFiles(): string[] { // Instead of testing every single file possible, if there's // an environment variable called `DIFF_FILES` or one called // `DIFF_FILE` then use that. // If `DIFF_FILES` is set, it's expected to be a space separated // string. If `DIFF_FILE` is set, it's expected to be a text file // which contains a space separated string. - const diffFiles = [] + const diffFiles: string[] = [] // Setting an environment variable called `DIFF_FILES` is optional. // But if and only if it's set, we will respect it. // And if it set, turn it into a cleaned up Set so it's made available diff --git a/src/content-linter/lib/linting-rules/code-fence-line-length.js b/src/content-linter/lib/linting-rules/code-fence-line-length.ts similarity index 55% rename from src/content-linter/lib/linting-rules/code-fence-line-length.js rename to src/content-linter/lib/linting-rules/code-fence-line-length.ts index 9aa3b7ea06..16b3ef9c23 100644 --- a/src/content-linter/lib/linting-rules/code-fence-line-length.js +++ b/src/content-linter/lib/linting-rules/code-fence-line-length.ts @@ -1,20 +1,24 @@ +// @ts-ignore - markdownlint-rule-helpers doesn't provide TypeScript declarations import { addError, filterTokens, newLineRe } from 'markdownlint-rule-helpers' -export const codeFenceLineLength = { +import type { RuleParams, RuleErrorCallback, MarkdownToken, Rule } from '@/content-linter/types' + +export const codeFenceLineLength: Rule = { names: ['GHD030', 'code-fence-line-length'], description: 'Code fence lines should not exceed a maximum length', tags: ['code', 'accessibility'], parser: 'markdownit', - function: (params, onError) => { - const MAX_LINE_LENGTH = String(params.config.maxLength || 60) - filterTokens(params, 'fence', (token) => { - const lines = token.content.split(newLineRe) - lines.forEach((line, index) => { + function: (params: RuleParams, onError: RuleErrorCallback) => { + const MAX_LINE_LENGTH: number = params.config?.maxLength || 60 + filterTokens(params, 'fence', (token: MarkdownToken) => { + if (!token.content) return + const lines: string[] = token.content.split(newLineRe) + lines.forEach((line: string, index: number) => { if (line.length > MAX_LINE_LENGTH) { // The token line number is the line number of the first line of the // code fence. We want to report the line number of the content within // the code fence so we need to add 1 + the index. - const lineNumber = token.lineNumber + index + 1 + const lineNumber: number = token.lineNumber + index + 1 addError( onError, lineNumber, diff --git a/src/content-render/liquid/post.js b/src/content-render/liquid/post.ts similarity index 62% rename from src/content-render/liquid/post.js rename to src/content-render/liquid/post.ts index 9f17fb426e..e618d58015 100644 --- a/src/content-render/liquid/post.js +++ b/src/content-render/liquid/post.ts @@ -1,16 +1,16 @@ // used below to remove extra newlines in TOC lists -const endLine = '\r?\n' -const blankLine = '\\s*?[\r\n]*' -const startNextLine = '[^\\S\r\n]*?[-\\*] foo @@ -22,7 +22,7 @@ function cleanUpListEmptyLines(template) { return template } -function cleanUpExtraEmptyLines(template) { +function cleanUpExtraEmptyLines(template: string): string { // this removes any extra newlines left by (now resolved) liquid // statements so that extra space doesn't mess with list numbering template = template.replace(/(\r?\n){3}/g, '\n\n') diff --git a/src/content-render/liquid/prompt.js b/src/content-render/liquid/prompt.ts similarity index 51% rename from src/content-render/liquid/prompt.js rename to src/content-render/liquid/prompt.ts index cc81688f93..a01ebfaedf 100644 --- a/src/content-render/liquid/prompt.js +++ b/src/content-render/liquid/prompt.ts @@ -1,17 +1,26 @@ -// src/content-render/liquid/prompt.js +// src/content-render/liquid/prompt.ts // Defines {% prompt %}…{% endprompt %} to wrap its content in and append the Copilot icon. +// @ts-ignore - @primer/octicons doesn't provide TypeScript declarations import octicons from '@primer/octicons' -export const Prompt = { +interface LiquidTag { + type: 'block' + templates?: any[] // Note: Using 'any' because liquidjs doesn't provide proper types for template objects + // Note: Using 'any' for liquid-related parameters because liquidjs doesn't provide comprehensive TypeScript definitions + parse(tagToken: any, remainTokens: any): void + render(scope: any): Generator +} + +export const Prompt: LiquidTag = { type: 'block', // Collect everything until {% endprompt %} - parse(tagToken, remainTokens) { + parse(tagToken: any, remainTokens: any): void { this.templates = [] const stream = this.liquid.parser.parseStream(remainTokens) stream - .on('template', (tpl) => this.templates.push(tpl)) + .on('template', (tpl: any) => this.templates.push(tpl)) .on('tag:endprompt', () => stream.stop()) .on('end', () => { throw new Error(`{% prompt %} tag not closed`) @@ -20,12 +29,12 @@ export const Prompt = { }, // Render the inner Markdown, wrap in , then append the SVG - render: function* (scope) { + render: function* (scope: any): Generator { const content = yield this.liquid.renderer.renderTemplates(this.templates, scope) // build a URL with the prompt text encoded as query parameter - const promptParam = encodeURIComponent(content) - const href = `https://github.com/copilot?prompt=${promptParam}` + const promptParam: string = encodeURIComponent(content as string) + const href: string = `https://github.com/copilot?prompt=${promptParam}` return `${content}${octicons.copilot.toSVG()}` }, } diff --git a/src/content-render/tests/prompt.js b/src/content-render/tests/prompt.ts similarity index 67% rename from src/content-render/tests/prompt.js rename to src/content-render/tests/prompt.ts index 029a0ef9d4..2eb453990d 100644 --- a/src/content-render/tests/prompt.js +++ b/src/content-render/tests/prompt.ts @@ -3,8 +3,8 @@ import { renderContent } from '@/content-render/index' describe('prompt tag', () => { test('wraps content in and appends svg', async () => { - const input = 'Here is your prompt: {% prompt %}example prompt text{% endprompt %}.' - const output = await renderContent(input) + const input: string = 'Here is your prompt: {% prompt %}example prompt text{% endprompt %}.' + const output: string = await renderContent(input) expect(output).toContain('example prompt text node.type === 'code' && node.lang - -export default function parseInfoString() { - return (tree) => { - visit(tree, matcher, (node) => { - node.meta = strToObj(node.meta) - - // Temporary, remove {:copy} to avoid highlight parse error in translations. - node.lang = node.lang.replace('{:copy}', '') - }) - } -} - -function strToObj(str) { - if (!str) return {} - return Object.fromEntries( - str - .split(/\s+/g) - .map((k) => k.split(/[:=]/)) // split by colon or equals sign - .map(([k, ...v]) => [k, v.length ? v.join(':') : true]), - ) -} diff --git a/src/content-render/unified/parse-info-string.ts b/src/content-render/unified/parse-info-string.ts new file mode 100644 index 0000000000..ef494bfd4c --- /dev/null +++ b/src/content-render/unified/parse-info-string.ts @@ -0,0 +1,45 @@ +// Based on https://spec.commonmark.org/0.30/#info-string +// Parse out info strings on fenced code blocks, example: +// ```javascript lineNumbers:left copy:all annotate +// becomes... +// node.lang = javascript +// node.meta = { lineNumbers: 'left', copy: 'all', annotate: true } +// Also parse equals signs, where id=some-id becomes { id: 'some-id' } + +import { visit } from 'unist-util-visit' + +interface CodeNode { + type: 'code' + lang?: string + meta?: string | Record + value: string +} + +// Note: Using 'any' for node because unist-util-visit's type constraints +// don't easily allow for proper code node typing without complex generics +const matcher = (node: any): node is CodeNode => node.type === 'code' && node.lang + +export default function parseInfoString() { + // Note: Using 'any' for tree because unified's AST types are complex and + // this function works with different tree types depending on the processor + return (tree: any) => { + visit(tree, matcher, (node: CodeNode) => { + node.meta = strToObj(node.meta as string) + + // Temporary, remove {:copy} to avoid highlight parse error in translations. + if (node.lang) { + node.lang = node.lang.replace('{:copy}', '') + } + }) + } +} + +function strToObj(str?: string): Record { + if (!str) return {} + return Object.fromEntries( + str + .split(/\s+/g) + .map((k: string) => k.split(/[:=]/)) // split by colon or equals sign + .map(([k, ...v]: string[]) => [k, v.length ? v.join(':') : true]), + ) +} diff --git a/src/content-render/unified/use-english-headings.js b/src/content-render/unified/use-english-headings.js deleted file mode 100644 index 73b7c93fb5..0000000000 --- a/src/content-render/unified/use-english-headings.js +++ /dev/null @@ -1,29 +0,0 @@ -import GithubSlugger from 'github-slugger' -import { encode } from 'html-entities' -import { toString } from 'hast-util-to-string' -import { visit } from 'unist-util-visit' -const slugger = new GithubSlugger() - -const matcher = (node) => node.type === 'element' && ['h2', 'h3', 'h4'].includes(node.tagName) - -// replace translated IDs and links in headings with English -export default function useEnglishHeadings({ englishHeadings }) { - if (!englishHeadings) return - return (tree) => { - visit(tree, matcher, (node) => { - slugger.reset() - // Get the plain text content of the heading node - const text = toString(node) - // find English heading in the collection - const englishHeading = englishHeadings[encode(text)] - // get English slug - const englishSlug = slugger.slug(englishHeading) - // use English slug for heading ID and link - if (englishSlug) { - // only use English slug if there is one, otherwise we'll end up with - // empty IDs - node.properties.id = englishSlug - } - }) - } -} diff --git a/src/content-render/unified/use-english-headings.ts b/src/content-render/unified/use-english-headings.ts new file mode 100644 index 0000000000..47b90c4e63 --- /dev/null +++ b/src/content-render/unified/use-english-headings.ts @@ -0,0 +1,41 @@ +import GithubSlugger from 'github-slugger' +import { encode } from 'html-entities' +import { toString } from 'hast-util-to-string' +import { visit } from 'unist-util-visit' + +const slugger = new GithubSlugger() + +// Note: Using 'any' for node because the unist/hast type system is complex and +// the visit function's type constraints don't easily allow for proper element typing +// without extensive type gymnastics. The runtime check ensures type safety. +const matcher = (node: any) => node.type === 'element' && ['h2', 'h3', 'h4'].includes(node.tagName) + +interface UseEnglishHeadingsOptions { + englishHeadings?: Record +} + +// replace translated IDs and links in headings with English +export default function useEnglishHeadings({ englishHeadings }: UseEnglishHeadingsOptions) { + if (!englishHeadings) return + // Note: Using 'any' for tree because unified's AST types are complex and + // this function works with different tree types depending on the processor + return (tree: any) => { + // Note: Using 'any' for node because visit() callback typing is restrictive + // and doesn't easily allow for proper element typing without complex generics + visit(tree, matcher, (node: any) => { + slugger.reset() + // Get the plain text content of the heading node + const text: string = toString(node) + // find English heading in the collection + const englishHeading: string = englishHeadings[encode(text)] + // get English slug + const englishSlug: string = slugger.slug(englishHeading) + // use English slug for heading ID and link + if (englishSlug) { + // only use English slug if there is one, otherwise we'll end up with + // empty IDs + node.properties.id = englishSlug + } + }) + } +} diff --git a/src/github-apps/scripts/enabled-list-schema.js b/src/github-apps/scripts/enabled-list-schema.ts similarity index 67% rename from src/github-apps/scripts/enabled-list-schema.js rename to src/github-apps/scripts/enabled-list-schema.ts index 1d805d62af..75ee13712d 100644 --- a/src/github-apps/scripts/enabled-list-schema.js +++ b/src/github-apps/scripts/enabled-list-schema.ts @@ -3,7 +3,23 @@ // src/github-apps/data/user-to-server-rest.json // and src/github-apps/data/fine-grained-pat.json -export default { +interface SchemaProperty { + description: string + type: string +} + +interface EnabledListSchema { + type: string + required: string[] + properties: { + slug: SchemaProperty + subcategory: SchemaProperty + verb: SchemaProperty + requestPath: SchemaProperty + } +} + +const schema: EnabledListSchema = { type: 'object', required: ['slug', 'subcategory', 'verb', 'requestPath'], properties: { @@ -25,3 +41,5 @@ export default { }, }, } + +export default schema diff --git a/src/rest/api/anchor-redirect.js b/src/rest/api/anchor-redirect.ts similarity index 72% rename from src/rest/api/anchor-redirect.js rename to src/rest/api/anchor-redirect.ts index 6eb0c3b122..e29a72b056 100644 --- a/src/rest/api/anchor-redirect.js +++ b/src/rest/api/anchor-redirect.ts @@ -7,12 +7,14 @@ import { REST_DATA_DIR } from '../lib/index' const clientSideRestAPIRedirects = readCompressedJsonFileFallbackLazily( path.join(REST_DATA_DIR, 'client-side-rest-api-redirects.json'), -) +) as () => Record const router = express.Router() // Returns a client side redirect if one exists for the given path. -router.get('/', function redirects(req, res) { +// Note: Using 'any' for req/res because Express types are complex and the +// function signature is constrained by the router.get() overloads +router.get('/', function redirects(req: any, res: any) { if (!req.query.path) { return res.status(400).send("Missing 'path' query string") } @@ -22,7 +24,7 @@ router.get('/', function redirects(req, res) { defaultCacheControl(res) - const redirectFrom = `${req.query.path}#${req.query.hash}` + const redirectFrom: string = `${req.query.path}#${req.query.hash}` res.status(200).send({ to: clientSideRestAPIRedirects()[redirectFrom] }) }) diff --git a/src/tests/helpers/schemas/site-tree-schema.js b/src/tests/helpers/schemas/site-tree-schema.js deleted file mode 100644 index 92e15e426c..0000000000 --- a/src/tests/helpers/schemas/site-tree-schema.js +++ /dev/null @@ -1,27 +0,0 @@ -const childPage = { - type: 'object', - required: ['href', 'page'], - properties: { - href: { - type: 'string', - }, - page: { - type: 'object', - required: ['title', 'relativePath', 'permalinks'], - properties: { - title: { - type: 'string', - }, - relativePath: { - type: 'string', - }, - permalinks: { - type: 'array', - minItems: 1, - }, - }, - }, - }, -} - -export default { childPage } diff --git a/src/tests/helpers/schemas/site-tree-schema.ts b/src/tests/helpers/schemas/site-tree-schema.ts new file mode 100644 index 0000000000..40e3722959 --- /dev/null +++ b/src/tests/helpers/schemas/site-tree-schema.ts @@ -0,0 +1,55 @@ +interface SchemaProperty { + type: string + minItems?: number +} + +interface PageProperties { + title: SchemaProperty + relativePath: SchemaProperty + permalinks: SchemaProperty +} + +interface PageSchema { + type: string + required: string[] + properties: PageProperties +} + +interface ChildPageProperties { + href: SchemaProperty + page: PageSchema +} + +interface ChildPageSchema { + type: string + required: string[] + properties: ChildPageProperties +} + +const childPage: ChildPageSchema = { + type: 'object', + required: ['href', 'page'], + properties: { + href: { + type: 'string', + }, + page: { + type: 'object', + required: ['title', 'relativePath', 'permalinks'], + properties: { + title: { + type: 'string', + }, + relativePath: { + type: 'string', + }, + permalinks: { + type: 'array', + minItems: 1, + }, + }, + }, + }, +} + +export default { childPage }