diff --git a/astro.config.mjs b/astro.config.mjs index bd24feeb7f..a65aa3366c 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -7,6 +7,7 @@ import { attributeMarkdown, wrapTables } from '/src/themes/octopus/utilities/cus import llmMdEmitter from './src/integrations/llm-md-emitter.ts'; import pruneDist from './src/integrations/prune-dist.ts'; import rehypeWbr from './src/plugins/rehype-wbr.js'; +import shikiCodeBlock from './src/plugins/shiki-code-block.js'; // https://astro.build/config export default defineConfig({ @@ -25,11 +26,20 @@ export default defineConfig({ ], markdown: { shikiConfig: { - theme: 'light-plus', + // Every token carries both sets. main.css picks the dark one up + // under html[data-theme='dark'] + themes: { + light: 'light-plus', + dark: 'dark-plus' + }, + defaultColor: 'light', // OCL is HCL-derived, so reuse the HCL grammar for ```ocl fences langAlias: { ocl: 'hcl' - } + }, + // A transformer, because rehype plugins registered through + // `processor` below never reach .mdx pages + transformers: [shikiCodeBlock()] }, processor: unified({ remarkPlugins: [ diff --git a/src/assets/icons/copy.svg b/src/assets/icons/copy.svg new file mode 100644 index 0000000000..b5656c88d9 --- /dev/null +++ b/src/assets/icons/copy.svg @@ -0,0 +1,4 @@ + + diff --git a/src/pages/components.mdx b/src/pages/components.mdx index 91b2693b1f..0c52f307eb 100644 --- a/src/pages/components.mdx +++ b/src/pages/components.mdx @@ -358,6 +358,118 @@ The Link component is designed to provide a standardized way to display links wi +### Code block + +Every fenced code block is given a header carrying its language and a copy +button. There is no component to import, so this works in `.md` as well as +`.mdx`. + +#### Label + +Text after the language on the opening fence becomes the block's label. Write +one that says what the code does; the language is already shown on the right. + +````text +```powershell Write a release marker into the repository +Write-Host "Hello, World!" +``` +```` + +```powershell Write a release marker into the repository +Write-Host "Hello, World!" +``` + +Without a label, the header carries the language and the copy button alone. + +```powershell +Write-Host "Hello, World!" +``` + +#### Several languages + +Wrap one fence per language in `
` elements sharing a `data-group`. Each +`` names its language, and the header offers them in a menu. + +````text +
+PowerShell + +```powershell Rename a deployment target +$machine = $repository.Machines.Get("machines-1"); +``` + +
+
+C# + +```csharp Rename a deployment target +var machine = repository.Machines.Get("machines-1"); +``` + +
+```` + +
+PowerShell + +```powershell Rename a deployment target +$machine = $repository.Machines.Get("machines-1"); +$machine.Name = "Test Server 1"; +$repository.Machines.Modify($machine); +``` + +
+
+C# + +```csharp Rename a deployment target +var machine = repository.Machines.Get("machines-1"); +machine.Name = "Test Server 1"; +repository.Machines.Modify(machine); +``` + +
+ +A group whose panels hold anything besides a single code block stays a tab list. + +#### Long blocks + +A block over 500px tall collapses, fading out at the cut. Clicking the code +expands it, and clicking away collapses it again. + +```yaml A deployment process with every step spelled out +steps: + - name: Approve the release + action: manual-intervention + instructions: Check the release notes before approving. + - name: Deploy to the cluster + action: kubernetes-deploy-raw-yaml + package: octopus/hello-world + namespace: production + - name: Smoke test + action: run-a-script + script: | + $response = Invoke-WebRequest -Uri "https://example.com/health" + if ($response.StatusCode -ne 200) { throw "Unhealthy" } + - name: Notify the team + action: send-email + to: releases@example.com + subject: Deployed #{Octopus.Release.Number} + - name: Tag the release + action: run-a-script + script: | + git tag "release/#{Octopus.Release.Number}" + git push origin --tags + - name: Update the changelog + action: run-a-script + script: | + Add-Content CHANGELOG.md "#{Octopus.Release.Number}" + - name: Close the change request + action: run-a-script + script: | + Invoke-RestMethod -Method Post -Uri "https://example.com/changes/close" +``` + ## Layout ### Grid diff --git a/src/pages/docs/deployments/custom-scripts/index.md b/src/pages/docs/deployments/custom-scripts/index.md index 974b558761..656818cc2f 100644 --- a/src/pages/docs/deployments/custom-scripts/index.md +++ b/src/pages/docs/deployments/custom-scripts/index.md @@ -123,7 +123,7 @@ Sometimes a script launches a service or application that runs continuously. In
PowerShell -```powershell PowerShell +```powershell Start-Process MyService ``` @@ -131,7 +131,7 @@ Start-Process MyService
Bash -```bash Bash +```bash screen -d -m -S "MyService" MyService ``` diff --git a/src/pages/docs/deployments/custom-scripts/logging-messages-in-scripts.md b/src/pages/docs/deployments/custom-scripts/logging-messages-in-scripts.md index 066a3e3ceb..de68c8f344 100644 --- a/src/pages/docs/deployments/custom-scripts/logging-messages-in-scripts.md +++ b/src/pages/docs/deployments/custom-scripts/logging-messages-in-scripts.md @@ -111,7 +111,7 @@ Progress messages will display and update a progress bar on your deployment task
PowerShell -```ps PowerShell +```ps Update-Progress 10 Update-Progress 50 "We're halfway there!" ``` @@ -205,7 +205,7 @@ def updateprogress(progress, message=None): ```bash function encode_service_message_value { - echo -n "$1" | openssl enc -base64 -A + echo -n "$1" | openssl enc -base64 -A } echo "##octopus[progress percentage='$(encode_service_message_value "$1")' message='$(encode_service_message_value "$2")']" @@ -216,7 +216,8 @@ echo "##octopus[progress percentage='$(encode_service_message_value "$1")' messa ## Service message The following service messages can be written directly to standard output which will be parsed by the server and the subsequent log lines written to standard output will be treated with the relevant log level. -``` + +```text Set the standard output log level ##octopus[stdout-ignore] ##octopus[stdout-error] ##octopus[stdout-warning] @@ -226,23 +227,25 @@ The following service messages can be written directly to standard output which ``` To return to the default standard output log level, write the following message: -``` + +```text Return to the default standard output log level ##octopus[stdout-default] ``` +The following service messages can be written directly to standard output which will be parsed by the server and the subsequent log lines written to standard error will be treated with the relevant log level. -The following service messages can be written directly to standard output which will be parsed by the server and the subsequent log lines written to standard error will be treated with the relevant log level. -``` +```text Set the standard error log level ##octopus[stderr-ignore] ##octopus[stderr-error] ##octopus[stderr-progress] ##octopus[stderr-output] ``` -- `stderr-progress` will cause error log lines to be written as `verbose` log lines. -- `stderr-output` will cause error log lines to be written as `info` log lines (standard output). Requires version `2025.3`. +- `stderr-progress` will cause error log lines to be written as `verbose` log lines. +- `stderr-output` will cause error log lines to be written as `info` log lines (standard output). Requires version `2025.3`. To return to the default standard error log level, write the following message: -``` + +```text Return to the default standard error log level ##octopus[stderr-default] ``` diff --git a/src/pages/docs/deployments/custom-scripts/output-variables.md b/src/pages/docs/deployments/custom-scripts/output-variables.md index 1a309bc4b0..4d00bbcf84 100644 --- a/src/pages/docs/deployments/custom-scripts/output-variables.md +++ b/src/pages/docs/deployments/custom-scripts/output-variables.md @@ -97,7 +97,7 @@ let appInstanceName3 = Octopus.tryFindVariable "Octopus.Action[Determine App Ins
Python3 -```python Python3 +```python appInstanceName = get_octopusvariable("Octopus.Action[Determine App Instance Name].Output.AppInstanceName") ``` @@ -106,6 +106,7 @@ appInstanceName = get_octopusvariable("Octopus.Action[Determine App Instance Nam ## Service message The following service message can be written directly (substituting the properties with the relevant values) to standard output which will be parsed by the server and the values processed as an output variable. Note that the properties must be supplied as a base64 encoded UTF-8 string. -``` + +```text Write an output variable from standard output ##octopus[setVariable name='' value=''] ``` diff --git a/src/pages/docs/deployments/custom-scripts/scripts-in-packages/reference-files-within-a-package.md b/src/pages/docs/deployments/custom-scripts/scripts-in-packages/reference-files-within-a-package.md index e8aaace61d..e80b5385aa 100644 --- a/src/pages/docs/deployments/custom-scripts/scripts-in-packages/reference-files-within-a-package.md +++ b/src/pages/docs/deployments/custom-scripts/scripts-in-packages/reference-files-within-a-package.md @@ -52,7 +52,7 @@ Get-Content ".\subfolder\file.txt"
C# -```csharp C# +```csharp // in pre-deploy, in post-deploy if custom installation directory has not been defined var extractPath = OctopusParameters["Octopus.Action.Package.InstallationDirectoryPath"]; // if a custom installation directory has been defined diff --git a/src/pages/docs/deployments/git/commit-to-git.md b/src/pages/docs/deployments/git/commit-to-git.md index 9f1a44a0ff..85eec6e01c 100644 --- a/src/pages/docs/deployments/git/commit-to-git.md +++ b/src/pages/docs/deployments/git/commit-to-git.md @@ -81,7 +81,7 @@ For example, the following scripts write a release marker into the repository be
PowerShell -```powershell PowerShell +```powershell # Get the path to the cloned repository $repoPath = $OctopusParameters["Octopus.Calamari.Git.RepositoryPath"] @@ -93,7 +93,7 @@ $repoPath = $OctopusParameters["Octopus.Calamari.Git.RepositoryPath"]
C# -```csharp C# +```csharp // Get the path to the cloned repository var repoPath = OctopusParameters["Octopus.Calamari.Git.RepositoryPath"]; @@ -105,7 +105,7 @@ System.IO.File.WriteAllText(System.IO.Path.Combine(repoPath, "release-marker.txt
Bash -```bash Bash +```bash # Get the path to the cloned repository repo_path=$(get_octopusvariable "Octopus.Calamari.Git.RepositoryPath") @@ -117,7 +117,7 @@ echo "Released #{Octopus.Release.Number} to #{Octopus.Environment.Name}" > "$rep
Python -```python Python +```python # Get the path to the cloned repository repo_path = get_octopusvariable("Octopus.Calamari.Git.RepositoryPath") diff --git a/src/pages/docs/infrastructure/deployment-targets/dynamic-infrastructure/new-octopustarget.mdx b/src/pages/docs/infrastructure/deployment-targets/dynamic-infrastructure/new-octopustarget.mdx index 0692f954d1..1ff71ad3d8 100644 --- a/src/pages/docs/infrastructure/deployment-targets/dynamic-infrastructure/new-octopustarget.mdx +++ b/src/pages/docs/infrastructure/deployment-targets/dynamic-infrastructure/new-octopustarget.mdx @@ -61,7 +61,7 @@ Below is an example of creating an AWS ECS Cluster target with [account credenti
PowerShell -```powershell PowerShell +```powershell $inputs = @" { "clusterName": "$($OctopusParameters["clusterName"])", @@ -137,7 +137,7 @@ New-OctopusTarget -Name "$($OctopusParameters["target_name"])" -TargetId "aws-ec
Bash -```bash Bash +```bash read -r -d '' INPUTS < PowerShell -```powershell +```powershell Rename a deployment target and save it $machine = $repository.Machines.Get("machines-1"); $machine.Name = "Test Server 1"; $repository.Machines.Modify($machine); @@ -23,7 +23,7 @@ $repository.Machines.Modify($machine);
C# -```csharp +```csharp Rename a deployment target and save it // Sync var machine = repository.Machines.Get("machines-1"); machine.Name = "Test Server 1"; @@ -37,4 +37,4 @@ await repository.Machines.Modify(machine);
-The repository methods all make direct HTTP requests. There's no "session" abstraction or transaction support. \ No newline at end of file +The repository methods all make direct HTTP requests. There's no "session" abstraction or transaction support. diff --git a/src/pages/docs/octopus-rest-api/octopus.client/working-with-spaces.md b/src/pages/docs/octopus-rest-api/octopus.client/working-with-spaces.md index beb86a3b2d..48c3e8cbd1 100644 --- a/src/pages/docs/octopus-rest-api/octopus.client/working-with-spaces.md +++ b/src/pages/docs/octopus-rest-api/octopus.client/working-with-spaces.md @@ -32,7 +32,7 @@ $projects = $repositoryForSpace.Projects.GetAll()
C# -```csharp C# +```csharp // Create endpoint and client var endpoint = new OctopusServerEndpoint("https://your-octopus-url", "API-YOUR-KEY"); var client = new OctopusClient(endpoint); @@ -83,4 +83,4 @@ var repositoryForSpace = repository.ForSpace(space); var projects = repositoryForSpace.Projects.GetAll(); ``` -
\ No newline at end of file +
diff --git a/src/pages/docs/projects/variables/certificate-variables.md b/src/pages/docs/projects/variables/certificate-variables.md index 12d4bc3f39..e1052d0d43 100644 --- a/src/pages/docs/projects/variables/certificate-variables.md +++ b/src/pages/docs/projects/variables/certificate-variables.md @@ -11,38 +11,38 @@ navOrder: 60 In the variable-editor, selecting *Certificate* as the [variable](/docs/projects/variables) type allows you to create a variable with a certificate managed by Octopus as the value. :::figure -![](/docs/img/projects/variables/images/certificate-variable-select.png) +![Selecting Certificate as the variable type in the variable editor](/docs/img/projects/variables/images/certificate-variable-select.png) ::: Certificate variables can be [scoped](/docs/projects/variables/#scoping-variables), similar to regular text variables. :::figure -![](/docs/img/projects/variables/images/certificate-variables-scoped.png) +![A certificate variable scoped to an environment](/docs/img/projects/variables/images/certificate-variables-scoped.png) ::: ## Expanded properties -At deploy-time, certificate variables are expanded. For example, a variable _MyCertificate_ becomes: - -| Variable | Description | Example value | -| ---------------------- | ------------------ | ------------- | -| `MyCertificate` | The certificate ID | Certificates-1 | -| `MyCertificate.Type` | The variable type | Certificate -| `MyCertificate.Name` | The user-provided name | My Development Certificate -| `MyCertificate.Thumbprint` | Thumbprint | A163E39F59560E6FE33A0299D19124B242D9B37E -| `MyCertificate.RawOriginal` | The base64 encoded original file, exactly as it was uploaded. | -| `MyCertificate.Password` | The password specified when the file was uploaded. | -| `MyCertificate.Pfx` | The base64 encoded certificate in [PKCS#12](https://datatracker.ietf.org/doc/html/rfc7292#page-9) format, including the private-key if present. If the originally uploaded certificate was password-protected (i.e. `MyCertificate.Password` is not empty), then this value will also be a password-protected PFX (PKCS#12) format. -| `MyCertificate.Certificate` | The base64 encoded DER ASN.1 certificate. | -| `MyCertificate.PrivateKey` | The base64 encoded DER ASN.1 private key. This will be stored and transmitted as a [sensitive variable](/docs/projects/variables/sensitive-variables). | -| `MyCertificate.CertificatePem` | The PEM representation of the certificate (i.e. the PublicKey with header\footer). | -| `MyCertificate.PrivateKeyPem` | The PEM representation of the private key (i.e. the PrivateKey with header\footer). | -| `MyCertificate.ChainPem` | The PEM representation of any chain certificates (intermediate or certificate-authority). This variable does not include the primary certificate. | -| `MyCertificate.Subject` | The X.500 distinguished name of the subject | -| `MyCertificate.SubjectCommonName` | The un-attributed subject common name | -| `MyCertificate.Issuer` | The X.500 distinguished name of the issuer | -| `MyCertificate.NotBefore` | NotBefore date | 2016-06-15T13:45:30.0000000-07:00 -| `MyCertificate.NotAfter` | NotAfter date | 2019-06-15T13:45:30.0000000-07:00 +At deploy-time, certificate variables are expanded. For example, a variable *MyCertificate* becomes: + +| Variable | Description | Example value | +| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | +| `MyCertificate` | The certificate ID | Certificates-1 | +| `MyCertificate.Type` | The variable type | Certificate | +| `MyCertificate.Name` | The user-provided name | My Development Certificate | +| `MyCertificate.Thumbprint` | Thumbprint | A163E39F59560E6FE33A0299D19124B242D9B37E | +| `MyCertificate.RawOriginal` | The base64 encoded original file, exactly as it was uploaded. | | +| `MyCertificate.Password` | The password specified when the file was uploaded. | | +| `MyCertificate.Pfx` | The base64 encoded certificate in [PKCS#12](https://datatracker.ietf.org/doc/html/rfc7292#page-9) format, including the private-key if present. If the originally uploaded certificate was password-protected (i.e. `MyCertificate.Password` is not empty), then this value will also be a password-protected PFX (PKCS#12) format. | | +| `MyCertificate.Certificate` | The base64 encoded DER ASN.1 certificate. | | +| `MyCertificate.PrivateKey` | The base64 encoded DER ASN.1 private key. This will be stored and transmitted as a [sensitive variable](/docs/projects/variables/sensitive-variables). | | +| `MyCertificate.CertificatePem` | The PEM representation of the certificate (i.e. the PublicKey with header/footer). | | +| `MyCertificate.PrivateKeyPem` | The PEM representation of the private key (i.e. the PrivateKey with header/footer). | | +| `MyCertificate.ChainPem` | The PEM representation of any chain certificates (intermediate or certificate-authority). This variable does not include the primary certificate. | | +| `MyCertificate.Subject` | The X.500 distinguished name of the subject | | +| `MyCertificate.SubjectCommonName` | The un-attributed subject common name | | +| `MyCertificate.Issuer` | The X.500 distinguished name of the issuer | | +| `MyCertificate.NotBefore` | NotBefore date | 2016-06-15T13:45:30.0000000-07:00 | +| `MyCertificate.NotAfter` | NotAfter date | 2019-06-15T13:45:30.0000000-07:00 | ### Example usage @@ -51,7 +51,7 @@ Given the certificate variable `MyCertificate`, you can access the certificate t
PowerShell -```powershell PowerShell +```powershell Write-Host $OctopusParameters["MyCertificate.Thumbprint"] ``` diff --git a/src/pages/docs/projects/variables/output-variables.mdx b/src/pages/docs/projects/variables/output-variables.mdx index 7844ce200f..d88e897c6d 100644 --- a/src/pages/docs/projects/variables/output-variables.mdx +++ b/src/pages/docs/projects/variables/output-variables.mdx @@ -114,7 +114,7 @@ testResult = get_octopusvariable("Octopus.Action[StepA].Output.TestResult")
PowerShell -```powershell PowerShell +```powershell Set-OctopusVariable -name "Password" -value "correct horse battery staple" -sensitive ``` @@ -173,14 +173,14 @@ Imagine that an output variable was set by a script which ran on two deployment In this scenario, the following output variables would be captured: -| Name | Value | Scope | +| Name | Value | Scope | | ---------------------------------------- | -------- | -------------- | -| `Octopus.Action[StepA].Output[Web01].TestResult` | `Passed` | | -| `Octopus.Action[StepA].Output[Web02].TestResult` | `Failed` | | +| `Octopus.Action[StepA].Output[Web01].TestResult` | `Passed` | | +| `Octopus.Action[StepA].Output[Web02].TestResult` | `Failed` | | | `Octopus.Action[StepA].Output.TestResult` | `Passed` | Deployment Target: Web01 | | `Octopus.Action[StepA].Output.TestResult` | `Failed` | Deployment Target: Web02 | -| `Octopus.Action[StepA].Output.TestResult` | `Passed` | | -| `Octopus.Action[StepA].Output.TestResult` | `Failed` | | +| `Octopus.Action[StepA].Output.TestResult` | `Passed` | | +| `Octopus.Action[StepA].Output.TestResult` | `Failed` | | Note that for each output variable/deployment target combination: @@ -195,9 +195,9 @@ For some practical examples of using output variables, and how scoping rules are ## Output from a Deploy a Release step \{#deploy-release-output} -Output variables from deployments triggered by a _Deploy a Release_ step are captured and exposed as output variables on the _Deploy a Release_ step. +Output variables from deployments triggered by a *Deploy a Release* step are captured and exposed as output variables on the *Deploy a Release* step. -To get the value of an output variable from a _Deploy a Release_ step, use the `Output.Deployment` variable on the _Deploy a Release_ step. For example, if your _Deploy a Release_ step is named "Deploy Web Project", the target step in the child project is named "Update IP Address", and the variable name is "IPAddress", you would use the following variable to access it in the parent project: `Octopus.Action[Deploy Web Project].Output.Deployment[Update IP Address].IPAddress`. +To get the value of an output variable from a *Deploy a Release* step, use the `Output.Deployment` variable on the *Deploy a Release* step. For example, if your *Deploy a Release* step is named "Deploy Web Project", the target step in the child project is named "Update IP Address", and the variable name is "IPAddress", you would use the following variable to access it in the parent project: `Octopus.Action[Deploy Web Project].Output.Deployment[Update IP Address].IPAddress`. ## Setting output variables using scripts \{#output-variables-in-scripts} @@ -214,9 +214,7 @@ From a PowerShell script, you can use the PowerShell CmdLet `Set-OctopusVariable For example: -**PowerShell** - -```powershell +```powershell Set an output variable Set-OctopusVariable -name "TestResult" -value "Passed" ``` @@ -228,9 +226,7 @@ Set-OctopusVariable -name "TestResult" -value "Passed" From a C# script, you can use the `public static void SetVariable(string name, string value)` method to set the name and value of an output variable. -**C#** - -```csharp +```csharp Set an output variable SetVariable("TestResult", "Passed"); ``` @@ -240,9 +236,7 @@ SetVariable("TestResult", "Passed"); In a Bash script you can use the `set_octopusvariable` function to set the name and value of an output variable. This function takes two positional parameters with the same purpose as the PowerShell CmdLet. -**Bash** - -```bash +```bash Set an output variable set_octopusvariable "TestResult" "Passed" ``` @@ -252,29 +246,25 @@ set_octopusvariable "TestResult" "Passed" From a F# script, you can use the `setVariable : name:string -> value:string -> unit` function to collect artifacts. The function takes two parameters with the same purpose as the PowerShell CmdLet. -**F#** - -```fsharp +```fsharp Set an output variable Octopus.setVariable "TestResult" "Passed" ``` -**Python3** - -```python Python3 +```python Set an output variable set_octopusvariable("TestResult", "Passed") ``` ## Best practice -If you have multiple steps which depend on an output variable created by a previous step in your deployment process, it can be cumbersome to need to use the full variable name everywhere, e.g. `Octopus.Action[StepA].Output.TestResult`. +If you have multiple steps which depend on an output variable created by a previous step in your deployment process, it can be cumbersome to need to use the full variable name everywhere, e.g. `Octopus.Action[StepA].Output.TestResult`. A useful pattern is to create a project variable which evaluates to the output variable, e.g. -| Variable name | Value | +| Variable name | Value | | ---------------------------------------- | -------- | | `TestResult` | `#{Octopus.Action[StepA].Output.TestResult}` | -This allows using `TestResult` as the variable name in dependent steps, rather than the full output variable name. In the case of the step name changing (e.g. `StepA` -> `StepX`), this also reduces the amount of places the step name in the output variable expression needs to be changed. +This allows using `TestResult` as the variable name in dependent steps, rather than the full output variable name. In the case of the step name changing (e.g. `StepA` -> `StepX`), this also reduces the amount of places the step name in the output variable expression needs to be changed. ## Learn more diff --git a/src/plugins/shiki-code-block.js b/src/plugins/shiki-code-block.js new file mode 100644 index 0000000000..c8747b08bc --- /dev/null +++ b/src/plugins/shiki-code-block.js @@ -0,0 +1,135 @@ +// Wraps every highlighted block in the code block shell at build time, so the +// frame, header, label and language are on the page before any script runs. +// code-blocks.js wires up what happens next: copying, collapsing, and folding a +//
set into one block with a language menu. + +const REST = 'Copy to clipboard'; +const SHOW_MORE = 'Show more'; + +/** Display names for the fence languages used across the docs. */ +const LANGUAGE_NAMES = { + bash: 'Bash', + batch: 'Batch', + 'c#': 'C#', + cs: 'C#', + csharp: 'C#', + docker: 'Docker', + dockerfile: 'Dockerfile', + fsharp: 'F#', + go: 'Go', + hcl: 'HCL', + html: 'HTML', + ini: 'INI', + java: 'Java', + javascript: 'JavaScript', + js: 'JavaScript', + json: 'JSON', + log: 'Log', + markdown: 'Markdown', + nginx: 'nginx', + ocl: 'OCL', + plaintext: 'Text', + powershell: 'PowerShell', + ps: 'PowerShell', + python: 'Python', + ruby: 'Ruby', + sh: 'Shell', + shell: 'Shell', + sql: 'SQL', + text: 'Text', + txt: 'Text', + typescript: 'TypeScript', + xml: 'XML', + yaml: 'YAML', + yml: 'YAML', +}; + +function displayName(language) { + const key = String(language ?? '') + .trim() + .toLowerCase(); + if (!key) return ''; + return LANGUAGE_NAMES[key] ?? key.charAt(0).toUpperCase() + key.slice(1); +} + +function h(tagName, properties, children = []) { + return { type: 'element', tagName, properties, children }; +} + +function text(value) { + return { type: 'text', value }; +} + +export default function shikiCodeBlock() { + return { + name: 'octopus:code-block', + + root(node) { + const pre = node.children.find( + (child) => child.type === 'element' && child.tagName === 'pre' + ); + if (!pre) return; + + // langAlias rewrites what Shiki reports, so the attribute Astro set from + // the fence wins when it is there. ```ocl has to stay OCL, not HCL. + const language = displayName( + pre.properties?.['data-language'] ?? this.options.lang + ); + const label = this.options.meta?.__raw?.trim() ?? ''; + + const header = h('div', { className: ['code-block__header'] }, [ + h( + 'p', + { className: ['code-block__label'], hidden: !label }, + label ? [text(label)] : [] + ), + h('div', { className: ['code-block__actions'] }, [ + h('span', { className: ['code-block__language'] }, [text(language)]), + h( + 'button', + { + type: 'button', + className: ['code-block__copy', 'btn', 'btn--small'], + 'data-tooltip': REST, + 'aria-label': 'Copy code to clipboard', + }, + // Empty: the glyph is a CSS mask on the span itself. + [ + h( + 'span', + { className: ['code-block__copy-icon', 'btn__icon'] }, + [] + ), + ] + ), + ]), + ]); + + const body = h('div', { className: ['code-block__body'] }, [ + h('div', { className: ['code-block__panel'] }, [pre]), + h('div', { className: ['code-block__fade'] }, []), + ]); + + // Sits below the body rather than over it, so it stays clear of the code + // once the block is open. Hidden until code-blocks.js finds the block too + // tall to show whole. + const toggle = h( + 'button', + { + type: 'button', + className: ['code-block__toggle'], + 'aria-expanded': 'false', + }, + [text(SHOW_MORE)] + ); + + // Replaced in place: assigning to node.children would drop anything else + // a later Shiki version puts alongside the
.
+      node.children[node.children.indexOf(pre)] = h(
+        'div',
+        { className: ['code-block'] },
+        [header, body, toggle]
+      );
+    },
+  };
+}
diff --git a/src/scripts/main.js b/src/scripts/main.js
index cd5a85d246..49c941da5f 100644
--- a/src/scripts/main.js
+++ b/src/scripts/main.js
@@ -43,6 +43,13 @@ function enabled(settings, option) {
   return settings && settings.includes(option);
 }
 
+// Ahead of the tabs: a group whose panels are all code becomes one code block
+// with a language menu, and code-blocks.js removes it so tabs skip it.
+if (enabled(f.codeBlocks, 'copy')) {
+  const codeBlocks = await import('./modules/code-blocks.js');
+  codeBlocks.enhanceCodeBlocks();
+}
+
 if (enabled(f.details, 'tabs')) {
   const tabs = await import('./modules/detail-tabs.js');
   tabs.enhanceDetailGroups();
@@ -53,11 +60,6 @@ if (enabled(f.youTubeLinks, 'embed')) {
   youTube.enhanceYoutubeLinks();
 }
 
-if (enabled(f.codeBlocks, 'copy')) {
-  const codeBlocks = await import('./modules/code-blocks.js');
-  codeBlocks.enhanceCodeBlocks();
-}
-
 if (enabled(f.figures, 'enlarge')) {
   const figures = await import('./modules/figures.js');
   figures.enhanceFigures();
diff --git a/src/scripts/modules/code-blocks.js b/src/scripts/modules/code-blocks.js
index d045e9c350..8302d65cfc 100644
--- a/src/scripts/modules/code-blocks.js
+++ b/src/scripts/modules/code-blocks.js
@@ -1,58 +1,228 @@
+// @ts-check
 import { qs, qsa } from './query.js';
+import { copyOnClick } from './copy-button.js';
 
-const activeClass = 'copy-button';
+// The shell around each block, its copy button included, is rendered at build
+// time by src/plugins/shiki-code-block.js. This wires up what happens next.
 
-const clipboard = `
-
-
-
-`;
+/** Taller than this and the block collapses until it is opened. */
+const COLLAPSE_HEIGHT = 500;
 
-const clipboardDone = `
-
-
-
-
-`;
+const SHOW_MORE = 'Show more';
+const SHOW_LESS = 'Show less';
 
-const clipboardError = `
-
-
-
-
-`;
+/**
+ * @param {HTMLElement} button
+ */
+function visibleCode(button) {
+  const code = button
+    .closest('.code-block')
+    ?.querySelector('.code-block__panel:not([hidden]) code');
+
+  // textContent because a collapsed block clips its last lines, and innerText
+  // returns only what is on screen.
+  return code?.textContent ?? null;
+}
+
+/* Language switcher ------------------------------------------------------ */
 
 /**
- * Enables copy on code blocks (
...)
+ * A  renders no pseudo-element to hang the caret on.
+  const switcher = document.createElement('span');
+  switcher.className = 'code-block__language-switcher';
+  switcher.appendChild(select);
+
+  qs('.code-block__language', block).replaceWith(switcher);
+  show();
+}
+
+/**
+ * A group qualifies only when every panel is a lone code block. Groups holding
+ * prose as well stay tabs, which detail-tabs.js builds.
+ */
+function enhanceGroups() {
+  const seen = new Set();
+
+  qsa('details[data-group]').forEach((first) => {
+    const group = first.dataset.group;
+    if (!group || seen.has(group)) return;
+    seen.add(group);
+
+    const participants = Array.from(
+      qsa(`details[data-group="${CSS.escape(group)}"]`)
+    );
+
+    const found = participants.map((details) => {
+      const summary = details.querySelector('summary');
+      const children = Array.from(details.children).filter(
+        (child) => child !== summary
+      );
+      const block = children[0];
+      const isLoneCodeBlock =
+        children.length === 1 &&
+        block instanceof HTMLElement &&
+        block.classList.contains('code-block');
+
+      return isLoneCodeBlock && summary ? { summary, block } : null;
     });
+
+    if (found.some((entry) => !entry)) return;
+
+    const host = found[0].block;
+    const entries = found.map(({ summary, block }) => ({
+      name:
+        summary.textContent?.trim() ||
+        qs('.code-block__language', block).textContent ||
+        '',
+      label: qs('.code-block__label', block).textContent ?? '',
+    }));
+
+    // Every panel moves into the first block, which then takes the group's
+    // place. The emptied shells leave with their 
. + const fade = qs('.code-block__fade', host); + found + .slice(1) + .forEach(({ block }) => fade.before(qs('.code-block__panel', block))); + + participants[0].replaceWith(host); + participants.forEach((details) => details.remove()); + + // A group of one still loses its
, but a switcher holding a single + // option would be a control that cannot do anything. + if (entries.length === 1) { + qs('.code-block__language', host).textContent = entries[0].name; + } else { + addLanguageSelect(host, entries); + } }); } +/* Collapsing ------------------------------------------------------------- */ + +/** + * The cap is lifted before reading, or the height comes back as the cap. + * + * @param {HTMLElement} block + */ +function measure(block) { + const body = qs('.code-block__body', block); + + block.removeAttribute('data-collapsible'); + const height = body.scrollHeight; + + if (height <= COLLAPSE_HEIGHT) { + block.removeAttribute('data-expanded'); + setToggle(block, false); + return; + } + + block.style.setProperty('--code-block-height', `${height}px`); + block.setAttribute('data-collapsible', ''); +} + +/** + * @param {HTMLElement} block + * @param {boolean} expanded + */ +function setToggle(block, expanded) { + const toggle = qs('.code-block__toggle', block); + toggle.textContent = expanded ? SHOW_LESS : SHOW_MORE; + toggle.setAttribute('aria-expanded', String(expanded)); +} + +/** + * Delegated to the document because detail-tabs.js rebuilds its panels from + * innerHTML, which drops any listener held on an element inside one. + * + * Opening is one way apart from the toggle. Collapsing on a click elsewhere + * pulled the page up by however tall the block was, which moved everything + * under the reader's cursor and lost their place. + */ +function addCollapseListeners() { + document.addEventListener('click', (event) => { + if (!(event.target instanceof Element)) return; + + const toggle = event.target.closest('.code-block__toggle'); + if (toggle) { + const block = toggle.closest('.code-block'); + if (!(block instanceof HTMLElement)) return; + + const expanded = block.toggleAttribute('data-expanded'); + setToggle(block, expanded); + // A block collapsing above the viewport would leave the reader somewhere + // else on the page. + if (!expanded) block.scrollIntoView({ block: 'nearest' }); + return; + } + + const body = event.target.closest( + '.code-block[data-collapsible] .code-block__body' + ); + const block = body?.closest('.code-block'); + if (block instanceof HTMLElement && !block.hasAttribute('data-expanded')) { + block.setAttribute('data-expanded', ''); + setToggle(block, true); + } + }); +} + +/** + * Deferred until the fonts settle: the fallback font gives different line + * heights, and a block near the threshold lands on the wrong side of it. + */ +function measureAll() { + const all = () => qsa('.code-block').forEach(measure); + + if (document.fonts) document.fonts.ready.then(all); + else all(); + + document.addEventListener('resized', all); + + // A tab panel has no height while it is hidden, so the blocks inside one can + // only be measured once its tab has been picked. + document.addEventListener('click', (event) => { + if (event.target instanceof Element && event.target.closest('[role=tab]')) { + all(); + } + }); +} + +function enhanceCodeBlocks() { + enhanceGroups(); + copyOnClick('.code-block__copy', visibleCode); + addCollapseListeners(); + measureAll(); +} + export { enhanceCodeBlocks }; diff --git a/src/scripts/modules/copy-button.js b/src/scripts/modules/copy-button.js new file mode 100644 index 0000000000..e06690ab8d --- /dev/null +++ b/src/scripts/modules/copy-button.js @@ -0,0 +1,109 @@ +// @ts-check + +// Shared by the heading copy-URL button and the code block copy button. Both +// swap their tooltip to a result, revert after a beat, and announce it. + +const REVERT_MS = 2000; + +const COPIED = 'Copied'; +const FAILED = 'Copy failed'; + +/** @type {WeakMap>} */ +const timers = new WeakMap(); + +/** @type {WeakMap} */ +const restLabels = new WeakMap(); + +/** @type {HTMLElement | null} */ +let status = null; + +/** + * A button's own data-tooltip is its resting label, captured before the first + * result overwrites it. + * + * @param {HTMLElement} button + */ +function restLabel(button) { + if (!restLabels.has(button)) { + restLabels.set(button, button.dataset.tooltip ?? ''); + } + return restLabels.get(button) ?? ''; +} + +/** + * @param {HTMLElement} button + * @param {string} message + */ +function showResult(button, message) { + const rest = restLabel(button); + + button.dataset.tooltip = message; + button.dataset.copied = ''; + + clearTimeout(timers.get(button)); + timers.set( + button, + setTimeout(() => { + button.dataset.tooltip = rest; + delete button.dataset.copied; + timers.delete(button); + }, REVERT_MS) + ); +} + +/** + * One region for the whole page, on the body so it cannot land inside a + * heading's accessible name. + * + * @param {string} message + */ +function announce(message) { + if (!status) { + status = document.createElement('div'); + status.className = 'copy-status'; + status.setAttribute('aria-live', 'polite'); + document.body.append(status); + } + + // Cleared first, then set on a later task, so copying twice in a row reads as + // a change and is announced both times. Same as copy-markdown.js. + const region = status; + region.textContent = ''; + setTimeout(() => { + region.textContent = message; + }, 50); +} + +/** + * Delegated, so it covers buttons that are rendered at build time as well as + * ones a module adds later. + * + * @param {string} selector + * @param {(button: HTMLElement) => string | null} read the text to copy. Must + * return synchronously: Safari spends the click's user activation on the + * first await, and the clipboard write then fails. + */ +function copyOnClick(selector, read) { + document.addEventListener('click', async (event) => { + if (!(event.target instanceof Element)) return; + + const button = event.target.closest(selector); + if (!(button instanceof HTMLElement)) return; + + const value = read(button); + if (value === null) return; + + let message = COPIED; + try { + await navigator.clipboard.writeText(value); + } catch (error) { + console.warn('[copy-button] clipboard write failed', error); + message = FAILED; + } + + showResult(button, message); + announce(message); + }); +} + +export { copyOnClick }; diff --git a/src/scripts/modules/headers.js b/src/scripts/modules/headers.js index 23be16f39a..93ce6ed755 100644 --- a/src/scripts/modules/headers.js +++ b/src/scripts/modules/headers.js @@ -1,17 +1,8 @@ // @ts-check import { qsa } from './query.js'; - -const REVERT_MS = 2000; +import { copyOnClick } from './copy-button.js'; const REST = 'Copy URL'; -const COPIED = 'Copied'; -const FAILED = 'Copy failed'; - -/** @type {HTMLElement | null} */ -let status = null; - -/** @type {WeakMap>} */ -const timers = new WeakMap(); /** * Scoped to .page-content headings with an id: the feedback prompt and the @@ -41,84 +32,21 @@ function addCopyButtons() { }); } -function addCopyListener() { - document.addEventListener('click', (event) => { - if (!(event.target instanceof Element)) return; - - const button = event.target.closest('.copy-heading-url'); - if (button instanceof HTMLElement) copyHeadingUrl(button); - }); -} - /** * @param {HTMLElement} button */ -async function copyHeadingUrl(button) { +function headingUrl(button) { const id = button.closest('h2, h3, h4, h5, h6')?.id; - if (!id) return; + if (!id) return null; const url = new URL(window.location.href); url.hash = id; - - let message = COPIED; - try { - // Nothing may be awaited before this: Safari spends the click's user - // activation on the first await, and the write then fails. - await navigator.clipboard.writeText(url.toString()); - } catch (error) { - console.warn('[headers] clipboard write failed', error); - message = FAILED; - } - - showResult(button, message); - announce(message); -} - -/** - * @param {HTMLElement} button - * @param {string} message - */ -function showResult(button, message) { - button.dataset.tooltip = message; - button.dataset.copied = ''; - - clearTimeout(timers.get(button)); - timers.set( - button, - setTimeout(() => { - button.dataset.tooltip = REST; - delete button.dataset.copied; - timers.delete(button); - }, REVERT_MS) - ); -} - -/** - * The region is appended to the body rather than the heading, so it cannot end - * up in a heading's accessible name. - * - * @param {string} message - */ -function announce(message) { - if (!status) { - status = document.createElement('div'); - status.className = 'copy-heading-url-status'; - status.setAttribute('aria-live', 'polite'); - document.body.append(status); - } - - // Cleared first, then set on a later task, so copying twice in a row reads as - // a change and is announced both times. Same as copy-markdown.js. - const region = status; - region.textContent = ''; - setTimeout(() => { - region.textContent = message; - }, 50); + return url.toString(); } function enhanceHeaders() { addCopyButtons(); - addCopyListener(); + copyOnClick('.copy-heading-url', headingUrl); } export { enhanceHeaders }; diff --git a/src/styles/main.css b/src/styles/main.css index 6c992e528b..4151c15081 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -125,12 +125,12 @@ code { font: var(--textCodeRegularMedium); } +/* The chrome lives on .code-block, which wraps every fenced block. A bare
+   only carries the type and the wrapping. */
 pre {
-  padding: 0.5rem 1rem;
+  margin: 0;
   white-space: break-spaces;
-  background: var(--colorBackgroundSecondaryDefault) !important;
-  border: var(--borderWidth1) solid var(--colorBorderPrimary);
-  border-radius: var(--borderRadiusLarge);
+  background: transparent !important;
   font: var(--textCodeRegularMedium);
   color: var(--color-text);
 }
@@ -156,9 +156,14 @@ div.hint code:not(pre code) {
   color: var(--color-text);
 }
 
+/* Shiki writes the light set inline and the dark set to --shiki-dark-*. */
 html[data-theme='dark'] {
-  pre.astro-code code .line {
-    filter: invert(98%) hue-rotate(180deg) brightness(1.1);
+  .astro-code,
+  .astro-code span {
+    color: var(--shiki-dark) !important;
+    font-style: var(--shiki-dark-font-style) !important;
+    font-weight: var(--shiki-dark-font-weight) !important;
+    text-decoration: var(--shiki-dark-text-decoration) !important;
   }
 }
 
@@ -317,16 +322,6 @@ strong {
   margin-block-end: var(--space16);
 }
 
-/* TODO: This is ugly but global styles above requires this to be set - Layout refactor is required to do first, then remove this. */
-.page-content > div.info > .copy-container,
-.page-content > div.success > .copy-container,
-.page-content > div.warning > .copy-container,
-.page-content > div.problem > .copy-container,
-.page-content > div.question > .copy-container,
-.page-content > div.hint > .copy-container {
-  margin: 0;
-}
-
 .page-content ul,
 .page-content ol {
   margin-inline-start: var(--space24);
@@ -342,7 +337,9 @@ strong {
   padding-block-start: var(--space6);
 }
 
-.page-content :is(pre, figure) {
+/* The block, not the 
 it wraps: spacing the inner element would push the
+   code away from its own header. */
+.page-content :is(.code-block, figure) {
   margin-block: var(--space24);
 }
 
@@ -412,7 +409,6 @@ strong {
 }
 
 .copy-heading-url.btn {
-  position: relative;
   top: -0.1em;
   font-size: inherit; /* make sure button is centered */
   margin-inline-start: var(--space8);
@@ -429,50 +425,12 @@ strong {
   mask: url('../assets/icons/check.svg') center / contain no-repeat;
 }
 
-/* The tooltip bubble. */
-.copy-heading-url::after {
-  content: attr(data-tooltip);
-  position: absolute;
-  left: 50%;
-  inset-block-end: calc(100% + var(--space8) + var(--borderWidth1));
-  translate: -50% 0;
-  padding: var(--space4) var(--space8);
-  border-radius: var(--borderRadiusSmall);
-  background: var(--colorBackgroundInversePrimary);
-  color: var(--colorTextInversePrimary);
-  font: var(--textBodyRegularXSmall);
-  text-align: center;
-  white-space: nowrap;
-  pointer-events: none;
-  opacity: 0;
-}
-
-/* The tooltip arrow. A border triangle */
-.copy-heading-url::before {
-  content: '';
-  position: absolute;
-  left: 50%;
-  inset-block-end: calc(100% + var(--space2) + var(--borderWidth1));
-  translate: -50% 0;
-  width: 0;
-  height: 0;
-  border-block-start: 7px solid var(--colorBackgroundInversePrimary);
-  border-inline: 7px solid transparent;
-  pointer-events: none;
-  opacity: 0;
-}
-
 :is(h2, h3, h4, h5, h6):hover .copy-heading-url,
 .copy-heading-url:focus-visible,
 .copy-heading-url[data-copied] {
   opacity: 1;
 }
 
-.copy-heading-url:is(:hover, :focus-visible, [data-copied])::before,
-.copy-heading-url:is(:hover, :focus-visible, [data-copied])::after {
-  opacity: 1;
-}
-
 /* Tables */
 
 .table-wrap {
@@ -1870,7 +1828,7 @@ html[data-theme='light'] .theme-switcher__icon--dark svg path {
 /* Live regions for announcing the result of an action, such as copying a URL or
    a code block. Read by screen readers, never shown. */
 .octo-copy-md__sr-status,
-.copy-heading-url-status {
+.copy-status {
   position: absolute;
   width: 1px;
   height: 1px;
@@ -2695,20 +2653,188 @@ a[data-youtube] {
   font-family: fa-solid;
 }
 
-.copy-container {
-  max-height: 0px;
+/* Code block, built around every fenced block by code-blocks.js. Spacing comes
+   from the .page-content rule it shares with figures. */
+.code-block {
+  border: var(--borderWidth1) solid var(--colorBorderPrimary);
+  border-radius: var(--borderRadiusMedium);
+  background: var(--colorBackgroundPrimaryDefault);
+}
+
+.code-block__header {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  gap: var(--space16);
+  padding: var(--space8);
+  padding-inline-start: var(--space16);
+  border-block-end: var(--borderWidth1) solid var(--colorBorderPrimary);
+}
+
+.code-block__label {
+  flex: 1;
+  min-width: 0;
   margin: 0;
-  width: 100%;
-  text-align: end;
-  z-index: 1;
+  overflow-wrap: break-word;
+  font: var(--textBodyBoldMedium);
+  color: var(--colorTextPrimary);
+}
+
+.code-block__actions {
+  display: flex;
+  align-items: center;
+  gap: var(--space8);
+}
+
+/* Clipping sits here so the language menu and copy tooltip can leave the header. */
+.code-block__body {
+  /* html's border-box does not inherit, and the collapsed height has to count
+     the padding to land on 500px. */
+  box-sizing: border-box;
   position: relative;
+  padding: var(--space16);
+  overflow: clip;
 }
 
-.copy-button {
-  stroke: var(--icon-stroke);
-  fill: var(--icon-fill);
-  background-color: transparent;
+.code-block__copy-icon {
+  background-color: var(--colorIconPrimary);
+  mask: url('../assets/icons/copy.svg') center / contain no-repeat;
+}
+
+.code-block__copy[data-copied] .code-block__copy-icon {
+  mask: url('../assets/icons/check.svg') center / contain no-repeat;
+}
+
+/* Edge-aligned: the button sits at the block's right edge, where a centred
+   bubble would hang off the side of the page. */
+.code-block .code-block__copy::after {
+  left: auto;
+  right: 0;
+  translate: 0 0;
+}
+
+/* The single fixed language */
+.code-block__language {
+  font: var(--textBodyRegularMedium);
+  color: var(--colorTextSecondary);
+  white-space: nowrap;
+}
+
+/* The switcher, when the block was written in several languages. Styled as the
+   design's button; the option list it opens belongs to the browser. */
+.code-block__language-switcher {
+  position: relative;
+  display: inline-flex;
+  align-items: center;
+}
+
+/* Qualified to outrank the `padding` and `text-align` `.btn` sets further down.
+   The box is sized to the longest language, so centring leaves a shorter one
+   adrift between the padding and the caret. */
+.code-block .code-block__language-select {
+  padding-inline-end: calc(var(--space16) + var(--space8));
+  text-align: start;
   cursor: pointer;
+  appearance: none;
+  -webkit-appearance: none;
+}
+
+/* On the wrapper, because a ; the old menu hand-rolled all of this
+    await select.press('ArrowDown');
+    await expect(select).toHaveValue('1');
+  });
+
+  test('a one-member group gets no switcher', async ({ page }) => {
+    await page.goto(SINGLE_GROUP);
+
+    const block = page
+      .locator('.code-block')
+      .filter({ hasText: 'Invoke-RestMethod' })
+      .first();
+
+    await expect(block).toBeVisible();
+    await expect(block.locator('.code-block__language-select')).toHaveCount(0);
+    await expect(block.locator('.code-block__language')).toHaveText(
+      'PowerShell'
+    );
+
+    // The 
is gone either way + await expect(page.locator('details[data-group]')).toHaveCount(0); + }); + + test('leaves a group holding more than code as a tab list', async ({ + page, + }) => { + await page.goto(TABBED); + + await expect(page.locator('.tab-list').first()).toBeVisible(); + }); + + test('a long block collapses behind a Show more button', async ({ page }) => { + await page.goto(LONG); + + const block = page.locator('.code-block[data-collapsible]').first(); + const toggle = block.locator('.code-block__toggle'); + await expect(block.locator('.code-block__fade')).toBeVisible(); + await expect(toggle).toHaveText('Show more'); + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + + const collapsed = await block.locator('.code-block__body').boundingBox(); + expect(collapsed!.height).toBe(500); + + await toggle.click(); + await expect(block).toHaveAttribute('data-expanded', ''); + await expect(toggle).toHaveText('Show less'); + await expect(toggle).toHaveAttribute('aria-expanded', 'true'); + await expect + .poll( + async () => + (await block.locator('.code-block__body').boundingBox())!.height + ) + .toBeGreaterThan(500); + + // Clicking elsewhere leaves it open: collapsing under the reader moved the + // page out from under them. + await page.locator('h1').click(); + await expect(block).toHaveAttribute('data-expanded', ''); + + await toggle.click(); + await expect(block).not.toHaveAttribute('data-expanded', ''); + await expect(toggle).toHaveText('Show more'); + }); + + test('a block that fits shows no toggle', async ({ page }) => { + await page.goto(SINGLE); + + const block = page.locator('.code-block').first(); + await expect(block).not.toHaveAttribute('data-collapsible', ''); + await expect(block.locator('.code-block__toggle')).toBeHidden(); + await expect(block.locator('.code-block__fade')).toBeHidden(); + }); + + test('clicking the code opens it too', async ({ page }) => { + await page.goto(LONG); + + const block = page.locator('.code-block[data-collapsible]').first(); + await block.locator('.code-block__body').click(); + await expect(block).toHaveAttribute('data-expanded', ''); + await expect(block.locator('.code-block__toggle')).toHaveText('Show less'); + }); +}); diff --git a/tests/copy-button.spec.ts b/tests/copy-button.spec.ts new file mode 100644 index 0000000000..cdd7d4f252 --- /dev/null +++ b/tests/copy-button.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from '@playwright/test'; + +// Both buttons run on copy-button.js, so a break in one is a break in both. +const PAGE = '/docs/kubernetes/steps/kustomize'; + +test.beforeEach(async ({ context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); +}); + +test('the heading button copies that heading’s URL', async ({ page }) => { + await page.goto(PAGE); + + const heading = page.locator('.page-content h2[id]').first(); + const id = await heading.getAttribute('id'); + const button = heading.locator('.copy-heading-url'); + + await expect(button).toHaveAttribute('data-tooltip', 'Copy URL'); + + await heading.hover(); + await button.click(); + + await expect(button).toHaveAttribute('data-tooltip', 'Copied'); + expect(await page.evaluate(() => navigator.clipboard.readText())).toContain( + `#${id}` + ); + + // Each button keeps its own resting label + await expect(button).toHaveAttribute('data-tooltip', 'Copy URL', { + timeout: 4000, + }); +}); + +test('the two buttons revert to different labels', async ({ page }) => { + await page.goto(PAGE); + + const heading = page.locator('.page-content h2[id]').first(); + await heading.hover(); + await heading.locator('.copy-heading-url').click(); + + const code = page.locator('.code-block__copy').first(); + await code.click(); + + await expect(heading.locator('.copy-heading-url')).toHaveAttribute( + 'data-tooltip', + 'Copy URL', + { timeout: 4000 } + ); + await expect(code).toHaveAttribute('data-tooltip', 'Copy to clipboard'); +}); + +test('copying announces the result once, from a single live region', async ({ + page, +}) => { + await page.goto(PAGE); + + await page.locator('.code-block__copy').first().click(); + + const region = page.locator('.copy-status'); + await expect(region).toHaveCount(1); + await expect(region).toHaveText('Copied'); +});