diff --git a/content/admin/managing-accounts-and-repositories/managing-repositories-in-your-enterprise/accessing-user-owned-repositories-in-your-enterprise.md b/content/admin/managing-accounts-and-repositories/managing-repositories-in-your-enterprise/accessing-user-owned-repositories-in-your-enterprise.md index f7cc5484824a..9c81caa350b6 100644 --- a/content/admin/managing-accounts-and-repositories/managing-repositories-in-your-enterprise/accessing-user-owned-repositories-in-your-enterprise.md +++ b/content/admin/managing-accounts-and-repositories/managing-repositories-in-your-enterprise/accessing-user-owned-repositories-in-your-enterprise.md @@ -17,7 +17,7 @@ redirect_from: If your enterprise uses {% data variables.product.prodname_emus %}, and you've allowed users to create repositories owned by their user accounts, you can temporarily access any user-owned repository within your enterprise. -When you temporarily access a repository, you get admin access to the repository for two hours. You can take all the same actions as the repository owner, including editing the repository, changing the repository's settings, transferring the repository, and deleting the repository. +When you temporarily access a repository, you get admin access to the repository for two hours. You can take all the same actions as the repository owner, including editing the repository, changing the repository's settings, transferring the repository, and deleting the repository. You can also use the temporary access to transfer repositories that are owned by suspended user accounts. The repository owner will receive an email informing them that you have enabled temporary access to the repository, and a `repo.temporary_access_granted` event will be added to the audit log for your enterprise and the user's security log. diff --git a/content/code-security/dependabot/working-with-dependabot/managing-pull-requests-for-dependency-updates.md b/content/code-security/dependabot/working-with-dependabot/managing-pull-requests-for-dependency-updates.md index cb33da4f06df..800a9948285e 100644 --- a/content/code-security/dependabot/working-with-dependabot/managing-pull-requests-for-dependency-updates.md +++ b/content/code-security/dependabot/working-with-dependabot/managing-pull-requests-for-dependency-updates.md @@ -57,18 +57,20 @@ By default, {% data variables.product.prodname_dependabot %} will stop rebasing You can use any of the following commands on a {% data variables.product.prodname_dependabot %} pull request. -* `@dependabot cancel merge` cancels a previously requested merge. -* `@dependabot close` closes the pull request and prevents {% data variables.product.prodname_dependabot %} from recreating that pull request. You can achieve the same result by closing the pull request manually. -* `@dependabot ignore this dependency` closes the pull request and prevents {% data variables.product.prodname_dependabot %} from creating any more pull requests for this dependency (unless you reopen the pull request or upgrade to the suggested version of the dependency yourself). -* `@dependabot ignore this major version` closes the pull request and prevents {% data variables.product.prodname_dependabot %} from creating any more pull requests for this major version (unless you reopen the pull request or upgrade to this major version yourself). -* `@dependabot ignore this minor version` closes the pull request and prevents {% data variables.product.prodname_dependabot %} from creating any more pull requests for this minor version (unless you reopen the pull request or upgrade to this minor version yourself). -* `@dependabot ignore this patch version` closes the pull request and prevents {% data variables.product.prodname_dependabot %} from creating any more pull requests for this patch version (unless you reopen the pull request or upgrade to this patch version yourself). -* `@dependabot merge` merges the pull request once your CI tests have passed. -* `@dependabot rebase` rebases the pull request. -* `@dependabot recreate` recreates the pull request, overwriting any edits that have been made to the pull request. -* `@dependabot reopen` reopens the pull request if the pull request is closed. -* `@dependabot show DEPENDENCY_NAME ignore conditions` retrieves information on the ignore conditions for the specified dependency, and comments on the pull request with a table that displays all ignore conditions for the dependency. For example, `@dependabot show express ignore conditions` would find all `ignore` conditions stored for the Express dependency, and comment on the pull request with that information. -* `@dependabot squash and merge` squashes and merges the pull request once your CI tests have passed. +| Command | Description | +| --- | --- | +| `@dependabot cancel merge` | Cancels a previously requested merge. | +| `@dependabot close` | Closes the pull request and prevents {% data variables.product.prodname_dependabot %} from recreating that pull request. You can achieve the same result by closing the pull request manually. | +| `@dependabot ignore this dependency` | Closes the pull request and prevents {% data variables.product.prodname_dependabot %} from creating any more pull requests for this dependency (unless you reopen the pull request or upgrade to the suggested version yourself). | +| `@dependabot ignore this major version` | Closes the pull request and prevents {% data variables.product.prodname_dependabot %} from creating any more pull requests for this major version (unless you reopen the pull request or upgrade to this major version yourself). | +| `@dependabot ignore this minor version` | Closes the pull request and prevents {% data variables.product.prodname_dependabot %} from creating any more pull requests for this minor version (unless you reopen the pull request or upgrade to this minor version yourself). | +| `@dependabot ignore this patch version` | Closes the pull request and prevents {% data variables.product.prodname_dependabot %} from creating any more pull requests for this patch version (unless you reopen the pull request or upgrade to this patch version yourself). | +| `@dependabot merge` | Merges the pull request once your CI tests have passed. | +| `@dependabot rebase` | Rebases the pull request. | +| `@dependabot recreate` | Recreates the pull request, overwriting any edits that have been made to the pull request. | +| `@dependabot reopen` | Reopens the pull request if the pull request is closed. | +| `@dependabot show DEPENDENCY_NAME ignore conditions` | Retrieves information on the ignore conditions for the specified dependency, and comments on the pull request with a table that displays all ignore conditions for the dependency. For example, `@dependabot show express ignore conditions` would find all `ignore` conditions stored for the Express dependency, and comment on the pull request with that information. | +| `@dependabot squash and merge` | Squashes and merges the pull request once your CI tests have passed. | {% data variables.product.prodname_dependabot %} will react with a "thumbs up" emoji to acknowledge the command, and may respond with a comment on the pull request. While {% data variables.product.prodname_dependabot %} usually responds quickly, some commands may take several minutes to complete if {% data variables.product.prodname_dependabot %} is busy processing other updates or commands. @@ -80,13 +82,15 @@ For more information, see [AUTOTITLE](/code-security/dependabot/working-with-dep In {% data variables.product.prodname_dependabot %} pull requests for grouped version updates and security updates, you can use comment commands to ignore and un-ignore updates for specific dependencies and versions. You can use any of the following commands to manage ignore conditions for grouped updates. -* `@dependabot ignore DEPENDENCY_NAME` closes the pull request and prevents {% data variables.product.prodname_dependabot %} from updating this dependency. -* `@dependabot ignore DEPENDENCY_NAME major version` closes the pull request and prevents {% data variables.product.prodname_dependabot %} from updating this dependency's major version. -* `@dependabot ignore DEPENDENCY_NAME minor version` closes the pull request and prevents {% data variables.product.prodname_dependabot %} from updating this dependency's minor version. -* `@dependabot ignore DEPENDENCY_NAME patch version` closes the pull request and prevents {% data variables.product.prodname_dependabot %} from updating this dependency's patch version. -* `@dependabot unignore *` closes the current pull request, clears all `ignore` conditions stored for all dependencies in the group, then opens a new pull request. -* `@dependabot unignore DEPENDENCY_NAME` closes the current pull request, clears all `ignore` conditions stored for the dependency, then opens a new pull request that includes available updates for the specified dependency. For example, `@dependabot unignore lodash` would open a new pull request that includes updates for the Lodash dependency. -* `@dependabot unignore DEPENDENCY_NAME IGNORE_CONDITION` closes the current pull request, clears the stored `ignore` condition, then opens a new pull request that includes available updates for the specified ignore condition. For example, `@dependabot unignore express [< 1.9, > 1.8.0]` would open a new pull request that includes updates for Express between versions 1.8.0 and 1.9.0. +| Command | Description | +| --- | --- | +| `@dependabot ignore DEPENDENCY_NAME` | Closes the pull request and prevents {% data variables.product.prodname_dependabot %} from updating this dependency. | +| `@dependabot ignore DEPENDENCY_NAME major version` | Closes the pull request and prevents {% data variables.product.prodname_dependabot %} from updating this dependency's major version. | +| `@dependabot ignore DEPENDENCY_NAME minor version` | Closes the pull request and prevents {% data variables.product.prodname_dependabot %} from updating this dependency's minor version. | +| `@dependabot ignore DEPENDENCY_NAME patch version` | Closes the pull request and prevents {% data variables.product.prodname_dependabot %} from updating this dependency's patch version. | +| `@dependabot unignore *` | Closes the current pull request, clears all `ignore` conditions stored for all dependencies in the group, then opens a new pull request. | +| `@dependabot unignore DEPENDENCY_NAME` | Closes the current pull request, clears all `ignore` conditions stored for the dependency, then opens a new pull request that includes available updates for the specified dependency. For example, `@dependabot unignore lodash` would open a new pull request that includes updates for the Lodash dependency. | +| `@dependabot unignore DEPENDENCY_NAME IGNORE_CONDITION` | Closes the current pull request, clears the stored `ignore` condition, then opens a new pull request that includes available updates for the specified ignore condition. For example, `@dependabot unignore express [< 1.9, > 1.8.0]` would open a new pull request that includes updates for Express between versions 1.8.0 and 1.9.0. | > [!TIP] > When you want to un-ignore a specific ignore condition, use the `@dependabot show DEPENDENCY_NAME ignore conditions` command to quickly check what ignore conditions a dependency currently has. diff --git a/content/copilot/concepts/auto-model-selection.md b/content/copilot/concepts/auto-model-selection.md index 834dbe19fdfa..99d2221e3a7e 100644 --- a/content/copilot/concepts/auto-model-selection.md +++ b/content/copilot/concepts/auto-model-selection.md @@ -17,7 +17,7 @@ category: Experience less rate limiting and reduce the mental load of choosing a model by letting {% data variables.copilot.copilot_auto_model_selection %} automatically choose the best available model on your behalf. -{% data variables.copilot.copilot_auto_model_selection %} is currently optimized for model availability, choosing from a list of models that may change over time. It currently chooses from {% data variables.copilot.copilot_gpt_41 %}, {% data variables.copilot.copilot_gpt_5_mini %}, {% data variables.copilot.copilot_gpt_5 %}, {% data variables.copilot.copilot_claude_haiku_45 %}, and {% data variables.copilot.copilot_claude_sonnet_45 %}, based on your subscription type. +{% data variables.copilot.copilot_auto_model_selection %} is currently optimized for model availability, choosing from a list of models that may change over time. It currently chooses from {% data variables.copilot.copilot_gpt_41 %}, {% data variables.copilot.copilot_gpt_5_mini %}, {% data variables.copilot.copilot_gpt_51_codex_max %}, {% data variables.copilot.copilot_claude_haiku_45 %}, {% data variables.copilot.copilot_claude_sonnet_45 %}, and {% data variables.copilot.copilot_gemini_3_pro %}, based on your subscription type. With {% data variables.copilot.copilot_auto_model_selection %}, you benefit from: * Reduced chances of rate limiting diff --git a/content/copilot/how-tos/use-copilot-agents/coding-agent/extend-coding-agent-with-mcp.md b/content/copilot/how-tos/use-copilot-agents/coding-agent/extend-coding-agent-with-mcp.md index 85e30cfffabe..9565ea77b49c 100644 --- a/content/copilot/how-tos/use-copilot-agents/coding-agent/extend-coding-agent-with-mcp.md +++ b/content/copilot/how-tos/use-copilot-agents/coding-agent/extend-coding-agent-with-mcp.md @@ -310,14 +310,21 @@ If you want to allow {% data variables.product.prodname_copilot_short %} to acce "github-mcp-server": { "type": "http", // Remove "/readonly" to enable wider access to all tools. - // Then, use the "tools" key to specify the subset of tools you'd like to include. + // Then, use the "X-MCP-Toolsets" header to specify which toolsets you'd like to include. + // Use the "tools" field to select individual tools from the toolsets. "url": "https://api.githubcopilot.com/mcp/readonly", - "tools": ["*"] + "tools": ["*"], + "headers": { + "X-MCP-Toolsets": "repos,issues,users,pull_requests,code_security,secret_protection,actions,web_search" + } } } } ``` + +For more information on toolsets, refer to the [README](https://github.com/github/github-mcp-server?tab=readme-ov-file#available-toolsets) in the {% data variables.product.github %} Remote MCP Server documentation. + 1. Click **Save**. {% data reusables.actions.sidebar-environment %} 1. Click the `copilot` environment. diff --git a/content/copilot/reference/ai-models/model-hosting.md b/content/copilot/reference/ai-models/model-hosting.md index 498e0558fa97..03015a18d5f2 100644 --- a/content/copilot/reference/ai-models/model-hosting.md +++ b/content/copilot/reference/ai-models/model-hosting.md @@ -27,6 +27,7 @@ Used for: * {% data variables.copilot.copilot_gpt_51 %} * {% data variables.copilot.copilot_gpt_51_codex %} * {% data variables.copilot.copilot_gpt_51_codex_mini %} +* {% data variables.copilot.copilot_gpt_51_codex_max %} These models are hosted by OpenAI and {% data variables.product.github %}'s Azure infrastructure. diff --git a/data/tables/copilot/model-multipliers.yml b/data/tables/copilot/model-multipliers.yml index e84196b9e475..be527e0b1deb 100644 --- a/data/tables/copilot/model-multipliers.yml +++ b/data/tables/copilot/model-multipliers.yml @@ -69,6 +69,10 @@ multiplier_paid: 0.33 multiplier_free: Not applicable +- name: GPT-5.1-Codex-Max + multiplier_paid: 1.0 + multiplier_free: Not applicable + - name: Grok Code Fast 1 multiplier_paid: 0.25 multiplier_free: Not applicable diff --git a/data/tables/copilot/model-release-status.yml b/data/tables/copilot/model-release-status.yml index d20ba3f0bacc..ccec7b367bd9 100644 --- a/data/tables/copilot/model-release-status.yml +++ b/data/tables/copilot/model-release-status.yml @@ -67,6 +67,13 @@ ask_mode: true edit_mode: true +- name: 'GPT-5.1-Codex-Max' + provider: 'OpenAI' + release_status: 'Public preview' + agent_mode: true + ask_mode: true + edit_mode: true + # Anthropic models - name: 'Claude Haiku 4.5' provider: 'Anthropic' diff --git a/data/tables/copilot/model-supported-clients.yml b/data/tables/copilot/model-supported-clients.yml index 63caed0b87f7..db706336e61d 100644 --- a/data/tables/copilot/model-supported-clients.yml +++ b/data/tables/copilot/model-supported-clients.yml @@ -125,6 +125,14 @@ xcode: true jetbrains: true +- name: GPT-5.1-Codex-Max + dotcom: true + vscode: true + vs: false + eclipse: false + xcode: false + jetbrains: false + - name: Grok Code Fast 1 dotcom: true vscode: true diff --git a/data/tables/copilot/model-supported-plans.yml b/data/tables/copilot/model-supported-plans.yml index c0db26603d48..2a4fdd5f5f1f 100644 --- a/data/tables/copilot/model-supported-plans.yml +++ b/data/tables/copilot/model-supported-plans.yml @@ -110,6 +110,13 @@ business: true enterprise: true +- name: GPT-5.1-Codex-Max + free: false + pro: true + pro_plus: true + business: true + enterprise: true + - name: Grok Code Fast 1 free: false pro: true diff --git a/data/variables/copilot.yml b/data/variables/copilot.yml index ca733c518085..8d0846baa8cf 100644 --- a/data/variables/copilot.yml +++ b/data/variables/copilot.yml @@ -158,6 +158,7 @@ copilot_gpt_5_mini: 'GPT-5 mini' copilot_gpt_51: 'GPT-5.1' copilot_gpt_51_codex: 'GPT-5.1-Codex' copilot_gpt_51_codex_mini: 'GPT-5.1-Codex-Mini' +copilot_gpt_51_codex_max: 'GPT-5.1-Codex-Max' # OpenAI 'o' series: copilot_o3: 'o3' copilot_o4_mini: 'o4-mini' diff --git a/src/article-api/README.md b/src/article-api/README.md index fd9b26a1d5cb..28c749bd6b02 100644 --- a/src/article-api/README.md +++ b/src/article-api/README.md @@ -13,12 +13,24 @@ Article API endpoints allow consumers to query GitHub Docs for listings of curre The `/api/article/meta` endpoint powers hovercards, which provide a preview for internal links on . +The `/api/article/body` endpoint can serve markdown for both regular articles and autogenerated content (such as REST API documentation) using specialized transformers. + ## How it works The `/api/article` endpoints return information about a page by `pathname`. `api/article/meta` is highly cached, in JSON format. +### Autogenerated Content Transformers + +For autogenerated pages (REST, landing pages, audit logs, webhooks, GraphQL, etc), the Article API uses specialized transformers to convert the rendered content into markdown format. These transformers are located in `src/article-api/transformers/` and use an extensible architecture: + +To add a new transformer for other autogenerated content types: +1. Create a new transformer file implementing the `PageTransformer` interface +2. Register it in `transformers/index.ts` +3. Create a template in `templates/` to configure how the transformer will organize the autogenerated content +4. The transformer will automatically be used by `/api/article/body` + ## How to get help For internal folks ask in the Docs Engineering slack channel. @@ -34,12 +46,13 @@ Get article metadata and content in a single object. Equivalent to calling `/art **Parameters**: - **pathname** (string) - Article path (e.g. '/en/get-started/article-name') +- **[apiVersion]** (string) - API version for REST pages (optional, defaults to latest) **Returns**: (object) - JSON object with article metadata and content (`meta` and `body` keys) **Throws**: - (Error): 403 - If the article body cannot be retrieved. Reason is given in the error message. -- (Error): 400 - If pathname parameter is invalid. +- (Error): 400 - If pathname or apiVersion parameters are invalid. - (Error): 404 - If the path is valid, but the page couldn't be resolved. **Example**: @@ -63,12 +76,13 @@ Get the contents of an article's body. **Parameters**: - **pathname** (string) - Article path (e.g. '/en/get-started/article-name') +- **[apiVersion]** (string) - API version (optional, defaults to latest) **Returns**: (string) - Article body content in markdown format. **Throws**: - (Error): 403 - If the article body cannot be retrieved. Reason is given in the error message. -- (Error): 400 - If pathname parameter is invalid. +- (Error): 400 - If pathname or apiVersion parameters are invalid. - (Error): 404 - If the path is valid, but the page couldn't be resolved. **Example**: diff --git a/src/article-api/liquid-renderers/index.ts b/src/article-api/liquid-renderers/index.ts new file mode 100644 index 000000000000..b25734dbad69 --- /dev/null +++ b/src/article-api/liquid-renderers/index.ts @@ -0,0 +1,16 @@ +/** + * API Transformer Liquid Tags + * + * This module contains custom Liquid tags used by article-api transformers + * to render API documentation in a consistent format. + */ + +import { restTags } from './rest-tags' + +// Export all API transformer tags for registration +export const apiTransformerTags = { + ...restTags, +} + +// Re-export individual tag modules for direct access if needed +export { restTags } from './rest-tags' diff --git a/src/article-api/liquid-renderers/rest-tags.ts b/src/article-api/liquid-renderers/rest-tags.ts new file mode 100644 index 000000000000..864644287a87 --- /dev/null +++ b/src/article-api/liquid-renderers/rest-tags.ts @@ -0,0 +1,230 @@ +import type { TagToken, Context as LiquidContext } from 'liquidjs' +import { fastTextOnly } from '@/content-render/unified/text-only' +import { renderContent } from '@/content-render/index' +import type { Context } from '@/types' +import type { Parameter, BodyParameter, ChildParameter, StatusCode } from '@/rest/components/types' +import { createLogger } from '@/observability/logger' + +const logger = createLogger('article-api/liquid-renderers/rest-tags') + +/** + * Custom Liquid tag for rendering REST API parameters + * Usage: {% rest_parameter param %} + */ +export class RestParameter { + private paramName: string + + constructor( + token: TagToken, + remainTokens: TagToken[], + liquid: { options: any; parser: any }, + private liquidContext?: LiquidContext, + ) { + // The tag receives the parameter object from the template context + this.paramName = token.args.trim() + } + + async render(ctx: LiquidContext, emitter: any): Promise { + const param = ctx.get([this.paramName]) as Parameter + const context = ctx.get(['context']) as Context + + if (!param) { + emitter.write('') + return + } + + const lines: string[] = [] + const required = param.required ? ' (required)' : '' + const type = param.schema?.type || 'string' + + lines.push(`- **\`${param.name}\`** (${type})${required}`) + + if (param.description) { + const description = await htmlToMarkdown(param.description, context) + lines.push(` ${description}`) + } + + if (param.schema?.default !== undefined) { + lines.push(` Default: \`${param.schema.default}\``) + } + + if (param.schema?.enum && param.schema.enum.length > 0) { + lines.push(` Can be one of: ${param.schema.enum.map((v) => `\`${v}\``).join(', ')}`) + } + + emitter.write(lines.join('\n')) + } +} + +/** + * Custom Liquid tag for rendering REST API body parameters + * Usage: {% rest_body_parameter param indent %} + */ +export class RestBodyParameter { + constructor( + token: TagToken, + remainTokens: TagToken[], + liquid: { options: any; parser: any }, + private liquidContext?: LiquidContext, + ) { + // Parse arguments - param name and optional indent level + const args = token.args.trim().split(/\s+/) + this.param = args[0] + this.indent = args[1] ? parseInt(args[1]) : 0 + } + + private param: string + private indent: number + + async render(ctx: LiquidContext, emitter: any): Promise { + const param = ctx.get([this.param]) as BodyParameter + const context = ctx.get(['context']) as Context + const indent = this.indent + + if (!param) { + emitter.write('') + return + } + + const lines: string[] = [] + const prefix = ' '.repeat(indent) + const required = param.isRequired ? ' (required)' : '' + const type = param.type || 'string' + + lines.push(`${prefix}- **\`${param.name}\`** (${type})${required}`) + + if (param.description) { + const description = await htmlToMarkdown(param.description, context) + lines.push(`${prefix} ${description}`) + } + + if (param.default !== undefined) { + lines.push(`${prefix} Default: \`${param.default}\``) + } + + if (param.enum && param.enum.length > 0) { + lines.push(`${prefix} Can be one of: ${param.enum.map((v) => `\`${v}\``).join(', ')}`) + } + + // Handle nested parameters + if (param.childParamsGroups && param.childParamsGroups.length > 0) { + for (const childGroup of param.childParamsGroups) { + lines.push(await renderChildParameter(childGroup, context, indent + 1)) + } + } + + emitter.write(lines.join('\n')) + } +} + +/** + * Custom Liquid tag for rendering REST API status codes + * Usage: {% rest_status_code statusCode %} + */ +export class RestStatusCode { + private statusCodeName: string + + constructor( + token: TagToken, + remainTokens: TagToken[], + liquid: { options: any; parser: any }, + private liquidContext?: LiquidContext, + ) { + this.statusCodeName = token.args.trim() + } + + async render(ctx: LiquidContext, emitter: any): Promise { + const statusCode = ctx.get([this.statusCodeName]) as StatusCode + const context = ctx.get(['context']) as Context + + if (!statusCode) { + emitter.write('') + return + } + + const lines: string[] = [] + + if (statusCode.description) { + const description = await htmlToMarkdown(statusCode.description, context) + lines.push(`- **${statusCode.httpStatusCode}**`) + if (description.trim()) { + lines.push(` ${description.trim()}`) + } + } else if (statusCode.httpStatusMessage) { + lines.push(`- **${statusCode.httpStatusCode}** - ${statusCode.httpStatusMessage}`) + } else { + lines.push(`- **${statusCode.httpStatusCode}**`) + } + + emitter.write(lines.join('\n')) + } +} + +/** + * Helper function to render child parameters recursively + */ +async function renderChildParameter( + param: ChildParameter, + context: Context, + indent: number, +): Promise { + const lines: string[] = [] + const prefix = ' '.repeat(indent) + const required = param.isRequired ? ' (required)' : '' + const type = param.type || 'string' + + lines.push(`${prefix}- **\`${param.name}\`** (${type})${required}`) + + if (param.description) { + const description = await htmlToMarkdown(param.description, context) + lines.push(`${prefix} ${description}`) + } + + if (param.default !== undefined) { + lines.push(`${prefix} Default: \`${param.default}\``) + } + + if (param.enum && param.enum.length > 0) { + lines.push(`${prefix} Can be one of: ${param.enum.map((v: string) => `\`${v}\``).join(', ')}`) + } + + // Recursively handle nested parameters + if (param.childParamsGroups && param.childParamsGroups.length > 0) { + for (const child of param.childParamsGroups) { + lines.push(await renderChildParameter(child, context, indent + 1)) + } + } + + return lines.join('\n') +} + +/** + * Helper function to convert HTML to markdown + */ +async function htmlToMarkdown(html: string, context: Context): Promise { + if (!html) return '' + + try { + const rendered = await renderContent(html, context, { textOnly: false }) + return fastTextOnly(rendered) + } catch (error) { + logger.error('Failed to render HTML content to markdown in REST tag', { + error, + html: html.substring(0, 100), // First 100 chars for context + contextInfo: context && context.page ? { page: context.page.relativePath } : undefined, + }) + // In non-production, re-throw to aid debugging + if (process.env.NODE_ENV !== 'production') { + throw error + } + // Fallback to simple text extraction + return fastTextOnly(html) + } +} + +// Export tag names for registration +export const restTags = { + rest_parameter: RestParameter, + rest_body_parameter: RestBodyParameter, + rest_status_code: RestStatusCode, +} diff --git a/src/article-api/middleware/article-body.ts b/src/article-api/middleware/article-body.ts index 8ed00fa67ecb..a47dc865d29a 100644 --- a/src/article-api/middleware/article-body.ts +++ b/src/article-api/middleware/article-body.ts @@ -3,20 +3,15 @@ import type { Response } from 'express' import { Context } from '@/types' import { ExtendedRequestWithPageInfo } from '@/article-api/types' import contextualize from '@/frame/middleware/context/context' +import { transformerRegistry } from '@/article-api/transformers' +import { allVersions } from '@/versions/lib/all-versions' +import type { Page } from '@/types' -export async function getArticleBody(req: ExtendedRequestWithPageInfo) { - // req.pageinfo is set from pageValidationMiddleware and pathValidationMiddleware - // and is in the ExtendedRequestWithPageInfo - const { page, pathname, archived } = req.pageinfo - - if (archived?.isArchived) - throw new Error(`Page ${pathname} is archived and can't be rendered in markdown.`) - // for anything that's not an article (like index pages), don't try to render and - // tell the user what's going on - if (page.documentType !== 'article') { - throw new Error(`Page ${pathname} isn't yet available in markdown.`) - } - // these parts allow us to render the page +/** + * Creates a mocked rendering request and contextualizes it. + * This is used to prepare a request for rendering pages in markdown format. + */ +async function createContextualizedRenderingRequest(pathname: string, page: Page) { const mockedContext: Context = {} const renderingReq = { path: pathname, @@ -29,9 +24,51 @@ export async function getArticleBody(req: ExtendedRequestWithPageInfo) { }, } - // contextualize and render the page + // contextualize the request to get proper version info await contextualize(renderingReq as ExtendedRequestWithPageInfo, {} as Response, () => {}) renderingReq.context.page = page + + return renderingReq +} + +export async function getArticleBody(req: ExtendedRequestWithPageInfo) { + // req.pageinfo is set from pageValidationMiddleware and pathValidationMiddleware + // and is in the ExtendedRequestWithPageInfo + const { page, pathname, archived } = req.pageinfo + + if (archived?.isArchived) + throw new Error(`Page ${pathname} is archived and can't be rendered in markdown.`) + + // Extract apiVersion from query params if provided + const apiVersion = req.query.apiVersion as string | undefined + + // Check if there's a transformer for this page type (e.g., REST, webhooks, etc.) + const transformer = transformerRegistry.findTransformer(page) + + if (transformer) { + // Use the transformer for autogenerated pages + const renderingReq = await createContextualizedRenderingRequest(pathname, page) + + // Determine the API version to use (provided or latest) + // Validation is handled by apiVersionValidationMiddleware + const currentVersion = renderingReq.context.currentVersion + let effectiveApiVersion = apiVersion + + // Use latest version if not provided + if (!effectiveApiVersion && currentVersion && allVersions[currentVersion]) { + effectiveApiVersion = allVersions[currentVersion].latestApiVersion || undefined + } + + return await transformer.transform(page, pathname, renderingReq.context, effectiveApiVersion) + } + + // For regular articles (non-autogenerated) + if (page.documentType !== 'article') { + throw new Error(`Page ${pathname} isn't yet available in markdown.`) + } + + // these parts allow us to render the page + const renderingReq = await createContextualizedRenderingRequest(pathname, page) renderingReq.context.markdownRequested = true return await page.render(renderingReq.context) } diff --git a/src/article-api/middleware/article.ts b/src/article-api/middleware/article.ts index 2d73067da152..3bbb79cc628f 100644 --- a/src/article-api/middleware/article.ts +++ b/src/article-api/middleware/article.ts @@ -4,7 +4,11 @@ import express from 'express' import { defaultCacheControl } from '@/frame/middleware/cache-control' import catchMiddlewareError from '@/observability/middleware/catch-middleware-error' import { ExtendedRequestWithPageInfo } from '../types' -import { pageValidationMiddleware, pathValidationMiddleware } from './validation' +import { + pageValidationMiddleware, + pathValidationMiddleware, + apiVersionValidationMiddleware, +} from './validation' import { getArticleBody } from './article-body' import { getMetadata } from './article-pageinfo' import { @@ -24,9 +28,10 @@ const router = express.Router() * Get article metadata and content in a single object. Equivalent to calling `/article/meta` concatenated with `/article/body`. * @route GET /api/article * @param {string} pathname - Article path (e.g. '/en/get-started/article-name') + * @param {string} [apiVersion] - API version for REST pages (optional, defaults to latest) * @returns {object} JSON object with article metadata and content (`meta` and `body` keys) * @throws {Error} 403 - If the article body cannot be retrieved. Reason is given in the error message. - * @throws {Error} 400 - If pathname parameter is invalid. + * @throws {Error} 400 - If pathname or apiVersion parameters are invalid. * @throws {Error} 404 - If the path is valid, but the page couldn't be resolved. * @example * ❯ curl -s "https://docs.github.com/api/article?pathname=/en/get-started/start-your-journey/about-github-and-git" @@ -43,6 +48,7 @@ router.get( '/', pathValidationMiddleware as RequestHandler, pageValidationMiddleware as RequestHandler, + apiVersionValidationMiddleware as RequestHandler, catchMiddlewareError(async function (req: ExtendedRequestWithPageInfo, res: Response) { const { meta, cacheInfo } = await getMetadata(req) let bodyContent @@ -66,9 +72,10 @@ router.get( * Get the contents of an article's body. * @route GET /api/article/body * @param {string} pathname - Article path (e.g. '/en/get-started/article-name') + * @param {string} [apiVersion] - API version (optional, defaults to latest) * @returns {string} Article body content in markdown format. * @throws {Error} 403 - If the article body cannot be retrieved. Reason is given in the error message. - * @throws {Error} 400 - If pathname parameter is invalid. + * @throws {Error} 400 - If pathname or apiVersion parameters are invalid. * @throws {Error} 404 - If the path is valid, but the page couldn't be resolved. * @example * ❯ curl -s https://docs.github.com/api/article/body\?pathname=/en/get-started/start-your-journey/about-github-and-git @@ -83,6 +90,7 @@ router.get( '/body', pathValidationMiddleware as RequestHandler, pageValidationMiddleware as RequestHandler, + apiVersionValidationMiddleware as RequestHandler, catchMiddlewareError(async function (req: ExtendedRequestWithPageInfo, res: Response) { let bodyContent try { diff --git a/src/article-api/middleware/validation.ts b/src/article-api/middleware/validation.ts index 1a92afff647b..6ef27385ccc9 100644 --- a/src/article-api/middleware/validation.ts +++ b/src/article-api/middleware/validation.ts @@ -6,6 +6,7 @@ import { isArchivedVersionByPath } from '@/archives/lib/is-archived-version' import getRedirect from '@/redirects/lib/get-redirect' import { getVersionStringFromPath, getLangFromPath } from '@/frame/lib/path-utils' import nonEnterpriseDefaultVersion from '@/versions/lib/non-enterprise-default-version' +import { allVersions } from '@/versions/lib/all-versions' // validates the path for pagelist endpoint // specifically, defaults to `/en/free-pro-team@latest` when those values are missing @@ -123,3 +124,47 @@ export const pageValidationMiddleware = ( return next() } + +export const apiVersionValidationMiddleware = ( + req: ExtendedRequestWithPageInfo, + res: Response, + next: NextFunction, +) => { + const apiVersion = req.query.apiVersion as string | string[] | undefined + + // If no apiVersion is provided, continue (it will default to latest) + if (!apiVersion) { + return next() + } + + // Validate apiVersion is a single string, not an array + if (Array.isArray(apiVersion)) { + return res.status(400).json({ error: "Multiple 'apiVersion' keys" }) + } + + // Get the version from the pathname query parameter + const pathname = req.pageinfo?.pathname || (req.query.pathname as string) + if (!pathname) { + // This should not happen as pathValidationMiddleware runs first + throw new Error('pathname not available for apiVersion validation') + } + + // Extract version from the pathname + const currentVersion = getVersionStringFromPath(pathname) || nonEnterpriseDefaultVersion + const versionInfo = allVersions[currentVersion] + + if (!versionInfo) { + return res.status(400).json({ error: `Invalid version '${currentVersion}'` }) + } + + const validApiVersions = versionInfo.apiVersions || [] + + // If this version has API versioning, validate the provided version + if (validApiVersions.length > 0 && !validApiVersions.includes(apiVersion)) { + return res.status(400).json({ + error: `Invalid apiVersion '${apiVersion}' for ${currentVersion}. Valid API versions are: ${validApiVersions.join(', ')}`, + }) + } + + return next() +} diff --git a/src/article-api/templates/rest-page.template.md b/src/article-api/templates/rest-page.template.md new file mode 100644 index 000000000000..50a9a25f02f1 --- /dev/null +++ b/src/article-api/templates/rest-page.template.md @@ -0,0 +1,100 @@ +# {{ page.title }} + +{{ page.intro }} + +{{ manualContent }} + +{% for operation in restOperations %} +## {{ operation.title }} + +``` +{{ operation.verb | upcase }} {{ operation.requestPath }} +``` + +{{ operation.description }} + +{% if operation.hasParameters %} +### Parameters + +{% if operation.showHeaders %} +#### Headers + +{% if operation.needsContentTypeHeader %} +- **`content-type`** (string, required) + Setting to `application/json` is required. + +{% endif %} +- **`accept`** (string) + Setting to `application/vnd.github+json` is recommended. + +{% endif %} + +{% if operation.parameters.size > 0 %} +#### Path and query parameters + +{% for param in operation.parameters %} +{% rest_parameter param %} +{% endfor %} + +{% endif %} + +{% if operation.bodyParameters.size > 0 %} +#### Body parameters + +{% for param in operation.bodyParameters %} +{% rest_body_parameter param %} +{% endfor %} + +{% endif %} +{% endif %} + +{% if operation.statusCodes.size > 0 %} +### HTTP response status codes + +{% for statusCode in operation.statusCodes %} +- **{{ statusCode.httpStatusCode }}**{% if statusCode.description %} - {{ statusCode.description }}{% elsif statusCode.httpStatusMessage %} - {{ statusCode.httpStatusMessage }}{% endif %} + +{% endfor %} +{% endif %} + +{% if operation.codeExamples.size > 0 %} +### Code examples + +{% for example in operation.codeExamples %} +{% if example.request.description %} +#### {{ example.request.description }} + +{% endif %} +**Request:** + +```curl +curl -L \ + -X {{ operation.verb | upcase }} \ + {{ example.request.url }} \ +{%- if example.request.acceptHeader %} + -H "Accept: {{ example.request.acceptHeader }}" \ +{%- endif %} + -H "Authorization: Bearer "{% if apiVersion %} \ + -H "X-GitHub-Api-Version: {{ apiVersion }}"{% endif -%} +{%- if example.request.bodyParameters %} \ + -d '{{ example.request.bodyParameters }}'{% endif %} +``` + +**Response schema:** + +{% if example.response.schema %} +```json +Status: {{ example.response.statusCode }} + +{{ example.response.schema }} +``` +{% else %} +``` +Status: {{ example.response.statusCode }} +``` +{% endif %} + +{% endfor %} +{% endif %} + +{% endfor %} diff --git a/src/article-api/tests/rest-transformer.ts b/src/article-api/tests/rest-transformer.ts new file mode 100644 index 000000000000..f306102197f6 --- /dev/null +++ b/src/article-api/tests/rest-transformer.ts @@ -0,0 +1,309 @@ +import { beforeAll, describe, expect, test } from 'vitest' + +import { get } from '@/tests/helpers/e2etest' + +const makeURL = (pathname: string, apiVersion?: string): string => { + const params = new URLSearchParams({ pathname }) + if (apiVersion) { + params.set('apiVersion', apiVersion) + } + return `/api/article/body?${params}` +} + +describe('REST transformer', () => { + beforeAll(() => { + if (!process.env.ROOT) { + console.warn( + 'WARNING: The REST transformer tests require the ROOT environment variable to be set to the fixture root', + ) + } + }) + + test('REST page renders with markdown structure', async () => { + const res = await get(makeURL('/en/rest/actions/artifacts')) + expect(res.statusCode).toBe(200) + expect(res.headers['content-type']).toContain('text/markdown') + + // Check for the main heading + expect(res.body).toContain('# GitHub Actions Artifacts') + + // Check for intro (using fixture's prodname_actions which is 'HubGit Actions') + expect(res.body).toContain('Use the REST API to interact with artifacts in HubGit Actions.') + + // Check for manual content section heading + expect(res.body).toContain('## About artifacts in HubGit Actions') + }) + + test('REST operations are formatted correctly', async () => { + const res = await get(makeURL('/en/rest/actions/artifacts')) + expect(res.statusCode).toBe(200) + + // Check for operation heading + expect(res.body).toContain('## List artifacts for a repository') + + // Check for HTTP method and endpoint + expect(res.body).toContain('GET /repos/{owner}/{repo}/actions/artifacts') + + // Check for operation description + expect(res.body).toContain('Lists all artifacts for a repository.') + }) + + test('Parameters section includes headers', async () => { + const res = await get(makeURL('/en/rest/actions/artifacts')) + expect(res.statusCode).toBe(200) + + // Check for parameters heading + expect(res.body).toContain('### Parameters') + + // Check for headers section + expect(res.body).toContain('#### Headers') + + // Check for accept header + expect(res.body).toContain('**`accept`** (string)') + expect(res.body).toContain('Setting to `application/vnd.github+json` is recommended.') + }) + + test('Path and query parameters are listed', async () => { + const res = await get(makeURL('/en/rest/actions/artifacts')) + expect(res.statusCode).toBe(200) + + // Check for path and query parameters section + expect(res.body).toContain('#### Path and query parameters') + + // Check for specific parameters + expect(res.body).toContain('**`owner`** (string) (required)') + expect(res.body).toContain('The account owner of the repository.') + + expect(res.body).toContain('**`repo`** (string) (required)') + + expect(res.body).toContain('**`per_page`** (integer)') + expect(res.body).toContain('Default: `30`') + }) + + test('Status codes are formatted correctly', async () => { + const res = await get(makeURL('/en/rest/actions/artifacts')) + expect(res.statusCode).toBe(200) + + // Check for status codes section + expect(res.body).toContain('### HTTP response status codes') + + // Check for specific status code + expect(res.body).toContain('**200**') + expect(res.body).toContain('OK') + }) + + test('Code examples include curl with proper formatting', async () => { + const res = await get(makeURL('/en/rest/actions/artifacts')) + expect(res.statusCode).toBe(200) + + // Check for code examples section + expect(res.body).toContain('### Code examples') + + // Check for request/response labels + expect(res.body).toContain('**Request:**') + expect(res.body).toContain('**Response schema:**') + + // Check for curl code block + expect(res.body).toContain('```curl') + expect(res.body).toContain('curl -L \\') + expect(res.body).toContain('-X GET \\') + expect(res.body).toContain('https://api.github.com/repos/OWNER/REPO/actions/artifacts \\') + expect(res.body).toContain('-H "Accept: application/vnd.github.v3+json" \\') + expect(res.body).toContain('-H "Authorization: Bearer "') + }) + + test('Code examples include X-GitHub-Api-Version header by default', async () => { + const res = await get(makeURL('/en/rest/actions/artifacts')) + expect(res.statusCode).toBe(200) + + // Check for API version header in curl example + expect(res.body).toContain('-H "X-GitHub-Api-Version: 2022-11-28"') + }) + + test('Code examples include specified API version', async () => { + const res = await get(makeURL('/en/rest/actions/artifacts', '2022-11-28')) + expect(res.statusCode).toBe(200) + + // Check for the specified API version header + expect(res.body).toContain('-H "X-GitHub-Api-Version: 2022-11-28"') + }) + + test('Liquid tags are rendered in intro', async () => { + const res = await get(makeURL('/en/rest/actions/artifacts')) + expect(res.statusCode).toBe(200) + + // Liquid tags should be rendered, not shown as raw tags (fixture uses 'HubGit Actions') + expect(res.body).toContain('HubGit Actions') + expect(res.body).not.toContain('{% data variables.product.prodname_actions %}') + + // Check in both the intro and the manual content section + expect(res.body).toMatch(/Use the REST API to interact with artifacts in HubGit Actions/) + expect(res.body).toMatch(/About artifacts in HubGit Actions/) + }) + + test('AUTOTITLE links are resolved', async () => { + const res = await get(makeURL('/en/rest/actions/artifacts')) + expect(res.statusCode).toBe(200) + + // Check that AUTOTITLE has been resolved to actual link text + // The link should have the actual page title, not "AUTOTITLE" + expect(res.body).toContain('[Storing workflow data as artifacts]') + expect(res.body).toContain('(/en/actions/using-workflows/storing-workflow-data-as-artifacts)') + + // Make sure the raw AUTOTITLE tag is not present + expect(res.body).not.toContain('[AUTOTITLE]') + + // Verify the link appears in the manual content section + expect(res.body).toMatch( + /About artifacts in HubGit Actions[\s\S]*Storing workflow data as artifacts/, + ) + }) + + test('Markdown links are preserved in descriptions', async () => { + const res = await get(makeURL('/en/rest/actions/artifacts')) + expect(res.statusCode).toBe(200) + + // Check that markdown links are preserved + expect(res.body).toMatch(/\[.*?\]\(\/en\/.*?\)/) + }) + + test('Response schema is formatted correctly', async () => { + const res = await get(makeURL('/en/rest/actions/artifacts')) + expect(res.statusCode).toBe(200) + + // Check for JSON code block with schema label + expect(res.body).toContain('**Response schema:**') + expect(res.body).toContain('```json') + expect(res.body).toContain('Status: 200') + + // Verify schema structure is present (not an example) + expect(res.body).toContain('"type":') + expect(res.body).toContain('"properties":') + + // Check for common schema keywords + const schemaMatch = res.body.match(/```json\s+Status: 200\s+([\s\S]*?)```/) + expect(schemaMatch).toBeTruthy() + + if (schemaMatch) { + const schemaContent = schemaMatch[1] + const schema = JSON.parse(schemaContent) + + // Verify it's a valid OpenAPI/JSON schema structure + expect(schema).toHaveProperty('type') + expect(schema.type).toBe('object') + expect(schema).toHaveProperty('properties') + + // Verify it has expected properties for artifacts response + expect(schema.properties).toHaveProperty('total_count') + expect(schema.properties).toHaveProperty('artifacts') + } + }) + + test('Non-REST pages return appropriate error', async () => { + const res = await get(makeURL('/en/get-started/start-your-journey/hello-world')) + expect(res.statusCode).toBe(200) + + // Regular article pages should still work, they just won't use the transformer + expect(res.body).toContain('## Introduction') + }) + + test('Invalid apiVersion returns 400 error', async () => { + // An invalid API version should return a validation error with 400 status + const res = await get(makeURL('/en/rest/actions/artifacts', 'invalid-version')) + + // Returns 400 because the apiVersion is invalid (client error) + expect(res.statusCode).toBe(400) + const parsed = JSON.parse(res.body) + expect(parsed.error).toContain("Invalid apiVersion 'invalid-version'") + expect(parsed.error).toContain('Valid API versions are:') + expect(parsed.error).toContain('2022-11-28') + }) + + test('Multiple apiVersion query parameters returns 400 error', async () => { + // Multiple apiVersion parameters should be rejected + const res = await get( + '/api/article/body?pathname=/en/rest/actions/artifacts&apiVersion=2022-11-28&apiVersion=2023-01-01', + ) + + expect(res.statusCode).toBe(400) + const parsed = JSON.parse(res.body) + expect(parsed.error).toBe("Multiple 'apiVersion' keys") + }) + + test('Valid apiVersion passes validation', async () => { + // A valid API version should work + const res = await get(makeURL('/en/rest/actions/artifacts', '2022-11-28')) + + expect(res.statusCode).toBe(200) + expect(res.body).toContain('-H "X-GitHub-Api-Version: 2022-11-28"') + }) + + test('Missing apiVersion defaults to latest', async () => { + // When no apiVersion is provided, it should default to the latest version + const res = await get(makeURL('/en/rest/actions/artifacts')) + + expect(res.statusCode).toBe(200) + // Should include the default API version header + expect(res.body).toContain('-H "X-GitHub-Api-Version: 2022-11-28"') + }) + + test('Multiple operations on a page are all rendered', async () => { + const res = await get(makeURL('/en/rest/actions/artifacts')) + expect(res.statusCode).toBe(200) + + // Check for multiple operation headings + expect(res.body).toContain('## List artifacts for a repository') + expect(res.body).toContain('## Get an artifact') + expect(res.body).toContain('## Delete an artifact') + }) + + test('Body parameters are formatted correctly for POST/PUT operations', async () => { + const res = await get(makeURL('/en/rest/actions/artifacts')) + expect(res.statusCode).toBe(200) + + // For operations with body parameters, check formatting + // (artifacts endpoint is mostly GET/DELETE, but structure should be there) + // The transformer handles body parameters when present + }) + + test('Content-type header is included for operations that need it', async () => { + const res = await get(makeURL('/en/rest/actions/artifacts')) + expect(res.statusCode).toBe(200) + + // Content-type header appears for operations that require it + // The REST transformer adds this based on the operation data + }) + + test('Non-English language paths work correctly', async () => { + // Note: This test may fail in dev mode with ENABLED_LANGUAGES=en + // but the transformer itself should handle any language path + const res = await get(makeURL('/ja/rest/actions/artifacts')) + expect(res.statusCode).toBe(200) + + // The transformer should work regardless of language prefix + // because it looks for 'rest' in the path and gets the category/subcategory after it + // e.g. /ja/rest/actions/artifacts should work the same as /en/rest/actions/artifacts + + // Verify the operation content is present (in English, since REST data is not translated) + expect(res.body).toContain('## List artifacts for a repository') + expect(res.body).toContain('GET /repos/{owner}/{repo}/actions/artifacts') + + // Check what language is actually being served by examining the response + // If Japanese translations are loaded, the title will be in Japanese + // Otherwise, it falls back to English + const hasJapaneseTitle = res.body.includes('# GitHub Actions アーティファクト') + const hasEnglishTitle = res.body.includes('# GitHub Actions Artifacts') + + // One of them must be present + expect(hasJapaneseTitle || hasEnglishTitle).toBe(true) + + // Verify the appropriate content based on which language was served + if (hasJapaneseTitle) { + // If Japanese is loaded, expect Japanese intro text + expect(res.body).toContain('アーティファクト') + } else { + // If Japanese is not loaded, expect English fallback + expect(res.body).toContain('Use the REST API to interact with artifacts in HubGit Actions') + } + }) +}) diff --git a/src/article-api/transformers/index.ts b/src/article-api/transformers/index.ts new file mode 100644 index 000000000000..a759723782c5 --- /dev/null +++ b/src/article-api/transformers/index.ts @@ -0,0 +1,18 @@ +import { TransformerRegistry } from './types' +import { RestTransformer } from './rest-transformer' + +/** + * Global transformer registry + * Registers all available page-to-markdown transformers + */ +export const transformerRegistry = new TransformerRegistry() + +// Register REST transformer +transformerRegistry.register(new RestTransformer()) + +// Future transformers can be registered here: +// transformerRegistry.register(new WebhooksTransformer()) +// transformerRegistry.register(new GitHubAppsTransformer()) + +export { TransformerRegistry } from './types' +export type { PageTransformer } from './types' diff --git a/src/article-api/transformers/rest-transformer.ts b/src/article-api/transformers/rest-transformer.ts new file mode 100644 index 000000000000..87f6b9abd417 --- /dev/null +++ b/src/article-api/transformers/rest-transformer.ts @@ -0,0 +1,210 @@ +import type { Context, Page } from '@/types' +import type { PageTransformer } from './types' +import type { Operation } from '@/rest/components/types' +import { renderContent } from '@/content-render/index' +import matter from '@gr2m/gray-matter' +import { readFileSync } from 'fs' +import { join, dirname } from 'path' +import { fileURLToPath } from 'url' +import { fastTextOnly } from '@/content-render/unified/text-only' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +/** + * Transformer for REST API pages + * Converts REST operations and their data into markdown format using a Liquid template + */ +export class RestTransformer implements PageTransformer { + canTransform(page: Page): boolean { + // Only transform REST pages that are not landing pages + // Landing pages (like /en/rest) will be handled by a separate transformer + return page.autogenerated === 'rest' && !page.relativePath.endsWith('index.md') + } + + async transform( + page: Page, + pathname: string, + context: Context, + apiVersion?: string, + ): Promise { + // Import getRest dynamically to avoid circular dependencies + const { default: getRest } = await import('@/rest/lib/index') + + // Extract version from context + const currentVersion = context.currentVersion! + + // Use the provided apiVersion, or fall back to the latest from context + const effectiveApiVersion = + apiVersion || + (context.currentVersionObj?.apiVersions?.length + ? context.currentVersionObj.latestApiVersion + : undefined) + + // Parse the category and subcategory from the page path + // e.g. /en/rest/actions/artifacts -> category: actions, subcategory: artifacts + const pathParts = pathname.split('/').filter(Boolean) + const restIndex = pathParts.indexOf('rest') + + if (restIndex === -1 || restIndex >= pathParts.length - 1) { + throw new Error(`Invalid REST path: ${pathname}`) + } + + const category = pathParts[restIndex + 1] + const subcategory = pathParts[restIndex + 2] // May be undefined for category-only pages + + // Get the REST operations data + const restData = await getRest(currentVersion, effectiveApiVersion) + + let operations: Operation[] = [] + + if (subcategory && restData[category]?.[subcategory]) { + operations = restData[category][subcategory] + } else if (category && restData[category]) { + // For categories without subcategories, operations are nested directly + const categoryData = restData[category] + // Flatten all operations from all subcategories + operations = Object.values(categoryData).flat() + } + + // Prepare manual content + let manualContent = '' + if (page.markdown) { + const markerIndex = page.markdown.indexOf( + '', + ) + if (markerIndex > 0) { + const { content } = matter(page.markdown) + const manualContentMarkerIndex = content.indexOf( + '', + ) + if (manualContentMarkerIndex > 0) { + const rawManualContent = content.substring(0, manualContentMarkerIndex).trim() + if (rawManualContent) { + manualContent = await renderContent(rawManualContent, { + ...context, + markdownRequested: true, + }) + } + } + } + } + + // Prepare data for template + const templateData = await this.prepareTemplateData( + page, + operations, + context, + manualContent, + effectiveApiVersion, + ) + + // Load and render template + const templatePath = join(__dirname, '../templates/rest-page.template.md') + const templateContent = readFileSync(templatePath, 'utf8') + + // Render the template with Liquid + const rendered = await renderContent(templateContent, { + ...context, + ...templateData, + markdownRequested: true, + }) + + return rendered + } + + /** + * Prepare data for the Liquid template + */ + private async prepareTemplateData( + page: Page, + operations: Operation[], + context: Context, + manualContent: string, + apiVersion?: string, + ): Promise> { + // Prepare page intro + const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : '' + + // Prepare operations for the template + const preparedOperations = await Promise.all( + operations.map(async (operation) => await this.prepareOperation(operation)), + ) + + return { + page: { + title: page.title, + intro, + }, + manualContent, + restOperations: preparedOperations, + apiVersion, + } + } + + /** + * Prepare a single operation for template rendering + */ + private async prepareOperation(operation: Operation): Promise> { + // Convert HTML description to text + const description = operation.descriptionHTML ? fastTextOnly(operation.descriptionHTML) : '' + + // Determine header settings + const needsContentTypeHeader = operation.subcategory === 'inference' + const omitHeaders = + operation.subcategory === 'management-console' || operation.subcategory === 'manage-ghes' + const showHeaders = !omitHeaders + + // Check if operation has parameters + const hasParameters = + (operation.parameters?.length || 0) > 0 || (operation.bodyParameters?.length || 0) > 0 + + // Process status codes to convert HTML descriptions to plain text + const statusCodes = operation.statusCodes?.map((statusCode) => ({ + ...statusCode, + description: statusCode.description ? fastTextOnly(statusCode.description) : undefined, + })) + + // Prepare code examples with processed URLs + const codeExamples = + operation.codeExamples?.map((example) => { + let url = `${operation.serverUrl}${operation.requestPath}` + + // Replace path parameters in URL + if (example.request?.parameters && Object.keys(example.request.parameters).length > 0) { + for (const [key, value] of Object.entries(example.request.parameters)) { + url = url.replace(`{${key}}`, String(value)) + } + } + + return { + request: { + description: example.request?.description + ? fastTextOnly(example.request.description) + : '', + url, + acceptHeader: example.request?.acceptHeader, + bodyParameters: example.request?.bodyParameters + ? JSON.stringify(example.request.bodyParameters, null, 2) + : null, + }, + response: { + statusCode: example.response?.statusCode, + schema: (example.response as any)?.schema + ? JSON.stringify((example.response as any).schema, null, 2) + : null, + }, + } + }) || [] + + return { + ...operation, + description, + hasParameters, + showHeaders, + needsContentTypeHeader, + statusCodes, + codeExamples, + } + } +} diff --git a/src/article-api/transformers/types.ts b/src/article-api/transformers/types.ts new file mode 100644 index 000000000000..4916aea2283e --- /dev/null +++ b/src/article-api/transformers/types.ts @@ -0,0 +1,103 @@ +import type { Context, Page } from '@/types' + +/** + * Base interface for page-to-markdown transformers + * + * Transformers convert autogenerated pages (REST, webhooks, etc.) + * into markdown format for the Article API + */ +export interface PageTransformer { + /** + * Check if this transformer can handle the given page + */ + canTransform(page: Page): boolean + + /** + * Transform the page into markdown format + * @param page - The page to transform + * @param pathname - The pathname of the page + * @param context - The rendering context + * @param apiVersion - Optional API version (e.g., '2022-11-28' for REST API calendar versioning) + */ + transform(page: Page, pathname: string, context: Context, apiVersion?: string): Promise +} + +/** + * Registry of available transformers for converting pages to markdown + * + * The TransformerRegistry manages a collection of PageTransformer instances + * and provides a mechanism to find the appropriate transformer for a given page. + * + * Transformers are evaluated in registration order. The first transformer + * whose `canTransform()` method returns true will be selected. + * + * @example + * ```typescript + * const registry = new TransformerRegistry() + * + * // Register transformers in priority order + * registry.register(new RestTransformer()) + * registry.register(new WebhookTransformer()) + * registry.register(new GraphQLTransformer()) + * + * // Find and use a transformer + * const transformer = registry.findTransformer(page) + * if (transformer) { + * const markdown = await transformer.transform(page, pathname, context) + * } + * ``` + * + * @remarks + * This class is not thread-safe. In server environments with concurrent requests, + * register all transformers during initialization before handling requests. + */ +export class TransformerRegistry { + private transformers: PageTransformer[] = [] + + /** + * Register a new transformer + * + * Transformers are evaluated in registration order when finding a match. + * Register more specific transformers before more general ones. + * + * @param transformer - The transformer to register + * + * @example + * ```typescript + * const registry = new TransformerRegistry() + * registry.register(new RestTransformer()) + * ``` + */ + register(transformer: PageTransformer): void { + this.transformers.push(transformer) + } + + /** + * Find a transformer that can handle the given page + * + * Iterates through registered transformers in registration order and returns + * the first transformer whose `canTransform()` method returns true. + * + * @param page - The page to find a transformer for + * @returns The first matching transformer, or null if: + * - The page is null/undefined + * - No registered transformer can handle the page + * + * @example + * ```typescript + * const transformer = registry.findTransformer(page) + * if (transformer) { + * const markdown = await transformer.transform(page, pathname, context) + * } else { + * // Handle case where no transformer is available + * console.warn('No transformer found for page:', page.relativePath) + * } + * ``` + */ + findTransformer(page: Page): PageTransformer | null { + if (page == null) { + return null + } + return this.transformers.find((t) => t.canTransform(page)) || null + } +} diff --git a/src/content-linter/lib/helpers/liquid-utils.ts b/src/content-linter/lib/helpers/liquid-utils.ts index 4ff9ca7bdbe9..f8c3b2204789 100644 --- a/src/content-linter/lib/helpers/liquid-utils.ts +++ b/src/content-linter/lib/helpers/liquid-utils.ts @@ -1,20 +1,16 @@ import { Tokenizer, TokenKind } from 'liquidjs' +import type { TopLevelToken, TagToken } from 'liquidjs' import { deprecated } from '@/versions/lib/enterprise-server-releases' -// Using `any` for the cache because TopLevelToken is a complex union type from liquidjs -// that includes TagToken, OutputToken, and HTMLToken with different properties. -// The cache is private to this module and we control all access to it. -const liquidTokenCache = new Map() +// Cache for liquid tokens to improve performance +const liquidTokenCache = new Map() -// Returns `any[]` instead of `TopLevelToken[]` because TopLevelToken is a union type -// (TagToken | OutputToken | HTMLToken) and consumers of this function access properties -// like `name` and `args` that only exist on TagToken. Using `any` here avoids complex -// type narrowing throughout the codebase. +// Returns TopLevelToken array from liquidjs which is a union of TagToken, OutputToken, and HTMLToken export function getLiquidTokens( content: string, { noCache = false }: { noCache?: boolean } = {}, -): any[] { +): TopLevelToken[] { if (!content) return [] if (noCache) { @@ -23,13 +19,13 @@ export function getLiquidTokens( } if (liquidTokenCache.has(content)) { - return liquidTokenCache.get(content) + return liquidTokenCache.get(content)! } const tokenizer = new Tokenizer(content) const tokens = tokenizer.readTopLevelTokens() liquidTokenCache.set(content, tokens) - return liquidTokenCache.get(content) + return liquidTokenCache.get(content)! } export const OUTPUT_OPEN = '{%' @@ -40,10 +36,9 @@ export const TAG_CLOSE = '}}' export const conditionalTags = ['if', 'elseif', 'unless', 'case', 'ifversion'] const CONDITIONAL_TAG_NAMES = ['if', 'ifversion', 'elsif', 'else', 'endif'] -// Token is `any` because it's used with different token types from liquidjs -// that all have `begin` and `end` properties but are part of complex union types. +// Token parameter uses TopLevelToken which has begin and end properties export function getPositionData( - token: any, + token: TopLevelToken, lines: string[], ): { lineNumber: number; column: number; length: number } { // Liquid indexes are 0-based, but we want to @@ -77,9 +72,9 @@ export function getPositionData( * by Markdownlint: * [ { lineNumber: 1, column: 1, deleteCount: 3, }] */ -// Token is `any` because it's used with different token types from liquidjs. +// Token parameter uses TopLevelToken from liquidjs export function getContentDeleteData( - token: any, + token: TopLevelToken, tokenEnd: number, lines: string[], ): Array<{ lineNumber: number; column: number; deleteCount: number }> { @@ -123,15 +118,14 @@ export function getContentDeleteData( // related elsif, else, and endif tags). // Docs doesn't use the standard `if` tag for versioning, instead the // `ifversion` tag is used. -// Returns `any[]` because the tokens need to be accessed as TagToken with `name` and `args` properties, -// but TopLevelToken union type would require complex type narrowing. -export function getLiquidIfVersionTokens(content: string): any[] { +// Returns TagToken array since we filter to only Tag tokens +export function getLiquidIfVersionTokens(content: string): TagToken[] { const tokens = getLiquidTokens(content) - .filter((token) => token.kind === TokenKind.Tag) + .filter((token): token is TagToken => token.kind === TokenKind.Tag) .filter((token) => CONDITIONAL_TAG_NAMES.includes(token.name)) let inIfStatement = false - const ifVersionTokens: any[] = [] + const ifVersionTokens: TagToken[] = [] for (const token of tokens) { if (token.name === 'if') { inIfStatement = true diff --git a/src/content-linter/lib/helpers/utils.ts b/src/content-linter/lib/helpers/utils.ts index 24af845b97c2..2a28b651a8ec 100644 --- a/src/content-linter/lib/helpers/utils.ts +++ b/src/content-linter/lib/helpers/utils.ts @@ -11,8 +11,8 @@ export function addFixErrorDetail( actual: string, // Using flexible type to accommodate different range formats from various linting rules range: [number, number] | number[] | null, - // Using any for fixInfo as markdownlint-rule-helpers accepts various fix info structures - fixInfo: any, + // Using unknown for fixInfo as markdownlint-rule-helpers accepts various fix info structures + fixInfo: unknown, ): void { addError(onError, lineNumber, `Expected: ${expected}`, ` Actual: ${actual}`, range, fixInfo) } @@ -20,9 +20,11 @@ export function addFixErrorDetail( export function forEachInlineChild( params: RuleParams, type: string, - // Using any for child and token types because different linting rules pass tokens with varying structures - // beyond the base MarkdownToken interface (e.g., ImageToken with additional properties) - handler: (child: any, token: any) => void, + // Handler uses `any` for function parameter variance reasons. TypeScript's contravariance rules for function + // parameters mean that a function accepting a specific type cannot be assigned to a parameter of type `unknown`. + // Therefore, `unknown` cannot be used here, as different linting rules pass tokens with varying structures + // beyond the base MarkdownToken interface, and some handlers are async. + handler: (child: any, token?: any) => void | Promise, ): void { filterTokens(params, 'inline', (token: MarkdownToken) => { for (const child of token.children!.filter((c) => c.type === type)) { @@ -146,8 +148,8 @@ export const docsDomains = ['docs.github.com', 'help.github.com', 'developer.git // This is the format we get from Markdownlint. // Returns null if the lines do not contain // frontmatter properties. -// Returns frontmatter as a Record with any values since YAML can contain various types -export function getFrontmatter(lines: string[]): Record | null { +// Returns frontmatter as a Record with unknown values since YAML can contain various types +export function getFrontmatter(lines: string[]): Record | null { const fmString = lines.join('\n') const { data } = matter(fmString) // If there is no frontmatter or the frontmatter contains diff --git a/src/content-linter/lib/linting-rules/internal-links-slash.ts b/src/content-linter/lib/linting-rules/internal-links-slash.ts index 0076c1531ad0..2a4101ee195d 100644 --- a/src/content-linter/lib/linting-rules/internal-links-slash.ts +++ b/src/content-linter/lib/linting-rules/internal-links-slash.ts @@ -1,7 +1,7 @@ import { filterTokens } from 'markdownlint-rule-helpers' import { addFixErrorDetail, getRange } from '../helpers/utils' -import type { RuleParams, RuleErrorCallback, Rule } from '../../types' +import type { RuleParams, RuleErrorCallback, Rule, MarkdownToken } from '../../types' export const internalLinksSlash: Rule = { names: ['GHD003', 'internal-links-slash'], @@ -9,8 +9,8 @@ export const internalLinksSlash: Rule = { tags: ['links', 'url'], parser: 'markdownit', function: (params: RuleParams, onError: RuleErrorCallback) => { - // Using 'any' type for token as markdownlint-rule-helpers doesn't provide TypeScript types - filterTokens(params, 'inline', (token: any) => { + filterTokens(params, 'inline', (token: MarkdownToken) => { + if (!token.children) return for (const child of token.children) { if (child.type !== 'link_open') continue @@ -20,6 +20,7 @@ export const internalLinksSlash: Rule = { // ['rel', 'canonical'], // ] // Attribute arrays are tuples of [attributeName, attributeValue] from markdownit parser + if (!child.attrs) continue const hrefsMissingSlashes = child.attrs // The attribute could also be `target` or `rel` .filter((attr: [string, string]) => attr[0] === 'href') diff --git a/src/content-linter/lib/linting-rules/liquid-data-tags.ts b/src/content-linter/lib/linting-rules/liquid-data-tags.ts index d66d5f974700..5a626fa8f5b0 100644 --- a/src/content-linter/lib/linting-rules/liquid-data-tags.ts +++ b/src/content-linter/lib/linting-rules/liquid-data-tags.ts @@ -1,5 +1,6 @@ import { addError } from 'markdownlint-rule-helpers' import { TokenKind } from 'liquidjs' +import type { TagToken } from 'liquidjs' import { getDataByLanguage } from '@/data-directory/lib/get-data' import { @@ -23,10 +24,9 @@ export const liquidDataReferencesDefined = { parser: 'markdownit', function: (params: RuleParams, onError: RuleErrorCallback) => { const content = params.lines.join('\n') - // Using any type because getLiquidTokens returns tokens from liquidjs library without complete type definitions const tokens = getLiquidTokens(content) - .filter((token: any) => token.kind === TokenKind.Tag) - .filter((token: any) => token.name === 'data' || token.name === 'indented_data_reference') + .filter((token): token is TagToken => token.kind === TokenKind.Tag) + .filter((token) => token.name === 'data' || token.name === 'indented_data_reference') if (!tokens.length) return @@ -60,13 +60,11 @@ export const liquidDataTagFormat = { function: (params: RuleParams, onError: RuleErrorCallback) => { const CHECK_LIQUID_TAGS = [OUTPUT_OPEN, OUTPUT_CLOSE, '{', '}'] const content = params.lines.join('\n') - // Using any type because getLiquidTokens returns tokens from liquidjs library without complete type definitions - // Tokens have properties like 'kind', 'name', 'args', and 'content' that aren't fully typed - const tokenTags = getLiquidTokens(content).filter((token: any) => token.kind === TokenKind.Tag) - const dataTags = tokenTags.filter((token: any) => token.name === 'data') - const indentedDataTags = tokenTags.filter( - (token: any) => token.name === 'indented_data_reference', + const tokenTags = getLiquidTokens(content).filter( + (token): token is TagToken => token.kind === TokenKind.Tag, ) + const dataTags = tokenTags.filter((token) => token.name === 'data') + const indentedDataTags = tokenTags.filter((token) => token.name === 'indented_data_reference') for (const token of dataTags) { // A data tag has only one argument, the data directory path. diff --git a/src/content-linter/lib/linting-rules/liquid-ifversion-versions.ts b/src/content-linter/lib/linting-rules/liquid-ifversion-versions.ts index 6631133278ff..c56439c41d64 100644 --- a/src/content-linter/lib/linting-rules/liquid-ifversion-versions.ts +++ b/src/content-linter/lib/linting-rules/liquid-ifversion-versions.ts @@ -1,4 +1,5 @@ import { addError } from 'markdownlint-rule-helpers' +import type { TopLevelToken } from 'liquidjs' import { getLiquidIfVersionTokens, @@ -35,8 +36,11 @@ export const liquidIfversionVersions = { const fileVersionsFm = params.name.startsWith('data') ? { ghec: '*', ghes: '*', fpt: '*' } : fm - ? fm.versions - : getFrontmatter(params.frontMatterLines)?.versions + ? (fm.versions as string | Record | undefined) + : (getFrontmatter(params.frontMatterLines)?.versions as + | string + | Record + | undefined) // This will only contain valid (non-deprecated) and future versions const fileVersions = getApplicableVersions(fileVersionsFm, '', { doNotThrow: true, @@ -134,7 +138,7 @@ function setLiquidErrors(condTagItems: any[], onError: RuleErrorCallback, lines: { begin: item.begin, end: item.end, - }, + } as TopLevelToken, lines, ) const deleteCount = length - column + 1 === lines[lineNumber - 1].length ? -1 : length @@ -159,7 +163,7 @@ function setLiquidErrors(condTagItems: any[], onError: RuleErrorCallback, lines: { begin: item.contentrange[0], end: item.contentrange[1], - }, + } as TopLevelToken, lines, ) const insertText = `${item.action.name || item.name} ${item.action.cond || item.cond}` diff --git a/src/content-linter/lib/linting-rules/liquid-quoted-conditional-arg.ts b/src/content-linter/lib/linting-rules/liquid-quoted-conditional-arg.ts index 66b1320bf26e..13ad700cb116 100644 --- a/src/content-linter/lib/linting-rules/liquid-quoted-conditional-arg.ts +++ b/src/content-linter/lib/linting-rules/liquid-quoted-conditional-arg.ts @@ -1,4 +1,5 @@ import { TokenKind } from 'liquidjs' +import type { TagToken } from 'liquidjs' import { addError } from 'markdownlint-rule-helpers' import { getLiquidTokens, conditionalTags, getPositionData } from '../helpers/liquid-utils' @@ -19,14 +20,12 @@ export const liquidQuotedConditionalArg: Rule = { tags: ['liquid', 'format'], function: (params: RuleParams, onError: RuleErrorCallback) => { const content = params.lines.join('\n') - // Using 'any' type for tokens as getLiquidTokens returns tokens from liquid-utils.ts which lacks type definitions const tokens = getLiquidTokens(content) - .filter((token: any) => token.kind === TokenKind.Tag) - .filter((token: any) => conditionalTags.includes(token.name)) - .filter((token: any) => { + .filter((token): token is TagToken => token.kind === TokenKind.Tag) + .filter((token) => conditionalTags.includes(token.name)) + .filter((token) => { const tokensArray = token.args.split(/\s+/g) - // Using 'any' for args as they come from the untyped liquid token structure - if (tokensArray.some((arg: any) => isStringQuoted(arg))) return true + if (tokensArray.some((arg) => isStringQuoted(arg))) return true return false }) diff --git a/src/content-linter/lib/linting-rules/liquid-syntax.ts b/src/content-linter/lib/linting-rules/liquid-syntax.ts index debb548e7866..5e3a93ea8dda 100644 --- a/src/content-linter/lib/linting-rules/liquid-syntax.ts +++ b/src/content-linter/lib/linting-rules/liquid-syntax.ts @@ -33,6 +33,7 @@ export const frontmatterLiquidSyntax = { for (const key of keysWithLiquid) { const value = fm[key] + if (typeof value !== 'string') continue try { liquid.parse(value) } catch (error) { diff --git a/src/content-linter/lib/linting-rules/liquid-tag-whitespace.ts b/src/content-linter/lib/linting-rules/liquid-tag-whitespace.ts index e0678607accf..1bdae8501fdd 100644 --- a/src/content-linter/lib/linting-rules/liquid-tag-whitespace.ts +++ b/src/content-linter/lib/linting-rules/liquid-tag-whitespace.ts @@ -1,4 +1,5 @@ import { TokenKind } from 'liquidjs' +import type { TopLevelToken } from 'liquidjs' import { getLiquidTokens, getPositionData } from '../helpers/liquid-utils' import { addFixErrorDetail } from '../helpers/utils' @@ -36,7 +37,10 @@ export const liquidTagWhitespace: Rule = { (token: LiquidToken) => token.kind === TokenKind.Tag, ) for (const token of tokens) { - const { lineNumber, column, length } = getPositionData(token, params.lines) + const { lineNumber, column, length } = getPositionData( + token as unknown as TopLevelToken, + params.lines, + ) const range = [column, length] const tag = params.lines[lineNumber - 1].slice(column - 1, column - 1 + length) diff --git a/src/content-linter/lib/linting-rules/liquid-versioning.ts b/src/content-linter/lib/linting-rules/liquid-versioning.ts index 6fa1471de035..a9062aa04243 100644 --- a/src/content-linter/lib/linting-rules/liquid-versioning.ts +++ b/src/content-linter/lib/linting-rules/liquid-versioning.ts @@ -1,5 +1,6 @@ import semver from 'semver' import { TokenKind } from 'liquidjs' +import type { TagToken } from 'liquidjs' import { addError } from 'markdownlint-rule-helpers' import { getRange, addFixErrorDetail } from '../helpers/utils' @@ -13,7 +14,7 @@ import type { RuleParams, RuleErrorCallback } from '@/content-linter/types' interface Feature { versions: Record - [key: string]: any + [key: string]: unknown } type AllFeatures = Record @@ -60,12 +61,13 @@ export const liquidIfTags = { function: (params: RuleParams, onError: RuleErrorCallback) => { const content = params.lines.join('\n') - const tokens = getLiquidTokens(content).filter( - (token) => - token.kind === TokenKind.Tag && - token.name === 'if' && - token.args.split(/\s+/).some((arg: string) => getAllPossibleVersionNames().has(arg)), - ) + const tokens = getLiquidTokens(content) + .filter((token): token is TagToken => token.kind === TokenKind.Tag) + .filter( + (token) => + token.name === 'if' && + token.args.split(/\s+/).some((arg: string) => getAllPossibleVersionNames().has(arg)), + ) for (const token of tokens) { const args = token.args @@ -90,7 +92,7 @@ export const liquidIfVersionTags = { function: (params: RuleParams, onError: RuleErrorCallback) => { const content = params.lines.join('\n') const tokens = getLiquidTokens(content) - .filter((token) => token.kind === TokenKind.Tag) + .filter((token): token is TagToken => token.kind === TokenKind.Tag) .filter((token) => token.name === 'ifversion' || token.name === 'elsif') for (const token of tokens) { diff --git a/src/content-linter/lib/linting-rules/rai-reusable-usage.ts b/src/content-linter/lib/linting-rules/rai-reusable-usage.ts index 2072611f168e..56ce74eb1cc1 100644 --- a/src/content-linter/lib/linting-rules/rai-reusable-usage.ts +++ b/src/content-linter/lib/linting-rules/rai-reusable-usage.ts @@ -1,5 +1,6 @@ import { addError } from 'markdownlint-rule-helpers' import { TokenKind } from 'liquidjs' +import type { TopLevelToken } from 'liquidjs' import path from 'path' import { getFrontmatter } from '../helpers/utils' @@ -45,7 +46,10 @@ export const raiReusableUsage: Rule = { if (dataDirectoryReference.startsWith('reusables.rai')) continue const lines = params.lines - const { lineNumber, column, length } = getPositionData(token, lines) + const { lineNumber, column, length } = getPositionData( + token as unknown as TopLevelToken, + lines, + ) addError( onError, lineNumber, diff --git a/src/content-linter/scripts/find-unsed-variables.ts b/src/content-linter/scripts/find-unsed-variables.ts index 29a409c6bb63..cfe38135c244 100644 --- a/src/content-linter/scripts/find-unsed-variables.ts +++ b/src/content-linter/scripts/find-unsed-variables.ts @@ -22,7 +22,8 @@ import yaml from 'js-yaml' import { program } from 'commander' import { loadPages, loadUnversionedTree } from '@/frame/lib/page-data' -import { TokenizationError } from 'liquidjs' +import { TokenizationError, TokenKind } from 'liquidjs' +import type { TagToken } from 'liquidjs' import readFrontmatter from '@/frame/lib/read-frontmatter' import { getLiquidTokens } from '@/content-linter/lib/helpers/liquid-utils' @@ -137,7 +138,10 @@ function getReusableFiles(root = 'data') { function checkString(string: string, variables: Map) { try { - for (const token of getLiquidTokens(string)) { + const tokens = getLiquidTokens(string).filter( + (token): token is TagToken => token.kind === TokenKind.Tag, + ) + for (const token of tokens) { if (token.name === 'data') { const { args } = token variables.delete(args) diff --git a/src/content-render/liquid/engine.ts b/src/content-render/liquid/engine.ts index 77591e81471b..65e1b4f36277 100644 --- a/src/content-render/liquid/engine.ts +++ b/src/content-render/liquid/engine.ts @@ -10,6 +10,7 @@ import { Tool, tags as toolTags } from './tool' import { Spotlight, tags as spotlightTags } from './spotlight' import { Prompt } from './prompt' import IndentedDataReference from './indented-data-reference' +import { apiTransformerTags } from '@/article-api/liquid-renderers' // Type assertions for .js files without type definitions // Copilot: Remove these assertions when the corresponding .js files are converted to TypeScript @@ -40,6 +41,11 @@ for (const tag in spotlightTags) { engine.registerTag('prompt', anyPrompt) +// Register API transformer tags +for (const [tagName, tagClass] of Object.entries(apiTransformerTags)) { + engine.registerTag(tagName, tagClass as any) +} + /** * Like the `size` filter, but specifically for * getting the number of keys in an object diff --git a/src/content-render/liquid/prompt.ts b/src/content-render/liquid/prompt.ts index d241786e6b81..dbba2b28b3c4 100644 --- a/src/content-render/liquid/prompt.ts +++ b/src/content-render/liquid/prompt.ts @@ -2,25 +2,25 @@ // Defines {% prompt %}…{% endprompt %} to wrap its content in and append the Copilot icon. import octicons from '@primer/octicons' +import type { TagToken, TopLevelToken } from 'liquidjs' import { generatePromptId } from '../lib/prompt-id' 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 + templates?: unknown[] + parse(tagToken: TagToken, remainTokens: TopLevelToken[]): void + render(scope: unknown): Generator } export const Prompt: LiquidTag = { type: 'block', // Collect everything until {% endprompt %} - parse(tagToken: any, remainTokens: any): void { + parse(tagToken: TagToken, remainTokens: TopLevelToken[]): void { this.templates = [] const stream = this.liquid.parser.parseStream(remainTokens) stream - .on('template', (tpl: any) => this.templates.push(tpl)) + .on('template', (tpl: unknown) => this.templates.push(tpl)) .on('tag:endprompt', () => stream.stop()) .on('end', () => { throw new Error(`{% prompt %} tag not closed`) @@ -29,7 +29,7 @@ export const Prompt: LiquidTag = { }, // Render the inner Markdown, wrap in , then append the SVG - *render(scope: any): Generator { + *render(scope: unknown): Generator { const content = yield this.liquid.renderer.renderTemplates(this.templates, scope) const contentString = String(content) diff --git a/src/content-render/scripts/reusables-cli/find/unused.ts b/src/content-render/scripts/reusables-cli/find/unused.ts index 82feb590ab87..1f7bf29e8711 100644 --- a/src/content-render/scripts/reusables-cli/find/unused.ts +++ b/src/content-render/scripts/reusables-cli/find/unused.ts @@ -1,5 +1,7 @@ import fs from 'fs' import path from 'path' +import { TokenKind } from 'liquidjs' +import type { TagToken } from 'liquidjs' import { getLiquidTokens } from '@/content-linter/lib/helpers/liquid-utils' import { getAllContentFilePaths, @@ -21,7 +23,9 @@ export function findUnused({ absolute }: { absolute: boolean }) { for (let i = 0; i < totalFiles; i++) { const filePath = allFilePaths[i] const fileContents = fs.readFileSync(filePath, 'utf-8') - const liquidTokens = getLiquidTokens(fileContents) + const liquidTokens = getLiquidTokens(fileContents).filter( + (token): token is TagToken => token.kind === TokenKind.Tag, + ) for (const token of liquidTokens) { const { args, name } = token if ( diff --git a/src/content-render/scripts/reusables-cli/find/used.ts b/src/content-render/scripts/reusables-cli/find/used.ts index 24e1851a1a68..589b87e12e6c 100644 --- a/src/content-render/scripts/reusables-cli/find/used.ts +++ b/src/content-render/scripts/reusables-cli/find/used.ts @@ -1,5 +1,7 @@ import fs from 'fs' import path from 'path' +import { TokenKind } from 'liquidjs' +import type { TagToken } from 'liquidjs' import { getLiquidTokens } from '@/content-linter/lib/helpers/liquid-utils' import { FilesWithLineNumbers, @@ -51,7 +53,9 @@ export function findTopUsed(numberOfMostUsedToFind: number, { absolute }: { abso const reusableCounts = new Map() for (const filePath of allFilePaths) { const fileContents = fs.readFileSync(filePath, 'utf-8') - const liquidTokens = getLiquidTokens(fileContents) + const liquidTokens = getLiquidTokens(fileContents).filter( + (token): token is TagToken => token.kind === TokenKind.Tag, + ) for (const token of liquidTokens) { const { args, name } = token if (name === 'data' && args.startsWith('reusables.')) { diff --git a/src/content-render/scripts/reusables-cli/shared.ts b/src/content-render/scripts/reusables-cli/shared.ts index 1df03be81a8f..792f48828bdc 100644 --- a/src/content-render/scripts/reusables-cli/shared.ts +++ b/src/content-render/scripts/reusables-cli/shared.ts @@ -1,6 +1,7 @@ import walk from 'walk-sync' import path from 'path' -import { TokenizationError } from 'liquidjs' +import { TokenizationError, TokenKind } from 'liquidjs' +import type { TagToken } from 'liquidjs' import { getLiquidTokens } from '@/content-linter/lib/helpers/liquid-utils' const __dirname = path.dirname(new URL(import.meta.url).pathname) @@ -56,7 +57,10 @@ export function getReusableLiquidString(reusablePath: string): string { export function getIndicesOfLiquidVariable(liquidVariable: string, fileContents: string): number[] { const indices: number[] = [] try { - for (const token of getLiquidTokens(fileContents)) { + const tokens = getLiquidTokens(fileContents).filter( + (token): token is TagToken => token.kind === TokenKind.Tag, + ) + for (const token of tokens) { if (token.name === 'data' && token.args.trim() === liquidVariable) { indices.push(token.begin) } diff --git a/src/content-render/types.ts b/src/content-render/types.ts index 9fc352ce9ac5..1f01894583a0 100644 --- a/src/content-render/types.ts +++ b/src/content-render/types.ts @@ -11,13 +11,13 @@ export interface Context { currentVersion?: string currentProduct?: string markdownRequested?: boolean - pages?: any - redirects?: any + pages?: Record + redirects?: Record page?: { fullPath: string - [key: string]: any + [key: string]: unknown } - [key: string]: any + [key: string]: unknown } /** @@ -27,20 +27,20 @@ export interface RenderOptions { cache?: boolean | ((template: string, context: Context) => string | null) filename?: string textOnly?: boolean - [key: string]: any + [key: string]: unknown } /** * Unified processor plugin function type */ -export type UnifiedPlugin = (context?: Context) => any +export type UnifiedPlugin = (context?: Context) => unknown /** * VFile interface for unified processing */ export interface VFile { toString(): string - [key: string]: any + [key: string]: unknown } /** @@ -48,5 +48,5 @@ export interface VFile { */ export interface UnifiedProcessor { process(content: string): Promise - use(plugin: any, ...args: any[]): UnifiedProcessor + use(plugin: unknown, ...args: unknown[]): UnifiedProcessor } diff --git a/src/content-render/unified/processor.ts b/src/content-render/unified/processor.ts index 39e9d1ff49f0..2544b201f7fe 100644 --- a/src/content-render/unified/processor.ts +++ b/src/content-render/unified/processor.ts @@ -40,21 +40,21 @@ export function createProcessor(context: Context): UnifiedProcessor { .use(gfm) // Markdown AST below vvv .use(parseInfoString) - // Using 'as any' because rewriteLocalLinks is a factory function that takes context + // Using type assertion because rewriteLocalLinks is a factory function that takes context // and returns a transformer, but TypeScript's unified plugin types don't handle this pattern - .use(rewriteLocalLinks as any, context) + .use(rewriteLocalLinks as unknown as (ctx: Context) => void, context) .use(emoji) // Markdown AST above ^^^ .use(remark2rehype, { allowDangerousHtml: true }) // HTML AST below vvv .use(slug) // useEnglishHeadings plugin requires context with englishHeadings property - .use(useEnglishHeadings as any, context || {}) + .use(useEnglishHeadings as unknown as (ctx: Context) => void, context || {}) .use(headingLinks) .use(codeHeader) .use(annotate, context) - // Using 'as any' for highlight plugin due to complex type mismatch between unified and rehype-highlight - .use(highlight as any, { + // Using type assertion for highlight plugin due to complex type mismatch between unified and rehype-highlight + .use(highlight as unknown as (options: unknown) => void, { languages: { ...common, graphql, dockerfile, http, groovy, erb, powershell }, subset: false, aliases: { @@ -82,9 +82,9 @@ export function createProcessor(context: Context): UnifiedProcessor { .use(rewriteImgSources) .use(rewriteAssetImgTags) // alerts plugin requires context with alertTitles property - .use(alerts as any, context || {}) + .use(alerts as unknown as (ctx: Context) => void, context || {}) // HTML AST above ^^^ - .use(html) as UnifiedProcessor // String below vvv + .use(html) as unknown as UnifiedProcessor // String below vvv ) } @@ -93,10 +93,10 @@ export function createMarkdownOnlyProcessor(context: Context): UnifiedProcessor unified() .use(remarkParse) .use(gfm) - // Using 'as any' because rewriteLocalLinks is a factory function that takes context + // Using type assertion because rewriteLocalLinks is a factory function that takes context // and returns a transformer, but TypeScript's unified plugin types don't handle this pattern - .use(rewriteLocalLinks as any, context) - .use(remarkStringify) as UnifiedProcessor + .use(rewriteLocalLinks as unknown as (ctx: Context) => void, context) + .use(remarkStringify) as unknown as UnifiedProcessor ) } @@ -105,12 +105,12 @@ export function createMinimalProcessor(context: Context): UnifiedProcessor { unified() .use(remarkParse) .use(gfm) - // Using 'as any' because rewriteLocalLinks is a factory function that takes context + // Using type assertion because rewriteLocalLinks is a factory function that takes context // and returns a transformer, but TypeScript's unified plugin types don't handle this pattern - .use(rewriteLocalLinks as any, context) + .use(rewriteLocalLinks as unknown as (ctx: Context) => void, context) .use(remark2rehype, { allowDangerousHtml: true }) .use(slug) .use(raw) - .use(html) as UnifiedProcessor + .use(html) as unknown as UnifiedProcessor ) } diff --git a/src/content-render/unified/use-english-headings.ts b/src/content-render/unified/use-english-headings.ts index 47b90c4e6306..0109be6be74a 100644 --- a/src/content-render/unified/use-english-headings.ts +++ b/src/content-render/unified/use-english-headings.ts @@ -2,14 +2,10 @@ import GithubSlugger from 'github-slugger' import { encode } from 'html-entities' import { toString } from 'hast-util-to-string' import { visit } from 'unist-util-visit' +import type { Element, Root } from 'hast' 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 } @@ -17,12 +13,9 @@ interface UseEnglishHeadingsOptions { // 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) => { + return (tree: Root) => { + visit(tree, 'element', (node: Element) => { + if (!['h2', 'h3', 'h4'].includes(node.tagName)) return slugger.reset() // Get the plain text content of the heading node const text: string = toString(node) diff --git a/src/data-directory/scripts/find-orphaned-features/find.ts b/src/data-directory/scripts/find-orphaned-features/find.ts index bce2bb69b642..e43169e816a2 100644 --- a/src/data-directory/scripts/find-orphaned-features/find.ts +++ b/src/data-directory/scripts/find-orphaned-features/find.ts @@ -32,7 +32,8 @@ import fs from 'fs' import path from 'path' import chalk from 'chalk' -import { TokenizationError } from 'liquidjs' +import { TokenizationError, TokenKind } from 'liquidjs' +import type { TagToken } from 'liquidjs' import type { Page } from '@/types' import warmServer from '@/frame/lib/warm-server' @@ -246,7 +247,10 @@ function checkString( // a LOT of different strings in and the cache will fill up rapidly // when testing every possible string in every possible language for // every page. - for (const token of getLiquidTokens(string, { noCache: true })) { + const tokens = getLiquidTokens(string, { noCache: true }).filter( + (token): token is TagToken => token.kind === TokenKind.Tag, + ) + for (const token of tokens) { if (token.name === 'ifversion' || token.name === 'elsif') { for (const arg of token.args.split(/\s+/)) { if (IGNORE_ARGS.has(arg)) continue diff --git a/src/fixtures/fixtures/content/actions/index.md b/src/fixtures/fixtures/content/actions/index.md index 7afa48729ade..7dbd367ee809 100644 --- a/src/fixtures/fixtures/content/actions/index.md +++ b/src/fixtures/fixtures/content/actions/index.md @@ -11,4 +11,5 @@ versions: ghec: '*' children: - /category + - /using-workflows --- diff --git a/src/fixtures/fixtures/content/actions/using-workflows/index.md b/src/fixtures/fixtures/content/actions/using-workflows/index.md new file mode 100644 index 000000000000..cb92113061b4 --- /dev/null +++ b/src/fixtures/fixtures/content/actions/using-workflows/index.md @@ -0,0 +1,12 @@ +--- +title: Using workflows +intro: Learn how to use workflows in GitHub Actions. +versions: + fpt: '*' + ghec: '*' + ghes: '*' +children: + - /storing-workflow-data-as-artifacts +--- + +This is a fixture index page for testing. diff --git a/src/fixtures/fixtures/content/actions/using-workflows/storing-workflow-data-as-artifacts.md b/src/fixtures/fixtures/content/actions/using-workflows/storing-workflow-data-as-artifacts.md new file mode 100644 index 000000000000..580b42d97f52 --- /dev/null +++ b/src/fixtures/fixtures/content/actions/using-workflows/storing-workflow-data-as-artifacts.md @@ -0,0 +1,10 @@ +--- +title: Storing workflow data as artifacts +intro: Artifacts allow you to share data between jobs in a workflow and store data once that workflow has completed. +versions: + fpt: '*' + ghec: '*' + ghes: '*' +--- + +This is a fixture file for testing links in the REST API artifacts documentation. diff --git a/src/fixtures/fixtures/content/rest/actions/artifacts.md b/src/fixtures/fixtures/content/rest/actions/artifacts.md index 2e420c5dafe5..5ee75935c4d0 100644 --- a/src/fixtures/fixtures/content/rest/actions/artifacts.md +++ b/src/fixtures/fixtures/content/rest/actions/artifacts.md @@ -16,4 +16,6 @@ autogenerated: rest ## About artifacts in {% data variables.product.prodname_actions %} +You can use the REST API to download, delete, and retrieve information about workflow artifacts in {% data variables.product.prodname_actions %}. Artifacts enable you to share data between jobs in a workflow and store data once that workflow has completed. For more information, see [AUTOTITLE](/actions/using-workflows/storing-workflow-data-as-artifacts). + diff --git a/src/frame/pages/app.tsx b/src/frame/pages/app.tsx index 272b5d358db7..2f4aa2e767f5 100644 --- a/src/frame/pages/app.tsx +++ b/src/frame/pages/app.tsx @@ -18,6 +18,7 @@ import { import { useTheme } from '@/color-schemes/components/useTheme' import { SharedUIContextProvider } from '@/frame/components/context/SharedUIContext' import { CTAPopoverProvider } from '@/frame/components/context/CTAContext' +import type { ExtendedRequest } from '@/types' type MyAppProps = AppProps & { isDotComAuthenticated: boolean @@ -158,7 +159,7 @@ MyApp.getInitialProps = async (appContext: AppContext) => { const { ctx } = appContext // calls page's `getInitialProps` and fills `appProps.pageProps` const appProps = await App.getInitialProps(appContext) - const req: any = ctx.req + const req = ctx.req as unknown as ExtendedRequest // Have to define the type manually here because `req.context.languages` // comes from Node JS and is not type-aware. @@ -188,11 +189,14 @@ MyApp.getInitialProps = async (appContext: AppContext) => { } } } - const stagingName = req.headers['x-ong-external-url']?.match(/staging-(\w+)\./)?.[1] + const headerValue = req.headers['x-ong-external-url'] + const stagingName = (typeof headerValue === 'string' ? headerValue : headerValue?.[0])?.match( + /staging-(\w+)\./, + )?.[1] return { ...appProps, languagesContext, - stagingName: stagingNames.has(stagingName) ? stagingName : undefined, + stagingName: stagingName && stagingNames.has(stagingName) ? stagingName : undefined, } } diff --git a/src/ghes-releases/scripts/create-enterprise-issue.ts b/src/ghes-releases/scripts/create-enterprise-issue.ts index 73eda3325789..5490543bd7f2 100644 --- a/src/ghes-releases/scripts/create-enterprise-issue.ts +++ b/src/ghes-releases/scripts/create-enterprise-issue.ts @@ -190,7 +190,7 @@ async function createIssue( body, labels, }) - } catch (error: any) { + } catch (error: unknown) { console.log(`#ERROR# ${error}\n🛑 There was an error creating the issue.`) throw error } @@ -223,7 +223,7 @@ async function updateIssue( body, labels, }) - } catch (error: any) { + } catch (error: unknown) { console.log( `#ERROR# ${error}\n🛑 There was an error updating issue ${issueNumber} in ${fullRepo}.`, ) @@ -244,8 +244,13 @@ async function addRepoLabels(fullRepo: string, labels: string[]) { repo, name, }) - } catch (error: any) { - if (error.status === 404) { + } catch (error: unknown) { + if ( + typeof error === 'object' && + error !== null && + 'status' in error && + (error as { status: number }).status === 404 + ) { labelsToAdd.push(name) } else { console.log(`#ERROR# ${error}\n🛑 There was an error getting the label ${name}.`) @@ -260,7 +265,7 @@ async function addRepoLabels(fullRepo: string, labels: string[]) { repo, name, }) - } catch (error: any) { + } catch (error: unknown) { console.log(`#ERROR# ${error}\n🛑 There was an error adding the label ${name}.`) throw error } diff --git a/src/observability/tests/logger-integration.ts b/src/observability/tests/logger-integration.ts index 8ea2f7a22116..038eb2b943a8 100644 --- a/src/observability/tests/logger-integration.ts +++ b/src/observability/tests/logger-integration.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { Request, Response } from 'express' import { createLogger } from '@/observability/logger' import { initLoggerContext, updateLoggerContext } from '@/observability/logger/lib/logger-context' @@ -8,7 +9,7 @@ describe('logger integration tests', () => { let originalConsoleError: typeof console.error let originalEnv: typeof process.env const consoleLogs: string[] = [] - const consoleErrors: any[] = [] + const consoleErrors: unknown[] = [] beforeEach(() => { // Store original console methods and environment @@ -20,7 +21,7 @@ describe('logger integration tests', () => { console.log = vi.fn((message: string) => { consoleLogs.push(message) }) - console.error = vi.fn((error: any) => { + console.error = vi.fn((error: unknown) => { consoleErrors.push(error) }) @@ -78,9 +79,9 @@ describe('logger integration tests', () => { 'accept-language': 'en-US,en;q=0.9', }, query: { filter: 'active' }, - } as any + } as unknown as Request - const mockRes = {} as any + const mockRes = {} as unknown as Response // Use a Promise to handle the async local storage execution const result = await new Promise((resolve, reject) => { diff --git a/src/release-notes/pages/release-notes.tsx b/src/release-notes/pages/release-notes.tsx index 7fc7cd70be34..949476c3573d 100644 --- a/src/release-notes/pages/release-notes.tsx +++ b/src/release-notes/pages/release-notes.tsx @@ -1,6 +1,8 @@ import { GetServerSideProps } from 'next' import { Liquid } from 'liquidjs' import pick from 'lodash/pick' +import get from 'lodash/get' +import type { Response } from 'express' import { MainContextT, @@ -11,6 +13,7 @@ import { import { DefaultLayout } from '@/frame/components/DefaultLayout' import { GHESReleaseNotes } from '@/release-notes/components/GHESReleaseNotes' import { GHESReleaseNotesContextT } from '@/release-notes/components/types' +import type { ExtendedRequest } from '@/types' const liquid = new Liquid() type Props = { @@ -33,22 +36,30 @@ export default function ReleaseNotes({ mainContext, ghesContext }: Props) { ) } -export const getServerSideProps: GetServerSideProps = async (context) => { - const req = context.req as any - const res = context.res as any +export const getServerSideProps: GetServerSideProps = async ( + context, +): Promise<{ props: Props }> => { + const req = context.req as unknown as ExtendedRequest + const res = context.res as unknown as Response // The `req.context.allVersion[X]` entries contains more keys (and values) // than we need so only pick out the keys that are actually needed // explicitly in the components served from these props. - const currentVersion = pick(req.context.allVersions[req.context.currentVersion], [ + const currentVersion = pick(req.context!.allVersions?.[req.context!.currentVersion!] || {}, [ 'plan', 'planTitle', 'versionTitle', 'currentRelease', 'releases', - ]) + ]) as { + plan?: string + planTitle?: string + versionTitle?: string + currentRelease?: string + releases?: string[] + } - const { latestPatch = '', latestRelease = '' } = req.context + const { latestPatch = '', latestRelease = '' } = req.context! const mainContext = await getMainContext(req, res) addUINamespaces(req, mainContext.data.ui, ['release_notes']) @@ -58,28 +69,39 @@ export const getServerSideProps: GetServerSideProps = async (context) => mainContext, ghesContext: currentVersion.plan === 'enterprise-server' - ? { + ? ({ currentVersion, latestPatch, latestRelease, - releaseNotes: req.context.ghesReleaseNotes, - releases: req.context.ghesReleases, + releaseNotes: req.context!.ghesReleaseNotes || [], + releases: req.context!.ghesReleases || [], message: { ghes_release_notes_upgrade_patch_only: liquid.parseAndRenderSync( - req.context.site.data.ui.header.notices.ghes_release_notes_upgrade_patch_only, + get( + req.context, + 'site.data.ui.header.notices.ghes_release_notes_upgrade_patch_only', + '', + ) as string, { latestPatch, latestRelease }, ), ghes_release_notes_upgrade_release_only: liquid.parseAndRenderSync( - req.context.site.data.ui.header.notices.ghes_release_notes_upgrade_release_only, + get( + req.context, + 'site.data.ui.header.notices.ghes_release_notes_upgrade_release_only', + '', + ) as string, { latestPatch, latestRelease }, ), ghes_release_notes_upgrade_patch_and_release: liquid.parseAndRenderSync( - req.context.site.data.ui.header.notices - .ghes_release_notes_upgrade_patch_and_release, + get( + req.context, + 'site.data.ui.header.notices.ghes_release_notes_upgrade_patch_and_release', + '', + ) as string, { latestPatch, latestRelease }, ), }, - } + } as unknown as GHESReleaseNotesContextT) : null, }, } diff --git a/src/search/scripts/scrape/lib/build-records.ts b/src/search/scripts/scrape/lib/build-records.ts index 59b0560fd52b..d5a3b0336b2a 100644 --- a/src/search/scripts/scrape/lib/build-records.ts +++ b/src/search/scripts/scrape/lib/build-records.ts @@ -130,12 +130,15 @@ export default async function buildRecords( }) .on('error', (err) => { // Track the failure - const url = (err as any).url - const relativePath = (err as any).relativePath + const url = (err as unknown as { url?: string }).url + const relativePath = (err as unknown as { relativePath?: string }).relativePath // Check for HTTPError by name since it may come from a different module - if ((err instanceof HTTPError || err?.name === 'HTTPError') && (err as any).response) { - const httpErr = err as any + if ( + (err instanceof HTTPError || err?.name === 'HTTPError') && + (err as unknown as HTTPError).response + ) { + const httpErr = err as unknown as HTTPError failedPages.push({ url: httpErr.request?.requestUrl?.pathname || url, relativePath, @@ -146,7 +149,7 @@ export default async function buildRecords( if (!noMarkers) process.stdout.write(chalk.red('✗')) } else if (err instanceof Error) { // Enhanced error handling for timeout and network errors - const errorType = (err.cause as any)?.code || err.name + const errorType = (err.cause as unknown as { code?: string })?.code || err.name const isTimeout = errorType === 'UND_ERR_HEADERS_TIMEOUT' || errorType === 'UND_ERR_CONNECT_TIMEOUT' || diff --git a/src/versions/lib/get-applicable-versions.ts b/src/versions/lib/get-applicable-versions.ts index fdaf58e3424c..e814c1ab1b7f 100644 --- a/src/versions/lib/get-applicable-versions.ts +++ b/src/versions/lib/get-applicable-versions.ts @@ -14,8 +14,14 @@ interface GetApplicableVersionsOptions { includeNextVersion?: boolean } -// Using any for feature data as it's dynamically loaded from YAML files -let featureData: any = null +interface FeatureData { + [featureName: string]: { + versions: VersionsObject + } +} + +// Feature data is dynamically loaded from YAML files +let featureData: FeatureData | null = null const allVersionKeys = Object.keys(allVersions) @@ -55,13 +61,13 @@ function getApplicableVersions( ? {} : reduce( versionsObj, - (result: any, value, key) => { + (result: VersionsObject, value, key) => { if (key === 'feature') { if (typeof value === 'string') { - Object.assign(result, { ...featureData[value]?.versions }) + Object.assign(result, { ...featureData?.[value]?.versions }) } else if (Array.isArray(value)) { for (const str of value) { - Object.assign(result, { ...featureData[str].versions }) + Object.assign(result, { ...featureData?.[str]?.versions }) } } delete result[key] diff --git a/src/workflows/lib/in-liquid.ts b/src/workflows/lib/in-liquid.ts index d8350a0f75c2..a8648e4292d1 100644 --- a/src/workflows/lib/in-liquid.ts +++ b/src/workflows/lib/in-liquid.ts @@ -1,17 +1,27 @@ import { getLiquidTokens } from '@/content-linter/lib/helpers/liquid-utils' +import type { TagToken } from 'liquidjs' +import { TokenKind } from 'liquidjs' -type Token = { - name?: string - args?: string +// Type guard to check if a token is a TagToken +function isTagToken(token: unknown): token is TagToken { + return ( + token !== null && + typeof token === 'object' && + 'kind' in token && + token.kind === TokenKind.Tag && + 'name' in token && + typeof token.name === 'string' && + 'args' in token + ) } -const parsedLiquidTokensCache = new Map() +const parsedLiquidTokensCache = new Map() export function inLiquid(filePath: string, fileContents: string, needle: string) { if (!parsedLiquidTokensCache.has(filePath)) { - parsedLiquidTokensCache.set(filePath, getLiquidTokens(fileContents)) + parsedLiquidTokensCache.set(filePath, getLiquidTokens(fileContents).filter(isTagToken)) } - const tokens = parsedLiquidTokensCache.get(filePath) as Token[] + const tokens = parsedLiquidTokensCache.get(filePath)! for (const token of tokens) { if (token.name === 'data') { const { args } = token