diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 46fa6e2d40bf..b04da84f0758 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -63,7 +63,7 @@ // Lifecycle commands // Start a web server and keep it running - "postStartCommand": "nohup bash -c 'npm start &'", + "postStartCommand": "nohup bash -c 'npm ci && npm start &'", // Set port 4000 to be public "postAttachCommand": "gh cs ports visibility 4000:public -c \"$CODESPACE_NAME\"", diff --git a/content/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens.md b/content/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens.md index 0da4f95c8461..9cc6c1285c71 100644 --- a/content/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens.md +++ b/content/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens.md @@ -144,6 +144,7 @@ Below are some example URLs that generate the tokens we see most often: * [GitHub Models access](https://github.com/settings/personal-access-tokens/new?name=GitHub+Models+token&description=Used%20to%20call%20GitHub%20Models%20APIs%20to%20easily%20run%20LLMs%3A%20https%3A%2F%2Fdocs.github.com%2Fgithub-models%2Fquickstart%23step-2-make-an-api-call&user_models=read) * [Update code and open a PR](https://github.com/settings/personal-access-tokens/new?name=Core-loop+token&description=Write%20code%20and%20push%20it%20to%20main%21%20Includes%20permission%20to%20edit%20workflow%20files%20for%20Actions%20-%20remove%20%60workflows%3Awrite%60%20if%20you%20don%27t%20need%20to%20do%20that&contents=write&pull_requests=write&workflows=write) * [Manage Copilot licenses in an organization](https://github.com/settings/personal-access-tokens/new?name=Core-loop+token&description=Enable%20or%20disable%20copilot%20access%20for%20users%20with%20the%20Seat%20Management%20APIs%3A%20https%3A%2F%2Fdocs.github.com%2Frest%2Fcopilot%2Fcopilot-user-management%0ABe%20sure%20to%20select%20an%20organization%20for%20your%20resource%20owner%20below%21&organization_copilot_seat_management=write) +* [Make Copilot requests](https://github.com/settings/personal-access-tokens/new?name=Copilot+requests+token&description=Make%20Copilot%20API%20requests%20on%20behalf%20of%20the%20user%2C%20consuming%20premium%20requests%3A%20https%3A%2F%2Fdocs.github.com%2Fcopilot%2Fconcepts%2Fbilling%2Fcopilot-requests&copilot_requests=write) #### Supported Query Parameters @@ -173,6 +174,7 @@ Account permissions are only used when the current user is set as the resource o | `codespaces_user_secrets` | Codespaces user secrets | `read`, `write` | | `copilot_messages` | Copilot Chat | `read` | | `copilot_editor_context` | Copilot Editor Context | `read` | +| `copilot_requests` | Copilot requests | `write` | | `emails` | Email addresses | `read`, `write` | | `user_events` | Events | `read` | | `followers` | Followers | `read`, `write` | @@ -189,6 +191,12 @@ Account permissions are only used when the current user is set as the resource o | `starring` | Starring | `read`, `write` | | `watching` | Watching | `read`, `write` | +{% ifversion copilot %} + +> [!NOTE] +> The `copilot_requests` permission enables making {% data variables.product.prodname_copilot_short %} requests for the given user, which count towards the user's premium request allowance or are charged to overage billing if the allowance is exceeded. For more information about {% data variables.product.prodname_copilot_short %} requests and billing, see [AUTOTITLE](/copilot/concepts/billing/copilot-requests). + +{% endif %} ##### Repository Permissions Repository permissions work for both user and organization resource owners. diff --git a/content/get-started/git-basics/set-up-git.md b/content/get-started/git-basics/set-up-git.md index 6720f0774066..0bf9b9fb37f8 100644 --- a/content/get-started/git-basics/set-up-git.md +++ b/content/get-started/git-basics/set-up-git.md @@ -27,7 +27,7 @@ topics: --- ## Using Git -To use Git on the command line, you will need to download, install, and configure Git on your computer. You can also install {% data variables.product.prodname_cli %} to use {% data variables.product.prodname_dotcom %} from the command line. For more information, see [AUTOTITLE](/github-cli/github-cli/about-github-cli). +To use Git on the command line, you need to download, install, and configure Git on your computer. You can also install {% data variables.product.prodname_cli %} to use {% data variables.product.prodname_dotcom %} from the command line. For more information, see [AUTOTITLE](/github-cli/github-cli/about-github-cli). If you want to work with Git locally, but do not want to use the command line, you can download and install the [{% data variables.product.prodname_desktop %}]({% data variables.product.desktop_link %}) client. For more information, see [AUTOTITLE](/desktop/overview/about-github-desktop). @@ -54,7 +54,7 @@ If you do not need to work with files locally, {% data variables.product.github ## Authenticating with {% data variables.product.github %} from Git -When you connect to a {% data variables.product.github %} repository from Git, you will need to authenticate with {% data variables.product.github %} using either HTTPS or SSH. +When you connect to a {% data variables.product.github %} repository from Git, you need to authenticate with {% data variables.product.github %} using either HTTPS or SSH. > [!NOTE] > You can authenticate to {% data variables.product.github %} using {% data variables.product.prodname_cli %}, for either HTTP or SSH. For more information, see [`gh auth login`](https://cli.github.com/manual/gh_auth_login). diff --git a/content/migrations/ado/migrating-repositories-from-azure-devops-to-github-enterprise-cloud.md b/content/migrations/ado/migrating-repositories-from-azure-devops-to-github-enterprise-cloud.md index 02003b38b1da..e11a3c15b9f7 100644 --- a/content/migrations/ado/migrating-repositories-from-azure-devops-to-github-enterprise-cloud.md +++ b/content/migrations/ado/migrating-repositories-from-azure-devops-to-github-enterprise-cloud.md @@ -27,7 +27,7 @@ contentType: other {% endapi %} {% ifversion repo-rules-enterprise %} -{% data reusables.enterprise-migration-tool.deploy-key-bypass %} +{% data reusables.enterprise-migration-tool.repository-migrations-bypass %} {% endif %} ## Prerequisites diff --git a/content/migrations/using-github-enterprise-importer/migrating-between-github-products/about-migrations-between-github-products.md b/content/migrations/using-github-enterprise-importer/migrating-between-github-products/about-migrations-between-github-products.md index 4fec001d16e2..178be86294f3 100644 --- a/content/migrations/using-github-enterprise-importer/migrating-between-github-products/about-migrations-between-github-products.md +++ b/content/migrations/using-github-enterprise-importer/migrating-between-github-products/about-migrations-between-github-products.md @@ -23,7 +23,7 @@ If your migration source is an account on {% data variables.product.prodname_dot The data that {% data variables.product.prodname_importer_proper_name %} migrates depends on the source of the migration and whether you are migrating a repository or organization. {% ifversion repo-rules-enterprise %} -{% data reusables.enterprise-migration-tool.deploy-key-bypass %} +{% data reusables.enterprise-migration-tool.repository-migrations-bypass %} {% endif %} ## Considerations for migrations to {% data variables.product.prodname_ghe_cloud %} diff --git a/content/migrations/using-github-enterprise-importer/migrating-between-github-products/migrating-organizations-from-githubcom-to-github-enterprise-cloud.md b/content/migrations/using-github-enterprise-importer/migrating-between-github-products/migrating-organizations-from-githubcom-to-github-enterprise-cloud.md index e0d67fe57a4f..d649ec4c3df2 100644 --- a/content/migrations/using-github-enterprise-importer/migrating-between-github-products/migrating-organizations-from-githubcom-to-github-enterprise-cloud.md +++ b/content/migrations/using-github-enterprise-importer/migrating-between-github-products/migrating-organizations-from-githubcom-to-github-enterprise-cloud.md @@ -26,6 +26,10 @@ Migrations to {% data variables.product.prodname_ghe_cloud %} include migrations {% data reusables.enterprise-migration-tool.gei-tool-switcher-cli %} {% endapi %} +{% ifversion repo-rules-enterprise %} +{% data reusables.enterprise-migration-tool.repository-migrations-bypass %} +{% endif %} + ## Prerequisites * {% data reusables.enterprise-migration-tool.github-trial-prerequisite %} diff --git a/content/migrations/using-github-enterprise-importer/migrating-between-github-products/migrating-repositories-from-github-enterprise-server-to-github-enterprise-cloud.md b/content/migrations/using-github-enterprise-importer/migrating-between-github-products/migrating-repositories-from-github-enterprise-server-to-github-enterprise-cloud.md index c8723b000a79..3b4aa29920e8 100644 --- a/content/migrations/using-github-enterprise-importer/migrating-between-github-products/migrating-repositories-from-github-enterprise-server-to-github-enterprise-cloud.md +++ b/content/migrations/using-github-enterprise-importer/migrating-between-github-products/migrating-repositories-from-github-enterprise-server-to-github-enterprise-cloud.md @@ -47,6 +47,10 @@ To migrate your repositories from {% data variables.product.prodname_ghe_server {% endapi %} +{% ifversion repo-rules-enterprise %} +{% data reusables.enterprise-migration-tool.repository-migrations-bypass %} +{% endif %} + ## Prerequisites * {% data reusables.enterprise-migration-tool.github-trial-prerequisite %} diff --git a/content/migrations/using-github-enterprise-importer/migrating-between-github-products/migrating-repositories-from-githubcom-to-github-enterprise-cloud.md b/content/migrations/using-github-enterprise-importer/migrating-between-github-products/migrating-repositories-from-githubcom-to-github-enterprise-cloud.md index 5eea87d3c797..fad9d61e2f04 100644 --- a/content/migrations/using-github-enterprise-importer/migrating-between-github-products/migrating-repositories-from-githubcom-to-github-enterprise-cloud.md +++ b/content/migrations/using-github-enterprise-importer/migrating-between-github-products/migrating-repositories-from-githubcom-to-github-enterprise-cloud.md @@ -27,7 +27,7 @@ Migrations to {% data variables.product.prodname_ghe_cloud %} include migrations {% endapi %} {% ifversion repo-rules-enterprise %} -{% data reusables.enterprise-migration-tool.deploy-key-bypass %} +{% data reusables.enterprise-migration-tool.repository-migrations-bypass %} {% endif %} ## Prerequisites diff --git a/content/migrations/using-github-enterprise-importer/migrating-from-bitbucket-server-to-github-enterprise-cloud/migrating-repositories-from-bitbucket-server-to-github-enterprise-cloud.md b/content/migrations/using-github-enterprise-importer/migrating-from-bitbucket-server-to-github-enterprise-cloud/migrating-repositories-from-bitbucket-server-to-github-enterprise-cloud.md index e0c99a0a22eb..0457d6e901c0 100644 --- a/content/migrations/using-github-enterprise-importer/migrating-from-bitbucket-server-to-github-enterprise-cloud/migrating-repositories-from-bitbucket-server-to-github-enterprise-cloud.md +++ b/content/migrations/using-github-enterprise-importer/migrating-from-bitbucket-server-to-github-enterprise-cloud/migrating-repositories-from-bitbucket-server-to-github-enterprise-cloud.md @@ -19,7 +19,7 @@ You can migrate individual repositories or all repositories from a BitBucket Ser At this time, migrating from Bitbucket Server with the {% data variables.product.prodname_dotcom %} API is not supported. {% ifversion repo-rules-enterprise %} -{% data reusables.enterprise-migration-tool.deploy-key-bypass %} +{% data reusables.enterprise-migration-tool.repository-migrations-bypass %} {% endif %} ## Prerequisites diff --git a/data/release-notes/enterprise-server/3-19/1.yml b/data/release-notes/enterprise-server/3-19/1.yml index c4509f159c4f..b706c75a215d 100644 --- a/data/release-notes/enterprise-server/3-19/1.yml +++ b/data/release-notes/enterprise-server/3-19/1.yml @@ -87,3 +87,5 @@ sections: When publishing npm packages in a workflow after restoring from a backup to GitHub Enterprise Server 3.13.5.gm4 or 3.14.2.gm3, you may encounter a `401 Unauthorized` error from the GitHub Packages service. This can happen if the restore is from an N-1 or N-2 version and the workflow targets the npm endpoint on the backup instance. To avoid this issue, ensure the access token is valid and includes the correct scopes for publishing to GitHub Packages. - | The setting to define private registries at the organization level for code scanning is only available if Dependabot is enabled for the instance. + - | + In patch 3.19.1, we identified an issue in the Management Console where the Backups (Preview) and Updates tabs may fail to open and instead return an Internal Server Error. We recommend using the command line interface (CLI) for backups and updates until an updated patch is released. [Updated: 2026-01-13] diff --git a/data/reusables/enterprise-migration-tool/deploy-key-bypass.md b/data/reusables/enterprise-migration-tool/deploy-key-bypass.md index 6d7e8d5b20f8..8786c6c8c50f 100644 --- a/data/reusables/enterprise-migration-tool/deploy-key-bypass.md +++ b/data/reusables/enterprise-migration-tool/deploy-key-bypass.md @@ -1,3 +1,4 @@ > [!NOTE] If the repository you are migrating has rulesets that the incoming repository doesn't match, the migration will be blocked. To bypass these rulesets and allow the migration, you can apply a ruleset bypass for all deploy keys in the target organization. > > Repository rulesets can be set at the organization level. If the incoming repository does not match any of these rulesets, you will need to use the deploy key bypass for each one. See [AUTOTITLE](/organizations/managing-organization-settings/creating-rulesets-for-repositories-in-your-organization#granting-bypass-permissions-for-your-branch-or-tag-ruleset). +> diff --git a/data/reusables/enterprise-migration-tool/repository-migrations-bypass.md b/data/reusables/enterprise-migration-tool/repository-migrations-bypass.md new file mode 100644 index 000000000000..396da2dc8647 --- /dev/null +++ b/data/reusables/enterprise-migration-tool/repository-migrations-bypass.md @@ -0,0 +1,9 @@ +If the destination organization or enterprise of your migration has rulesets enabled, the migrated repository's history may violate those rules. To allow the migration without disabling your rulesets, add "Repository migrations" to the bypass list for each applicable ruleset. This bypass applies only during the migration. Once complete, rulesets will be enforced on all new contributions. + +To configure the bypass: + +1. Navigate to each enterprise or organization ruleset. +1. In the "Bypass list" section, click **Add bypass**. +1. Select **Repository migrations**. + +For more information, see [AUTOTITLE](/organizations/managing-organization-settings/creating-rulesets-for-repositories-in-your-organization#granting-bypass-permissions-for-your-branch-or-tag-ruleset). diff --git a/src/article-api/lib/graphql-helpers.ts b/src/article-api/lib/graphql-helpers.ts new file mode 100644 index 000000000000..6db482d7ff06 --- /dev/null +++ b/src/article-api/lib/graphql-helpers.ts @@ -0,0 +1,32 @@ +import type { Context, Page } from '@/types' +import { renderContent } from '@/content-render/index' +import matter from '@gr2m/gray-matter' + +/** + * Extract manual content from page markdown + * Used by GraphQL transformers to get content before the auto-generated marker + */ +export async function extractManualContent(page: Page, context: Context): Promise { + if (!page.markdown) return '' + + const markerIndex = page.markdown.indexOf( + '', + ) + + if (markerIndex <= 0) return '' + + const { content } = matter(page.markdown) + const manualContentMarkerIndex = content.indexOf( + '', + ) + + if (manualContentMarkerIndex <= 0) return '' + + const rawManualContent = content.substring(0, manualContentMarkerIndex).trim() + if (!rawManualContent) return '' + + return await renderContent(rawManualContent, { + ...context, + markdownRequested: true, + }) +} diff --git a/src/article-api/middleware/article-body.ts b/src/article-api/middleware/article-body.ts index 31dd93626ed6..c6b82b59f627 100644 --- a/src/article-api/middleware/article-body.ts +++ b/src/article-api/middleware/article-body.ts @@ -74,5 +74,20 @@ export async function getArticleBody(req: ExtendedRequestWithPageInfo) { // these parts allow us to render the page const renderingReq = await createContextualizedRenderingRequest(pathname, page) renderingReq.context.markdownRequested = true - return await page.render(renderingReq.context) + const content = await page.render(renderingReq.context) + + // Get title and intro for consistency with transformer-based pages + const title = page.title + const intro = page.intro + ? await page.renderProp('intro', renderingReq.context, { textOnly: true }) + : '' + + // Prepend title and intro to the content + let result = `# ${title}\n\n` + if (intro) { + result += `${intro}\n\n` + } + result += content + + return result } diff --git a/src/article-api/templates/codeql-cli-page.template.md b/src/article-api/templates/codeql-cli-page.template.md new file mode 100644 index 000000000000..a497a9dde462 --- /dev/null +++ b/src/article-api/templates/codeql-cli-page.template.md @@ -0,0 +1,7 @@ +# {{ page.title }} + +{% if page.intro %} +{{ page.intro }} +{% endif %} + +{{ content }} diff --git a/src/article-api/templates/secret-scanning-page.template.md b/src/article-api/templates/secret-scanning-page.template.md new file mode 100644 index 000000000000..a497a9dde462 --- /dev/null +++ b/src/article-api/templates/secret-scanning-page.template.md @@ -0,0 +1,7 @@ +# {{ page.title }} + +{% if page.intro %} +{{ page.intro }} +{% endif %} + +{{ content }} diff --git a/src/article-api/templates/webhooks-page.template.md b/src/article-api/templates/webhooks-page.template.md index c7a4401d0791..dbf51b71d30f 100644 --- a/src/article-api/templates/webhooks-page.template.md +++ b/src/article-api/templates/webhooks-page.template.md @@ -11,12 +11,42 @@ {% for webhook in webhooks %} ## {{ webhook.name }} -**Available actions:** {% for actionType in webhook.actionTypes %}{% if forloop.last and forloop.length > 1 %}and {% endif %}`{{ actionType }}`{% unless forloop.last %}{% if forloop.length > 2 %}, {% else %} {% endif %}{% endunless %}{% endfor %} +{% if webhook.summary %} +{{ webhook.summary }} -{% if webhook.data.descriptionHtml %} -{{ webhook.data.descriptionHtml }} {% endif %} +{% if webhook.availability.size > 0 %} +### Availability -**Availability:** {% for availability in webhook.data.availability %}{% if forloop.last and forloop.length > 1 %}and {% endif %}`{{ availability }}`{% unless forloop.last %}{% if forloop.length > 2 %}, {% else %} {% endif %}{% endunless %}{% endfor %} +{% for avail in webhook.availability %}- `{{ avail }}` +{% endfor %} + +{% endif %} +### Webhook payload object + +{% if webhook.actionTypes.size > 1 %} +**Action type:** {% for actionType in webhook.actionTypes %}`{{ actionType }}`{% unless forloop.last %}, {% endunless %}{% endfor %} + +{% endif %} +{% if webhook.description %} +{{ webhook.description }} + +{% endif %} +{% if webhook.bodyParameters.size > 0 %} +#### Webhook payload object parameters + +| Name | Type | Description | +|------|------|-------------| +{% for param in webhook.bodyParameters %}| `{{ param.name }}` | `{{ param.type }}` | {% if param.isRequired %}**Required.** {% endif %}{{ param.description }} | +{% endfor %} +{% endif %} +{% if webhook.payloadExample %} +### Webhook payload example + +```json +{{ webhook.payloadExample }} +``` + +{% endif %} {% endfor %} diff --git a/src/article-api/tests/article-body.ts b/src/article-api/tests/article-body.ts index 5d2814cfa41b..26eb956da5d0 100644 --- a/src/article-api/tests/article-body.ts +++ b/src/article-api/tests/article-body.ts @@ -25,6 +25,15 @@ describe('article body api', () => { expect(res.headers['content-type']).toContain('text/markdown') }) + test('body includes title and intro', async () => { + const res = await get(makeURL('/en/get-started/start-your-journey/hello-world')) + expect(res.statusCode).toBe(200) + // Body should start with the page title as H1 + expect(res.body).toMatch(/^# Hello World/) + // Body should include the intro after the title + expect(res.body).toContain('Follow this Hello World exercise to get started with') + }) + test('octicons auto-generate aria-labels', async () => { const res = await get(makeURL('/en/get-started/start-your-journey/hello-world')) expect(res.statusCode).toBe(200) diff --git a/src/article-api/tests/bespoke-landing-transformer.ts b/src/article-api/tests/bespoke-landing-transformer.ts new file mode 100644 index 000000000000..0e6828ea5c0a --- /dev/null +++ b/src/article-api/tests/bespoke-landing-transformer.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from 'vitest' + +import { get } from '@/tests/helpers/e2etest' + +const makeURL = (pathname: string): string => + `/api/article/body?${new URLSearchParams({ pathname })}` + +describe('bespoke landing transformer', () => { + test('renders a bespoke landing page with all sections', async () => { + // /en/get-started/article-grid-bespoke is a bespoke landing page + const res = await get(makeURL('/en/get-started/article-grid-bespoke')) + expect(res.statusCode).toBe(200) + expect(res.headers['content-type']).toContain('text/markdown') + + // Check for title + expect(res.body).toContain('# Article Grid Bespoke Landing') + + // Should have intro + expect(res.body).toContain('A test page for testing') + }) + + test('renders all descendant articles recursively', async () => { + const res = await get(makeURL('/en/get-started/article-grid-bespoke')) + expect(res.statusCode).toBe(200) + + // Should have Articles section with all descendant articles (recursive) + expect(res.body).toContain('## Articles') + expect(res.body).toContain('[Grid Article One]') + expect(res.body).toContain('[Grid Article Two]') + expect(res.body).toContain('[Grid Article Three]') + expect(res.body).toContain('[Grid Article Four]') + }) +}) diff --git a/src/article-api/tests/journey-landing-transformer.ts b/src/article-api/tests/journey-landing-transformer.ts new file mode 100644 index 000000000000..09e56d9af8fc --- /dev/null +++ b/src/article-api/tests/journey-landing-transformer.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from 'vitest' + +import { get } from '@/tests/helpers/e2etest' + +const makeURL = (pathname: string): string => + `/api/article/body?${new URLSearchParams({ pathname })}` + +describe('journey landing transformer', () => { + test('renders a journey landing page in markdown', async () => { + // /en/get-started/test-journey is a journey landing page in the fixtures + const res = await get(makeURL('/en/get-started/test-journey')) + expect(res.statusCode).toBe(200) + expect(res.headers['content-type']).toContain('text/markdown') + + // Check for journey tracks (now under Links section with track title as h3) + expect(res.body).toContain('## Links') + expect(res.body).toContain('### First Track') + expect(res.body).toContain('* [Hello World](/en/get-started/start-your-journey/hello-world)') + }) +}) diff --git a/src/article-api/tests/webhooks-transformer.ts b/src/article-api/tests/webhooks-transformer.ts index 630b45ebfba8..4bf18d7ef21f 100644 --- a/src/article-api/tests/webhooks-transformer.ts +++ b/src/article-api/tests/webhooks-transformer.ts @@ -93,8 +93,8 @@ describe('Webhooks transformer', () => { // Should show payload object parameters section expect(res.body).toContain('### Webhook payload object') expect(res.body).toContain('#### Webhook payload object parameters') - // Should have a markdown table with parameter columns - expect(res.body).toContain('| Name | Type | Description |') + // Should have a markdown table with parameter columns (may have extra spacing from formatting) + expect(res.body).toMatch(/\|\s*Name\s*\|\s*Type\s*\|\s*Description\s*\|/) }) test('webhooks show descriptions', async () => { diff --git a/src/article-api/transformers/audit-logs-transformer.ts b/src/article-api/transformers/audit-logs-transformer.ts index 962e47c3d884..1da904f694d8 100644 --- a/src/article-api/transformers/audit-logs-transformer.ts +++ b/src/article-api/transformers/audit-logs-transformer.ts @@ -2,19 +2,16 @@ import type { Context, Page } from '@/types' import type { PageTransformer } from './types' import type { CategorizedEvents } from '@/audit-logs/types' import { renderContent } from '@/content-render/index' +import { loadTemplate } from '@/article-api/lib/load-template' import matter from '@gr2m/gray-matter' -import { readFileSync } from 'fs' -import { join, dirname } from 'path' -import { fileURLToPath } from 'url' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = dirname(__filename) /** * Transformer for Audit Logs pages * Converts audit log events and their data into markdown format using a Liquid template */ export class AuditLogsTransformer implements PageTransformer { + templateName = 'audit-logs-page.template.md' + canTransform(page: Page): boolean { return page.autogenerated === 'audit-logs' } @@ -76,8 +73,7 @@ export class AuditLogsTransformer implements PageTransformer { ) // Load and render template - const templatePath = join(__dirname, '../templates/audit-logs-page.template.md') - const templateContent = readFileSync(templatePath, 'utf8') + const templateContent = loadTemplate(this.templateName) // Render the template with Liquid const rendered = await renderContent(templateContent, { diff --git a/src/article-api/transformers/bespoke-landing-transformer.ts b/src/article-api/transformers/bespoke-landing-transformer.ts new file mode 100644 index 000000000000..e3c52ed3a35e --- /dev/null +++ b/src/article-api/transformers/bespoke-landing-transformer.ts @@ -0,0 +1,121 @@ +import type { Context, Page } from '@/types' +import type { PageTransformer, TemplateData, Section, LinkData } from './types' +import { renderContent } from '@/content-render/index' +import { loadTemplate } from '@/article-api/lib/load-template' +import { getAllTocItems, flattenTocItems } from '@/article-api/lib/get-all-toc-items' + +interface RecommendedItem { + href: string + title?: string + intro?: string +} + +interface BespokeLandingPage extends Omit { + featuredLinks?: Record> + children?: string[] + recommended?: RecommendedItem[] + rawRecommended?: string[] + includedCategories?: string[] +} + +/** + * Transforms bespoke-landing pages into markdown format. + * Handles recommended carousel and full article listings. + * Note: Unlike discovery-landing, bespoke-landing shows ALL articles + * regardless of includedCategories. + */ +export class BespokeLandingTransformer implements PageTransformer { + templateName = 'landing-page.template.md' + + canTransform(page: Page): boolean { + return page.layout === 'bespoke-landing' + } + + async transform(page: Page, pathname: string, context: Context): Promise { + const templateData = await this.prepareTemplateData(page, pathname, context) + + const templateContent = loadTemplate(this.templateName) + + const rendered = await renderContent(templateContent, { + ...context, + ...templateData, + markdownRequested: true, + }) + + return rendered + } + + private async prepareTemplateData( + page: Page, + _pathname: string, + context: Context, + ): Promise { + const bespokePage = page as BespokeLandingPage + const sections: Section[] = [] + + // Recommended carousel + const recommended = bespokePage.recommended ?? bespokePage.rawRecommended + if (recommended && recommended.length > 0) { + const { default: getLearningTrackLinkData } = await import( + '@/learning-track/lib/get-link-data' + ) + + let links: LinkData[] + if (typeof recommended[0] === 'object' && 'title' in recommended[0]) { + links = recommended.map((item) => ({ + href: typeof item === 'string' ? item : item.href, + title: (typeof item === 'object' && item.title) || '', + intro: (typeof item === 'object' && item.intro) || '', + })) + } else { + const linkData = await getLearningTrackLinkData(recommended as string[], context, { + title: true, + intro: true, + }) + links = (linkData || []).map((item: { href: string; title?: string; intro?: string }) => ({ + href: item.href, + title: item.title || '', + intro: item.intro || '', + })) + } + + const validLinks = links.filter((l) => l.href && l.title) + if (validLinks.length > 0) { + sections.push({ + title: 'Recommended', + groups: [{ title: null, links: validLinks }], + }) + } + } + + // Articles section: recursively gather ALL descendant articles + // This matches the behavior of the site which uses genericTocFlat/genericTocNested + // Note: For bespoke-landing pages, the site shows ALL articles regardless of includedCategories + // (includedCategories only filters for discovery-landing pages) + if (bespokePage.children && bespokePage.children.length > 0) { + const tocItems = await getAllTocItems(page, context, { + recurse: true, + renderIntros: true, + }) + + // Flatten to get all leaf articles (excludeParents: true means only get articles, not category pages) + const allArticles = flattenTocItems(tocItems, { excludeParents: true }) + + if (allArticles.length > 0) { + sections.push({ + title: 'Articles', + groups: [{ title: null, links: allArticles }], + }) + } + } + + const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : '' + const title = await page.renderTitle(context, { unwrap: true }) + + return { + title, + intro, + sections, + } + } +} diff --git a/src/article-api/transformers/codeql-cli-transformer.ts b/src/article-api/transformers/codeql-cli-transformer.ts index 9aefa8133845..f32c8fbc903e 100644 --- a/src/article-api/transformers/codeql-cli-transformer.ts +++ b/src/article-api/transformers/codeql-cli-transformer.ts @@ -1,28 +1,47 @@ import type { Context, Page } from '@/types' import type { PageTransformer } from './types' +import { renderContent } from '@/content-render/index' +import { loadTemplate } from '@/article-api/lib/load-template' import { stripHtmlCommentsAndNormalizeWhitespace } from '@/article-api/lib/strip-html-comments' /** * Transformer for CodeQL CLI reference pages. - * Renders autogenerated CodeQL CLI documentation pages as markdown. + * Renders autogenerated CodeQL CLI documentation pages as markdown using a Liquid template. * Sets `markdownRequested` to true in the context to ensure the page is rendered as markdown, * bypassing the default article type check. */ export class CodeQLCliTransformer implements PageTransformer { + templateName = 'codeql-cli-page.template.md' + canTransform(page: Page): boolean { return page.autogenerated === 'codeql-cli' } async transform(page: Page, _pathname: string, context: Context): Promise { // CodeQL CLI pages are fully generated markdown files in the repo. - // We render them with markdownRequested=true to get the markdown output, - // similar to how regular articles are rendered but through the transformer pattern. + // We render them with markdownRequested=true to get the markdown output. context.markdownRequested = true const content = await page.render(context) const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : '' - const result = `# ${page.title}\n\n${intro}\n\n${content}` + // Prepare template data + const templateData: Record = { + page: { + title: page.title, + intro, + }, + content, + } + + // Load and render template + const templateContent = loadTemplate(this.templateName) + + const result = await renderContent(templateContent, { + ...context, + ...templateData, + markdownRequested: true, + }) // Strip HTML comments (e.g., markdownlint-disable comments) from the output return stripHtmlCommentsAndNormalizeWhitespace(result) diff --git a/src/article-api/transformers/github-apps-transformer.ts b/src/article-api/transformers/github-apps-transformer.ts index 522c9873b50d..f04b9b1e3c94 100644 --- a/src/article-api/transformers/github-apps-transformer.ts +++ b/src/article-api/transformers/github-apps-transformer.ts @@ -1,13 +1,9 @@ import type { Context, Page } from '@/types' import type { PageTransformer } from './types' import { renderContent } from '@/content-render/index' +import { loadTemplate } from '@/article-api/lib/load-template' import matter from '@gr2m/gray-matter' -import { readFileSync } from 'fs' -import { join, dirname } from 'path' -import { fileURLToPath } from 'url' -const __filename = fileURLToPath(import.meta.url) -const __dirname = dirname(__filename) const DEBUG = process.env.RUNNER_DEBUG === '1' || process.env.DEBUG === '1' // GitHub Apps data types @@ -91,6 +87,8 @@ const PERMISSIONS_PAGE_TYPES = new Set([ * in TypeScript for permissions pages to avoid Liquid escaping issues. */ export class GithubAppsTransformer implements PageTransformer { + templateName = 'github-apps-page.template.md' + canTransform(page: Page): boolean { return page.autogenerated === 'github-apps' } @@ -160,9 +158,8 @@ export class GithubAppsTransformer implements PageTransformer { isPermissionsPage, ) - // Load and render template - const templatePath = join(__dirname, '../templates/github-apps-page.template.md') - const templateContent = readFileSync(templatePath, 'utf8') + // Load template + const templateContent = loadTemplate(this.templateName) // For permissions pages, we need to construct the tables manually to avoid Liquid escaping let finalContent: string diff --git a/src/article-api/transformers/graphql-breaking-changes-transformer.ts b/src/article-api/transformers/graphql-breaking-changes-transformer.ts new file mode 100644 index 000000000000..e0268c7351c5 --- /dev/null +++ b/src/article-api/transformers/graphql-breaking-changes-transformer.ts @@ -0,0 +1,69 @@ +import type { Context, Page } from '@/types' +import type { PageTransformer } from './types' +import type { BreakingChangesT } from '@/graphql/components/types' +import { renderContent } from '@/content-render/index' +import { loadTemplate } from '@/article-api/lib/load-template' +import { fastTextOnly } from '@/content-render/unified/text-only' +import { extractManualContent } from '@/article-api/lib/graphql-helpers' +import GithubSlugger from 'github-slugger' + +/** + * Transformer for GraphQL breaking changes page + * Renders breaking changes organized by date + */ +export class GraphQLBreakingChangesTransformer implements PageTransformer { + templateName = 'graphql-breaking-changes.template.md' + + canTransform(page: Page): boolean { + if (page.autogenerated !== 'graphql') return false + + return page.relativePath.includes('graphql/overview/breaking-changes') + } + + async transform(page: Page, _pathname: string, context: Context): Promise { + const currentVersion = context.currentVersion! + + const { getGraphqlBreakingChanges } = await import('@/graphql/lib/index') + + const schema = getGraphqlBreakingChanges(currentVersion) as BreakingChangesT + + const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : '' + const manualContent = await extractManualContent(page, context) + + const slugger = new GithubSlugger() + + // Process breaking changes by date + const breakingChangesByDate = Object.keys(schema).map((date) => { + const items = schema[date] + const heading = `Changes scheduled for ${date}` + const slug = slugger.slug(heading) + + return { + date, + heading, + slug, + items: items.map((item) => ({ + location: item.location, + description: fastTextOnly(item.description), + reason: fastTextOnly(item.reason), + criticality: item.criticality, + })), + } + }) + + const templateData: Record = { + pageTitle: page.title, + pageIntro: intro, + manualContent, + breakingChangesByDate, + } + + const templateContent = loadTemplate(this.templateName) + + return await renderContent(templateContent, { + ...context, + ...templateData, + markdownRequested: true, + }) + } +} diff --git a/src/article-api/transformers/graphql-changelog-transformer.ts b/src/article-api/transformers/graphql-changelog-transformer.ts new file mode 100644 index 000000000000..0e3b504cbce0 --- /dev/null +++ b/src/article-api/transformers/graphql-changelog-transformer.ts @@ -0,0 +1,69 @@ +import type { Context, Page } from '@/types' +import type { PageTransformer } from './types' +import type { ChangelogItemT } from '@/graphql/components/types' +import { renderContent } from '@/content-render/index' +import { loadTemplate } from '@/article-api/lib/load-template' +import { fastTextOnly } from '@/content-render/unified/text-only' +import { extractManualContent } from '@/article-api/lib/graphql-helpers' + +/** + * Transformer for GraphQL changelog page + * Renders the changelog with schema changes, preview changes, and upcoming changes + */ +export class GraphQLChangelogTransformer implements PageTransformer { + templateName = 'graphql-changelog.template.md' + + canTransform(page: Page): boolean { + if (page.autogenerated !== 'graphql') return false + + return page.relativePath.includes('graphql/overview/changelog') + } + + async transform(page: Page, _pathname: string, context: Context): Promise { + const currentVersion = context.currentVersion! + + const { getGraphqlChangelog } = await import('@/graphql/lib/index') + + const schema = getGraphqlChangelog(currentVersion) as ChangelogItemT[] + + const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : '' + const manualContent = await extractManualContent(page, context) + + // Process changelog items + const changelogItems = schema.map((item) => { + const processChanges = (changes: Array<{ title: string; changes: string[] }>) => + changes.map((change) => ({ + title: change.title, + changes: change.changes.map((html: string) => { + // Remove wrapping

tags if present + if (html.startsWith('

') && html.endsWith('

')) { + return fastTextOnly(html.slice(3, -4)) + } + return fastTextOnly(html) + }), + })) + + return { + date: item.date, + schemaChanges: processChanges(item.schemaChanges || []), + previewChanges: processChanges(item.previewChanges || []), + upcomingChanges: processChanges(item.upcomingChanges || []), + } + }) + + const templateData: Record = { + pageTitle: page.title, + pageIntro: intro, + manualContent, + changelogItems, + } + + const templateContent = loadTemplate(this.templateName) + + return await renderContent(templateContent, { + ...context, + ...templateData, + markdownRequested: true, + }) + } +} diff --git a/src/article-api/transformers/graphql-index-transformer.ts b/src/article-api/transformers/graphql-index-transformer.ts new file mode 100644 index 000000000000..b46b41cf9100 --- /dev/null +++ b/src/article-api/transformers/graphql-index-transformer.ts @@ -0,0 +1,55 @@ +import type { Context, Page } from '@/types' +import type { PageTransformer } from './types' +import { renderContent } from '@/content-render/index' +import { loadTemplate } from '@/article-api/lib/load-template' +import { extractManualContent } from '@/article-api/lib/graphql-helpers' + +/** + * Transformer for GraphQL reference index page + * Renders the index page with links to child pages + */ +export class GraphQLIndexTransformer implements PageTransformer { + templateName = 'graphql-index.template.md' + + canTransform(page: Page): boolean { + if (page.autogenerated !== 'graphql') return false + + // Match the reference index page (no specific page type after /reference) + return page.relativePath.endsWith('graphql/reference/index.md') + } + + async transform(page: Page, _pathname: string, context: Context): Promise { + const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : '' + + const manualContent = await extractManualContent(page, context) + + // Get children links from page metadata + const children = page.children || [] + const childrenLinks = children + .map((child) => { + const childPath = child.startsWith('/') ? child : `/${child}` + const childName = childPath.split('/').pop() || '' + const displayName = childName + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') + return `- [${displayName}](${childPath})` + }) + .join('\n') + + const templateData: Record = { + pageTitle: page.title, + pageIntro: intro, + manualContent, + childrenLinks, + } + + const templateContent = loadTemplate(this.templateName) + + return await renderContent(templateContent, { + ...context, + ...templateData, + markdownRequested: true, + }) + } +} diff --git a/src/article-api/transformers/graphql-transformer.ts b/src/article-api/transformers/graphql-reference-transformer.ts similarity index 53% rename from src/article-api/transformers/graphql-transformer.ts rename to src/article-api/transformers/graphql-reference-transformer.ts index 12a2c43a049d..418ad9ef5ff9 100644 --- a/src/article-api/transformers/graphql-transformer.ts +++ b/src/article-api/transformers/graphql-reference-transformer.ts @@ -9,28 +9,28 @@ import type { UnionT, InputObjectT, ScalarT, - ChangelogItemT, - BreakingChangesT, FieldT, } from '@/graphql/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 { loadTemplate } from '@/article-api/lib/load-template' import { fastTextOnly } from '@/content-render/unified/text-only' -import GithubSlugger from 'github-slugger' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = dirname(__filename) +import { extractManualContent } from '@/article-api/lib/graphql-helpers' /** - * Transformer for GraphQL pages - * Converts GraphQL schema data into markdown format using Liquid templates + * Transformer for GraphQL reference pages (queries, mutations, objects, etc.) + * Renders schema items with their fields and arguments */ -export class GraphQLTransformer implements PageTransformer { +export class GraphQLReferenceTransformer implements PageTransformer { + templateName = 'graphql-reference.template.md' + canTransform(page: Page): boolean { - return page.autogenerated === 'graphql' + if (page.autogenerated !== 'graphql') return false + + // Match reference pages that have a specific page type (not index) + const isReference = page.relativePath.includes('graphql/reference/') + const isNotIndex = !page.relativePath.endsWith('index.md') + + return isReference && isNotIndex } async transform(page: Page, pathname: string, context: Context): Promise { @@ -39,77 +39,8 @@ export class GraphQLTransformer implements PageTransformer { // Determine the page type from the pathname const pathParts = pathname.split('/').filter(Boolean) const graphqlIndex = pathParts.indexOf('graphql') + const pageType = pathParts[graphqlIndex + 2] // specific page like 'queries', 'mutations', etc. - if (graphqlIndex === -1) { - throw new Error(`Invalid GraphQL path: ${pathname}`) - } - - const section = pathParts[graphqlIndex + 1] // 'reference' or 'overview' - const pageType = pathParts[graphqlIndex + 2] // specific page like 'queries', 'changelog', etc. - - // Handle different GraphQL page types - if (section === 'overview' && pageType === 'changelog') { - return await this.transformChangelog(page, currentVersion, context) - } else if (section === 'overview' && pageType === 'breaking-changes') { - return await this.transformBreakingChanges(page, currentVersion, context) - } else if (section === 'reference' && pageType) { - return await this.transformReference(page, currentVersion, context, pageType) - } else if (section === 'reference' && !pageType) { - // Index page - just render the intro and manual content - return await this.transformIndexPage(page, context) - } - - throw new Error(`Unsupported GraphQL page type: ${pathname}`) - } - - /** - * Transform the GraphQL reference index page - */ - private async transformIndexPage(page: Page, context: Context): Promise { - const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : '' - - const manualContent = await this.extractManualContent(page, context) - - // Get children links from page metadata - const children = page.children || [] - const childrenLinks = children - .map((child) => { - const childPath = child.startsWith('/') ? child : `/${child}` - const childName = childPath.split('/').pop() || '' - const displayName = childName - .split('-') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' ') - return `- [${displayName}](${childPath})` - }) - .join('\n') - - const templateData = { - pageTitle: page.title, - pageIntro: intro, - manualContent, - childrenLinks, - } - - const templatePath = join(__dirname, '../templates/graphql-index.template.md') - const templateContent = readFileSync(templatePath, 'utf8') - - return await renderContent(templateContent, { - ...context, - ...templateData, - markdownRequested: true, - }) - } - - /** - * Transform GraphQL reference pages (queries, mutations, objects, etc.) - */ - private async transformReference( - page: Page, - currentVersion: string, - context: Context, - pageType: string, - ): Promise { // Import GraphQL data functions dynamically const { getGraphqlSchema } = await import('@/graphql/lib/index') @@ -120,7 +51,7 @@ export class GraphQLTransformer implements PageTransformer { // Prepare intro and manual content const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : '' - const manualContent = await this.extractManualContent(page, context) + const manualContent = await extractManualContent(page, context) // Prepare the schema items based on page type let preparedItems: Array> = [] @@ -166,7 +97,7 @@ export class GraphQLTransformer implements PageTransformer { break } - const templateData = { + const templateData: Record = { pageTitle: page.title, pageIntro: intro, manualContent, @@ -174,62 +105,7 @@ export class GraphQLTransformer implements PageTransformer { pageType: schemaKey, } - const templatePath = join(__dirname, '../templates/graphql-reference.template.md') - const templateContent = readFileSync(templatePath, 'utf8') - - return await renderContent(templateContent, { - ...context, - ...templateData, - markdownRequested: true, - }) - } - - /** - * Transform changelog page - */ - private async transformChangelog( - page: Page, - currentVersion: string, - context: Context, - ): Promise { - const { getGraphqlChangelog } = await import('@/graphql/lib/index') - - const schema = getGraphqlChangelog(currentVersion) as ChangelogItemT[] - - const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : '' - const manualContent = await this.extractManualContent(page, context) - - // Process changelog items - const changelogItems = schema.map((item) => { - const processChanges = (changes: Array<{ title: string; changes: string[] }>) => - changes.map((change) => ({ - title: change.title, - changes: change.changes.map((html: string) => { - // Remove wrapping

tags if present - if (html.startsWith('

') && html.endsWith('

')) { - return fastTextOnly(html.slice(3, -4)) - } - return fastTextOnly(html) - }), - })) - - return { - date: item.date, - schemaChanges: processChanges(item.schemaChanges || []), - previewChanges: processChanges(item.previewChanges || []), - upcomingChanges: processChanges(item.upcomingChanges || []), - } - }) - - const templateData = { - pageTitle: page.title, - pageIntro: intro, - manualContent, - changelogItems, - } - - const templatePath = join(__dirname, '../templates/graphql-changelog.template.md') - const templateContent = readFileSync(templatePath, 'utf8') + const templateContent = loadTemplate(this.templateName) return await renderContent(templateContent, { ...context, @@ -238,87 +114,6 @@ export class GraphQLTransformer implements PageTransformer { }) } - /** - * Transform breaking changes page - */ - private async transformBreakingChanges( - page: Page, - currentVersion: string, - context: Context, - ): Promise { - const { getGraphqlBreakingChanges } = await import('@/graphql/lib/index') - - const schema = getGraphqlBreakingChanges(currentVersion) as BreakingChangesT - - const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : '' - const manualContent = await this.extractManualContent(page, context) - - const slugger = new GithubSlugger() - - // Process breaking changes by date - const breakingChangesByDate = Object.keys(schema).map((date) => { - const items = schema[date] - const heading = `Changes scheduled for ${date}` - const slug = slugger.slug(heading) - - return { - date, - heading, - slug, - items: items.map((item) => ({ - location: item.location, - description: fastTextOnly(item.description), - reason: fastTextOnly(item.reason), - criticality: item.criticality, - })), - } - }) - - const templateData = { - pageTitle: page.title, - pageIntro: intro, - manualContent, - breakingChangesByDate, - } - - const templatePath = join(__dirname, '../templates/graphql-breaking-changes.template.md') - const templateContent = readFileSync(templatePath, 'utf8') - - return await renderContent(templateContent, { - ...context, - ...templateData, - markdownRequested: true, - }) - } - - /** - * Extract manual content from page markdown - */ - private async extractManualContent(page: Page, context: Context): Promise { - if (!page.markdown) return '' - - const markerIndex = page.markdown.indexOf( - '', - ) - - if (markerIndex <= 0) return '' - - const { content } = matter(page.markdown) - const manualContentMarkerIndex = content.indexOf( - '', - ) - - if (manualContentMarkerIndex <= 0) return '' - - const rawManualContent = content.substring(0, manualContentMarkerIndex).trim() - if (!rawManualContent) return '' - - return await renderContent(rawManualContent, { - ...context, - markdownRequested: true, - }) - } - /** * Prepare a query item for rendering */ diff --git a/src/article-api/transformers/index.ts b/src/article-api/transformers/index.ts index 08722471b6e8..7c56d4b55e9a 100644 --- a/src/article-api/transformers/index.ts +++ b/src/article-api/transformers/index.ts @@ -3,10 +3,15 @@ import { RestTransformer } from './rest-transformer' import { SecretScanningTransformer } from './secret-scanning-transformer' import { CodeQLCliTransformer } from './codeql-cli-transformer' import { AuditLogsTransformer } from './audit-logs-transformer' -import { GraphQLTransformer } from './graphql-transformer' +import { GraphQLIndexTransformer } from './graphql-index-transformer' +import { GraphQLReferenceTransformer } from './graphql-reference-transformer' +import { GraphQLChangelogTransformer } from './graphql-changelog-transformer' +import { GraphQLBreakingChangesTransformer } from './graphql-breaking-changes-transformer' import { GithubAppsTransformer } from './github-apps-transformer' import { WebhooksTransformer } from './webhooks-transformer' import { TocTransformer } from './toc-transformer' +import { BespokeLandingTransformer } from './bespoke-landing-transformer' +import { JourneyLandingTransformer } from './journey-landing-transformer' import { CategoryLandingTransformer } from './category-landing-transformer' import { DiscoveryLandingTransformer } from './discovery-landing-transformer' import { ProductGuidesTransformer } from './product-guides-transformer' @@ -22,10 +27,15 @@ transformerRegistry.register(new RestTransformer()) transformerRegistry.register(new SecretScanningTransformer()) transformerRegistry.register(new CodeQLCliTransformer()) transformerRegistry.register(new AuditLogsTransformer()) -transformerRegistry.register(new GraphQLTransformer()) +transformerRegistry.register(new GraphQLIndexTransformer()) +transformerRegistry.register(new GraphQLReferenceTransformer()) +transformerRegistry.register(new GraphQLChangelogTransformer()) +transformerRegistry.register(new GraphQLBreakingChangesTransformer()) transformerRegistry.register(new GithubAppsTransformer()) transformerRegistry.register(new WebhooksTransformer()) transformerRegistry.register(new TocTransformer()) +transformerRegistry.register(new BespokeLandingTransformer()) +transformerRegistry.register(new JourneyLandingTransformer()) transformerRegistry.register(new CategoryLandingTransformer()) transformerRegistry.register(new DiscoveryLandingTransformer()) transformerRegistry.register(new ProductGuidesTransformer()) diff --git a/src/article-api/transformers/journey-landing-transformer.ts b/src/article-api/transformers/journey-landing-transformer.ts new file mode 100644 index 000000000000..959b28deaf6a --- /dev/null +++ b/src/article-api/transformers/journey-landing-transformer.ts @@ -0,0 +1,115 @@ +import type { Context, Page } from '@/types' +import type { PageTransformer, TemplateData, Section, LinkData, LinkGroup } from './types' +import { renderContent } from '@/content-render/index' +import { loadTemplate } from '@/article-api/lib/load-template' +import { resolvePath } from '@/article-api/lib/resolve-path' +import { getLinkData } from '@/article-api/lib/get-link-data' + +interface JourneyGuide { + href: string + alternativeNextStep?: string +} + +interface JourneyTrack { + id: string + title: string + description: string + guides: JourneyGuide[] +} + +interface JourneyPage extends Page { + journeyTracks?: JourneyTrack[] + children?: string[] +} + +/** + * Transforms journey-landing pages into markdown format. + * Handles journey tracks (grouped learning paths) with guides, + * falling back to children listings when tracks aren't available. + */ +export class JourneyLandingTransformer implements PageTransformer { + templateName = 'landing-page.template.md' + + canTransform(page: Page): boolean { + return page.layout === 'journey-landing' + } + + async transform(page: Page, pathname: string, context: Context): Promise { + const templateData = await this.prepareTemplateData(page, pathname, context) + const templateContent = loadTemplate(this.templateName) + + return await renderContent(templateContent, { + ...context, + ...templateData, + markdownRequested: true, + }) + } + + private async prepareTemplateData( + page: Page, + pathname: string, + context: Context, + ): Promise { + const journeyPage = page as JourneyPage + const languageCode = page.languageCode || 'en' + const sections: Section[] = [] + + // Journey tracks + const journeyTracks = journeyPage.journeyTracks + if (journeyTracks) { + const groups: LinkGroup[] = [] + for (const track of journeyTracks) { + const links = await Promise.all( + (track.guides || []).map(async (guide) => { + const guideHref = guide.href + if (!guideHref) return null + const linkData = await getLinkData( + guideHref, + languageCode, + pathname, + context, + resolvePath, + ) + return linkData + }), + ) + const validLinks = links.filter((l): l is LinkData => l !== null && !!l.href) + if (validLinks.length > 0) { + groups.push({ title: track.title, links: validLinks }) + } + } + + if (groups.length > 0) { + sections.push({ + title: 'Links', + groups, + }) + } + } + + // Children fallback + if (sections.length === 0 && journeyPage.children) { + const links = await Promise.all( + journeyPage.children.map(async (childHref) => { + return await getLinkData(childHref, languageCode, pathname, context, resolvePath) + }), + ) + const validLinks = links.filter((l): l is LinkData => !!l.href) + if (validLinks.length > 0) { + sections.push({ + title: 'Links', + groups: [{ title: null, links: validLinks }], + }) + } + } + + const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : '' + const title = await page.renderTitle(context, { unwrap: true }) + + return { + title, + intro, + sections, + } + } +} diff --git a/src/article-api/transformers/rest-transformer.ts b/src/article-api/transformers/rest-transformer.ts index 5ba981f1a55d..2ef6f845d8c9 100644 --- a/src/article-api/transformers/rest-transformer.ts +++ b/src/article-api/transformers/rest-transformer.ts @@ -2,14 +2,10 @@ 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 { loadTemplate } from '@/article-api/lib/load-template' 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) const DEBUG = process.env.RUNNER_DEBUG === '1' || process.env.DEBUG === '1' /** @@ -17,6 +13,8 @@ const DEBUG = process.env.RUNNER_DEBUG === '1' || process.env.DEBUG === '1' * Converts REST operations and their data into markdown format using a Liquid template */ export class RestTransformer implements PageTransformer { + templateName = 'rest-page.template.md' + 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 @@ -104,8 +102,7 @@ export class RestTransformer implements PageTransformer { ) // Load and render template - const templatePath = join(__dirname, '../templates/rest-page.template.md') - const templateContent = readFileSync(templatePath, 'utf8') + const templateContent = loadTemplate(this.templateName) // Render the template with Liquid const rendered = await renderContent(templateContent, { diff --git a/src/article-api/transformers/secret-scanning-transformer.ts b/src/article-api/transformers/secret-scanning-transformer.ts index b98f4cdcf717..8bed61b8e2a5 100644 --- a/src/article-api/transformers/secret-scanning-transformer.ts +++ b/src/article-api/transformers/secret-scanning-transformer.ts @@ -4,15 +4,18 @@ import fs from 'fs' import yaml from 'js-yaml' import path from 'path' import { getVersionInfo } from '@/app/lib/constants' -import { liquid } from '@/content-render/index' +import { liquid, renderContent } from '@/content-render/index' import { allVersions } from '@/versions/lib/all-versions' +import { loadTemplate } from '@/article-api/lib/load-template' /** * Transformer for Secret Scanning pages. - * Loads pattern data and converts secret scanning documentation into markdown format. + * Loads pattern data and converts secret scanning documentation into markdown format using a Liquid template. * Used by the Article API to render Secret Scanning documentation dynamically. */ export class SecretScanningTransformer implements PageTransformer { + templateName = 'secret-scanning-page.template.md' + canTransform(page: Page): boolean { return page.autogenerated === 'secret-scanning' } @@ -71,6 +74,22 @@ export class SecretScanningTransformer implements PageTransformer { const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : '' - return `# ${page.title}\n\n${intro}\n\n${content}` + // Prepare template data + const templateData: Record = { + page: { + title: page.title, + intro, + }, + content, + } + + // Load and render template + const templateContent = loadTemplate(this.templateName) + + return await renderContent(templateContent, { + ...context, + ...templateData, + markdownRequested: true, + }) } } diff --git a/src/article-api/transformers/webhooks-transformer.ts b/src/article-api/transformers/webhooks-transformer.ts index 21ff264244fa..d30e84a08fb0 100644 --- a/src/article-api/transformers/webhooks-transformer.ts +++ b/src/article-api/transformers/webhooks-transformer.ts @@ -2,13 +2,16 @@ import type { Context, Page } from '@/types' import type { PageTransformer } from './types' import { renderContent } from '@/content-render/index' import { fastTextOnly } from '@/content-render/unified/text-only' +import { loadTemplate } from '@/article-api/lib/load-template' import matter from '@gr2m/gray-matter' /** * Transformer for Webhooks pages. - * Converts webhook events and payloads into markdown format. + * Converts webhook events and payloads into markdown format using a Liquid template. */ export class WebhooksTransformer implements PageTransformer { + templateName = 'webhooks-page.template.md' + canTransform(page: Page): boolean { return page.autogenerated === 'webhooks' } @@ -26,12 +29,6 @@ export class WebhooksTransformer implements PageTransformer { // Prepare page intro const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : '' - // Build the page header manually to avoid Context type conflicts - let headerMarkdown = `# ${page.title}\n\n` - if (intro) { - headerMarkdown += `${intro}\n\n` - } - // Prepare manual content let manualContent = '' if (page.markdown) { @@ -51,75 +48,43 @@ export class WebhooksTransformer implements PageTransformer { } } - // Build webhooks sections manually - let webhooksMarkdown = '' - for (const webhook of webhooksData) { - webhooksMarkdown += `## ${webhook.name}\n\n` - - // Summary if available - if (webhook.data.summaryHtml) { - const summaryText = fastTextOnly(webhook.data.summaryHtml) - webhooksMarkdown += `${summaryText}\n\n` - } - - // Availability - if (webhook.data.availability && webhook.data.availability.length > 0) { - webhooksMarkdown += '### Availability\n\n' - for (const avail of webhook.data.availability) { - webhooksMarkdown += `- \`${avail}\`\n` - } - webhooksMarkdown += '\n' - } - - // Webhook payload object section - webhooksMarkdown += '### Webhook payload object\n\n' - - // Available actions - if (webhook.actionTypes.length > 1) { - webhooksMarkdown += '**Action type:** ' - const actions = webhook.actionTypes.map((a) => `\`${a}\``).join(', ') - webhooksMarkdown += `${actions}\n\n` - } - - // Description if available - if (webhook.data.descriptionHtml) { - const descriptionText = fastTextOnly(webhook.data.descriptionHtml) - webhooksMarkdown += `${descriptionText}\n\n` - } - - // Body parameters (payload structure) - if (webhook.data.bodyParameters && webhook.data.bodyParameters.length > 0) { - webhooksMarkdown += '#### Webhook payload object parameters\n\n' - webhooksMarkdown += '| Name | Type | Description |\n' - webhooksMarkdown += '|------|------|-------------|\n' - - for (const param of webhook.data.bodyParameters) { - const name = param.name ? `\`${param.name}\`` : '' - const type = param.type ? `\`${param.type}\`` : '' - // Convert HTML description to plain text - let desc = param.description || '' - if (desc) { - desc = fastTextOnly(desc).replace(/\n/g, ' ').trim() - } - // Add required indicator - if (param.isRequired) { - desc = `**Required.** ${desc}` - } - - webhooksMarkdown += `| ${name} | ${type} | ${desc} |\n` - } - webhooksMarkdown += '\n' - } - - // Payload example if available - if (webhook.data.payloadExample) { - webhooksMarkdown += '### Webhook payload example\n\n' - webhooksMarkdown += '```json\n' - webhooksMarkdown += JSON.stringify(webhook.data.payloadExample, null, 2) - webhooksMarkdown += '\n```\n\n' - } + // Prepare webhooks data for template + const preparedWebhooks = webhooksData.map((webhook) => ({ + name: webhook.name, + actionTypes: webhook.actionTypes, + summary: webhook.data.summaryHtml ? fastTextOnly(webhook.data.summaryHtml) : '', + description: webhook.data.descriptionHtml ? fastTextOnly(webhook.data.descriptionHtml) : '', + availability: webhook.data.availability || [], + bodyParameters: (webhook.data.bodyParameters || []).map((param) => ({ + name: param.name || '', + type: param.type || '', + description: param.description + ? fastTextOnly(param.description).replace(/\n/g, ' ').trim() + : '', + isRequired: param.isRequired || false, + })), + payloadExample: webhook.data.payloadExample + ? JSON.stringify(webhook.data.payloadExample, null, 2) + : null, + })) + + // Prepare template data + const templateData: Record = { + page: { + title: page.title, + intro, + }, + manualContent, + webhooks: preparedWebhooks, } - return `${headerMarkdown + manualContent}\n\n${webhooksMarkdown}` + // Load and render template + const templateContent = loadTemplate(this.templateName) + + return await renderContent(templateContent, { + ...context, + ...templateData, + markdownRequested: true, + }) } } diff --git a/src/workflows/fr-add-docs-reviewers-requests.ts b/src/workflows/fr-add-docs-reviewers-requests.ts index 92bd8a871ebc..7b426be1e2d8 100644 --- a/src/workflows/fr-add-docs-reviewers-requests.ts +++ b/src/workflows/fr-add-docs-reviewers-requests.ts @@ -1,4 +1,3 @@ -// eslint-disable-next-line import/no-extraneous-dependencies import { graphql } from '@octokit/graphql' import { diff --git a/src/workflows/projects.ts b/src/workflows/projects.ts index 07048d2cb2ea..398b9e1924b8 100644 --- a/src/workflows/projects.ts +++ b/src/workflows/projects.ts @@ -1,4 +1,3 @@ -// eslint-disable-next-line import/no-extraneous-dependencies import { graphql } from '@octokit/graphql' // Shared functions for managing projects (memex)