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