From beb0025a26510b9bd856e415e68440420ebb17e6 Mon Sep 17 00:00:00 2001 From: William Laugesen Date: Fri, 7 Aug 2026 12:23:41 +1200 Subject: [PATCH 01/10] Give code blocks a header with a language and copy button NES-285. Every fenced block is now wrapped in a shell carrying the block's label, its language and a copy button, matching the CodeBlock component in the design system. A group of
panels that are each a lone code block becomes one block whose header offers the languages in a menu, replacing the tab list. Groups holding prose as well stay tabs. Blocks over 500px collapse behind a gradient fade until they are clicked. Text after the language on the opening fence becomes the block's label. Shiki drops that meta string, so a transformer keeps it on the element. The fences that only repeated their own language there have it removed, since that would render a label saying what the header already shows. Shiki now highlights against both themes, so dark mode gets real syntax colours in place of the inverting filter. Co-Authored-By: Claude Opus 5 (1M context) --- astro.config.mjs | 20 +- src/assets/icons/copy.svg | 4 + src/pages/components.mdx | 112 +++++ .../docs/deployments/custom-scripts/index.md | 4 +- .../logging-messages-in-scripts.md | 2 +- .../custom-scripts/output-variables.md | 2 +- .../reference-files-within-a-package.md | 2 +- .../docs/deployments/git/commit-to-git.md | 8 +- .../new-octopustarget.mdx | 4 +- src/pages/docs/kubernetes/steps/kustomize.mdx | 2 +- .../users-and-teams/add-azure-ad-to-users.mdx | 4 +- .../octopus.client/using-resources.md | 4 +- .../octopus.client/working-with-spaces.md | 4 +- .../variables/certificate-variables.md | 2 +- .../projects/variables/output-variables.mdx | 4 +- src/scripts/main.js | 12 +- src/scripts/modules/code-blocks.js | 463 ++++++++++++++++-- src/styles/main.css | 285 ++++++++--- tests/code-block.spec.ts | 108 ++++ 19 files changed, 908 insertions(+), 138 deletions(-) create mode 100644 src/assets/icons/copy.svg create mode 100644 tests/code-block.spec.ts diff --git a/astro.config.mjs b/astro.config.mjs index bd24feeb7f..7de765bb81 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -25,11 +25,27 @@ 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' - } + }, + transformers: [ + { + // Shiki drops the fence's meta string, which the code block + // header renders as the block's label + pre(node) { + const label = this.options.meta?.__raw?.trim(); + if (label) node.properties['data-label'] = label; + } + } + ] }, 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..ce6dfc65c6 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!" ``` diff --git a/src/pages/docs/deployments/custom-scripts/output-variables.md b/src/pages/docs/deployments/custom-scripts/output-variables.md index 1a309bc4b0..8d75318e06 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") ``` 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"; 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..cfb2662a00 100644 --- a/src/pages/docs/projects/variables/certificate-variables.md +++ b/src/pages/docs/projects/variables/certificate-variables.md @@ -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..91bd66a9fb 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 ``` @@ -260,7 +260,7 @@ Octopus.setVariable "TestResult" "Passed" **Python3** -```python Python3 +```python set_octopusvariable("TestResult", "Passed") ``` 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..16a02bf616 100644 --- a/src/scripts/modules/code-blocks.js +++ b/src/scripts/modules/code-blocks.js @@ -1,58 +1,431 @@ +// @ts-check import { qs, qsa } from './query.js'; -const activeClass = 'copy-button'; +const REVERT_MS = 2000; -const clipboard = ` - - - -`; +const REST = 'Copy to clipboard'; +const COPIED = 'Copied'; +const FAILED = 'Copy failed'; -const clipboardDone = ` - - - - -`; +/** Taller than this and the block collapses until it is clicked. */ +const COLLAPSE_HEIGHT = 500; -const clipboardError = ` - - - - -`; +/** + * Display names for the fence languages used across the docs. Anything missing + * falls back to the raw value with its first letter capitalised. + */ +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', +}; + +/** @type {WeakMap>} */ +const timers = new WeakMap(); + +/** @type {HTMLElement | null} */ +let status = null; /** - * Enables copy on code blocks (
...)
+ * @param {string} language
  */
-function enhanceCodeBlocks() {
-  // Make code blocks focusable, so they can be keyboard scrolled
-  qsa('pre.astro-code').forEach((elem) => elem.setAttribute('tabindex', '0'));
-
-  qsa(`pre:not(.${activeClass})`).forEach((node) => {
-    const copy = document.createElement('button');
-    copy.classList.add(activeClass);
-    copy.innerHTML = clipboard;
-    copy.title = 'Copy';
-
-    const copyContainer = document.createElement('div');
-    copyContainer.className = 'copy-container';
-    copyContainer.appendChild(copy);
-
-    node.insertAdjacentElement('beforebegin', copyContainer);
-    copy.addEventListener('click', async () => {
-      if (navigator.clipboard) {
-        // @ts-ignore
-        const text = qs('code', node).innerText;
-        await navigator.clipboard.writeText(text);
-        copy.innerHTML = clipboardDone;
-      } else {
-        copy.innerHTML = clipboardError;
-      }
-
-      setTimeout(() => (copy.innerHTML = clipboard), 2000);
+function displayName(language) {
+  const key = language.trim().toLowerCase();
+  return LANGUAGE_NAMES[key] ?? key.charAt(0).toUpperCase() + key.slice(1);
+}
+
+/**
+ * @param {string} tag
+ * @param {string} className
+ * @param {string} [text]
+ */
+function el(tag, className, text) {
+  const node = document.createElement(tag);
+  node.className = className;
+  if (text) node.textContent = text;
+  return node;
+}
+
+/* Copying ---------------------------------------------------------------- */
+
+function buildCopyButton() {
+  const button = document.createElement('button');
+  button.type = 'button';
+  button.className = 'code-block__copy btn btn--small';
+  button.dataset.tooltip = REST;
+  button.setAttribute('aria-label', 'Copy code to clipboard');
+
+  // Empty: the glyph is a CSS mask on the span itself.
+  button.appendChild(el('span', 'code-block__copy-icon btn__icon'));
+
+  return button;
+}
+
+/**
+ * @param {HTMLElement} button
+ */
+async function copyCode(button) {
+  const block = button.closest('.code-block');
+  const code = block?.querySelector('.code-block__panel:not([hidden]) code');
+  if (!code) return;
+
+  let message = COPIED;
+  try {
+    // textContent because a collapsed block clips its last lines, and innerText
+    // returns only what is on screen.
+    // 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(code.textContent ?? '');
+  } catch (error) {
+    console.warn('[code-blocks] 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)
+  );
+}
+
+/**
+ * @param {string} message
+ */
+function announce(message) {
+  if (!status) {
+    status = el('div', 'code-block-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);
+}
+
+/* Language selector ------------------------------------------------------ */
+
+/**
+ * @param {string[]} names
+ * @param {(index: number) => void} onSelect
+ */
+function buildLanguageControl(names, onSelect) {
+  if (names.length === 1) {
+    return el('span', 'code-block__language', names[0]);
+  }
+
+  const menu = document.createElement('details');
+  menu.className = 'code-block__languages';
+
+  const trigger = document.createElement('summary');
+  trigger.className = 'code-block__language-trigger btn btn--small';
+
+  const caret = el('i', 'fa-solid fa-caret-down btn__icon');
+  caret.setAttribute('aria-hidden', 'true');
+
+  const triggerLabel = el('span', 'btn__label', names[0]);
+  trigger.append(triggerLabel, caret);
+
+  // The visible text alone would name the control "PowerShell", which says
+  // nothing about it being a control.
+  const nameTrigger = (name) =>
+    trigger.setAttribute('aria-label', `Language: ${name}. Change language`);
+  nameTrigger(names[0]);
+
+  const options = el('ul', 'code-block__language-options');
+  names.forEach((name, index) => {
+    const option = document.createElement('button');
+    option.type = 'button';
+    option.className = 'code-block__language-option';
+    option.textContent = name;
+    option.setAttribute('aria-pressed', index === 0 ? 'true' : 'false');
+
+    option.addEventListener('click', () => {
+      triggerLabel.textContent = name;
+      nameTrigger(name);
+      qsa('.code-block__language-option', options).forEach((other) =>
+        other.setAttribute('aria-pressed', String(other === option))
+      );
+      menu.open = false;
+      onSelect(index);
     });
+
+    const item = document.createElement('li');
+    item.appendChild(option);
+    options.appendChild(item);
   });
+
+  menu.append(trigger, options);
+  addMenuListeners(menu, trigger);
+
+  return menu;
+}
+
+/**
+ * @param {HTMLDetailsElement} menu
+ * @param {HTMLElement} trigger
+ */
+function addMenuListeners(menu, trigger) {
+  menu.addEventListener('keydown', (event) => {
+    if (!menu.open || event.key !== 'Escape') return;
+    event.preventDefault();
+    menu.open = false;
+    trigger.focus();
+  });
+
+  document.addEventListener('click', (event) => {
+    if (!menu.open) return;
+    if (event.target instanceof Node && menu.contains(event.target)) return;
+    menu.open = false;
+  });
+}
+
+/* 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');
+    return;
+  }
+
+  block.style.setProperty('--code-block-height', `${height}px`);
+  block.setAttribute('data-collapsible', '');
+}
+
+/**
+ * Delegated to the document because detail-tabs.js rebuilds its panels from
+ * innerHTML, which drops any listener held on an element inside one.
+ */
+function addCollapseListeners() {
+  /** @param {Element} target */
+  const expand = (target) => {
+    const body = target.closest(
+      '.code-block[data-collapsible] .code-block__body'
+    );
+    body?.closest('.code-block')?.setAttribute('data-expanded', '');
+  };
+
+  document.addEventListener('click', (event) => {
+    if (!(event.target instanceof Element)) return;
+    expand(event.target);
+
+    // The header is part of the block, so copying does not collapse it.
+    qsa('.code-block[data-expanded]').forEach((block) => {
+      if (!block.contains(event.target)) block.removeAttribute('data-expanded');
+    });
+  });
+
+  // Tabbing into the code counts as reaching for it, same as a click.
+  document.addEventListener('focusin', (event) => {
+    if (event.target instanceof Element) expand(event.target);
+  });
+}
+
+/* Assembly --------------------------------------------------------------- */
+
+/**
+ * @param {HTMLElement[]} pres
+ * @param {string[]} names
+ * @param {HTMLElement} anchor element the finished block takes the place of
+ */
+function buildBlock(pres, names, anchor) {
+  // For a plain block the anchor is the 
 itself, which the panels below
+  // move out of the page. An element cannot be replaced by its own descendant,
+  // so the place is claimed before any of that happens.
+  const marker = document.createComment('code-block');
+  anchor.replaceWith(marker);
+
+  const block = el('div', 'code-block');
+  const header = el('div', 'code-block__header');
+  const body = el('div', 'code-block__body');
+
+  const label = el('p', 'code-block__label');
+  const setLabel = (index) => {
+    const text = pres[index].dataset.label ?? '';
+    label.textContent = text;
+    label.hidden = !text;
+  };
+  setLabel(0);
+  header.appendChild(label);
+
+  const panels = pres.map((pre, index) => {
+    const panel = el('div', 'code-block__panel');
+    panel.hidden = index !== 0;
+    // Focusable so an overflowing panel can be scrolled from the keyboard.
+    pre.setAttribute('tabindex', '0');
+    panel.appendChild(pre);
+    return panel;
+  });
+  body.append(...panels, el('div', 'code-block__fade'));
+
+  const actions = el('div', 'code-block__actions');
+  actions.append(
+    buildLanguageControl(names, (index) => {
+      panels.forEach((panel, i) => (panel.hidden = i !== index));
+      setLabel(index);
+      measure(block);
+    }),
+    buildCopyButton()
+  );
+  header.appendChild(actions);
+
+  block.append(header, body);
+  marker.replaceWith(block);
+
+  return block;
+}
+
+/**
+ * A group qualifies for the language menu 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 panels = participants.map((details) => {
+      const summary = details.querySelector('summary');
+      const children = Array.from(details.children).filter(
+        (child) => child !== summary
+      );
+      const pre = children[0];
+      const isLoneCodeBlock =
+        children.length === 1 &&
+        pre instanceof HTMLElement &&
+        pre.tagName === 'PRE';
+
+      return isLoneCodeBlock && summary
+        ? { name: summary.textContent?.trim() ?? '', pre }
+        : null;
+    });
+
+    if (panels.some((panel) => !panel)) return;
+
+    buildBlock(
+      panels.map((panel) => panel.pre),
+      panels.map(
+        (panel) => panel.name || displayName(panel.pre.dataset.language ?? '')
+      ),
+      participants[0]
+    );
+
+    // detail-tabs.js keys off data-group, so the consumed ones have to go.
+    participants.forEach((details) => details.remove());
+  });
+}
+
+function enhanceSingleBlocks() {
+  qsa('pre.astro-code').forEach((pre) => {
+    if (pre.closest('.code-block')) return;
+    buildBlock([pre], [displayName(pre.dataset.language ?? '')], pre);
+  });
+}
+
+function addCopyListener() {
+  document.addEventListener('click', (event) => {
+    if (!(event.target instanceof Element)) return;
+
+    const button = event.target.closest('.code-block__copy');
+    if (button instanceof HTMLElement) copyCode(button);
+  });
+}
+
+/**
+ * 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();
+  enhanceSingleBlocks();
+  addCopyListener();
+  addCollapseListeners();
+  measureAll();
 }
 
 export { enhanceCodeBlocks };
diff --git a/src/styles/main.css b/src/styles/main.css
index 6c992e528b..e2094ed38e 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);
@@ -412,7 +407,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 +423,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 +1826,8 @@ 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-heading-url-status,
+.code-block-status {
   position: absolute;
   width: 1px;
   height: 1px;
@@ -2695,22 +2652,176 @@ a[data-youtube] {
   font-family: fa-solid;
 }
 
-.copy-container {
-  max-height: 0px;
+/* Code block, built around every fenced block by code-blocks.js */
+.code-block {
+  margin-block: 1rem;
+  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 menu, when the block was written in several languages */
+.code-block__languages {
+  position: relative;
+}
+
+.code-block__language-trigger {
+  list-style: none;
+  cursor: pointer;
+}
+
+/* Qualified to outrank the global rule that styles every  as a link */
+.code-block .code-block__language-trigger,
+.code-block .code-block__language-trigger > * {
+  color: var(--colorTextPrimary);
+  text-decoration: none;
+}
+
+.code-block__language-trigger::-webkit-details-marker {
+  display: none;
+}
+
+/* Qualified to outrank `.page-content ul`, which indents every list */
+.code-block .code-block__language-options {
+  position: absolute;
+  inset-inline-end: 0;
+  inset-block-start: calc(100% + var(--space4));
+  z-index: 2;
+  min-width: 100%;
+  margin: 0;
+  padding: var(--space4);
+  border: var(--borderWidth1) solid var(--colorBorderPrimary);
+  border-radius: var(--borderRadiusSmall);
+  background: var(--colorBackgroundPrimaryDefault);
+  box-shadow: var(--octo-shadow-standard-box);
+  list-style: none;
+}
+
+.code-block__language-option {
+  display: block;
+  width: 100%;
+  padding: var(--space4) var(--space8);
+  border: 0;
+  border-radius: var(--borderRadiusSmall);
+  background: transparent;
+  color: var(--colorTextPrimary);
+  font: var(--textBodyRegularMedium);
+  text-align: start;
+  white-space: nowrap;
   cursor: pointer;
 }
 
+.code-block__language-option:is(:hover, :focus-visible) {
+  background: var(--colorBackgroundPrimaryHover);
+}
+
+.code-block__language-option[aria-pressed='true'] {
+  font: var(--textBodyBoldMedium);
+}
+
+/* --code-block-height is measured by code-blocks.js: max-height has to resolve
+   to a length for the expansion to animate. */
+.code-block[data-collapsible] .code-block__body {
+  max-height: 500px;
+  cursor: pointer;
+  transition: max-height var(--duration-default) ease-in-out;
+
+  @media (prefers-reduced-motion: reduce) {
+    transition: none;
+  }
+}
+
+.code-block[data-collapsible][data-expanded] .code-block__body {
+  max-height: var(--code-block-height);
+  cursor: auto;
+}
+
+.code-block__fade {
+  display: none;
+  position: absolute;
+  inset-inline: 0;
+  inset-block-end: 0;
+  height: 59px;
+  background: linear-gradient(
+    to bottom,
+    rgb(from var(--colorBackgroundPrimaryDefault) r g b / 0) 60%,
+    var(--colorBackgroundPrimaryDefault)
+  );
+  pointer-events: none;
+  transition: opacity var(--duration-default) ease-in-out;
+
+  @media (prefers-reduced-motion: reduce) {
+    transition: none;
+  }
+}
+
+.code-block[data-collapsible] .code-block__fade {
+  display: block;
+}
+
+.code-block[data-expanded] .code-block__fade {
+  opacity: 0;
+}
+
 .magnify-container {
   max-height: 0px;
   margin: 0;
@@ -2937,6 +3048,50 @@ img.btn__icon {
   color: var(--colorButtonIconDisabled);
 }
 
+/* Tooltip. Put the label in data-tooltip; the host positions the bubble above
+   itself, so it needs room there and a stacking context of its own. */
+[data-tooltip] {
+  position: relative;
+}
+
+/* The bubble */
+[data-tooltip]::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 arrow. A border triangle */
+[data-tooltip]::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;
+}
+
+[data-tooltip]:is(:hover, :focus-visible, [data-copied])::before,
+[data-tooltip]:is(:hover, :focus-visible, [data-copied])::after {
+  opacity: 1;
+}
+
 /* Buttons side by side e.g. a toolbar or the component showcase */
 .btn-row {
   display: flex;
diff --git a/tests/code-block.spec.ts b/tests/code-block.spec.ts
new file mode 100644
index 0000000000..9c228b0ecc
--- /dev/null
+++ b/tests/code-block.spec.ts
@@ -0,0 +1,108 @@
+import { test, expect } from '@playwright/test';
+
+// A page with two 
panels, each a lone code block +const GROUPED = '/docs/octopus-rest-api/octopus.client/using-resources'; + +// A page whose group panels hold prose as well as code, so they stay tabs +const TABBED = '/docs/kubernetes/targets/kubernetes-agent/permissions'; + +const SINGLE = '/docs/kubernetes/steps/kustomize'; + +// A page holding a code block far taller than the collapse threshold +const LONG = '/docs/octopus-rest-api/octopus.server.exe-command-line/configure'; + +test.describe('code block', () => { + test('wraps a fenced block in a header with its language and a copy button', async ({ + page, + }) => { + await page.goto(SINGLE); + + const block = page.locator('.code-block').first(); + await expect(block).toBeVisible(); + await expect(block.locator('.code-block__language')).toHaveText('YAML'); + await expect(block.locator('.code-block__copy')).toBeVisible(); + }); + + test('shows the fence meta as the label', async ({ page }) => { + await page.goto(SINGLE); + + await expect(page.locator('.code-block__label').first()).toHaveText( + 'Reference a container image package by version' + ); + }); + + test('copies the visible panel and reverts after the delay', async ({ + page, + context, + }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + await page.goto(SINGLE); + + const copy = page.locator('.code-block__copy').first(); + await expect(copy).toHaveAttribute('data-tooltip', 'Copy to clipboard'); + + await copy.click(); + await expect(copy).toHaveAttribute('data-tooltip', 'Copied'); + + const copied = await page.evaluate(() => navigator.clipboard.readText()); + expect(copied).toContain('kustomization.yaml'); + + await expect(copy).toHaveAttribute('data-tooltip', 'Copy to clipboard', { + timeout: 4000, + }); + }); + + test('turns a code-only details group into one block with a language menu', async ({ + page, + }) => { + await page.goto(GROUPED); + + const block = page.locator('.code-block').first(); + const trigger = block.locator('.code-block__language-trigger'); + await expect(trigger).toHaveText('PowerShell'); + + // Only the selected language is on the page + await expect(block.locator('.code-block__panel:visible')).toHaveCount(1); + await expect(block).toContainText('$repository.Machines.Modify'); + + await trigger.click(); + await block + .locator('.code-block__language-option', { hasText: 'C#' }) + .click(); + + await expect(trigger).toHaveText('C#'); + await expect(block).toContainText('repository.Machines.Modify(machine)'); + await expect(block.locator('.code-block__panel:visible')).toHaveCount(1); + }); + + 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('collapses a long block until it is clicked', async ({ page }) => { + await page.goto(LONG); + + const block = page.locator('.code-block[data-collapsible]').first(); + await expect(block).toBeVisible(); + await expect(block.locator('.code-block__fade')).toBeVisible(); + + const collapsed = await block.locator('.code-block__body').boundingBox(); + expect(collapsed!.height).toBe(500); + + await block.locator('.code-block__body').click(); + await expect(block).toHaveAttribute('data-expanded', ''); + + // Waits out the expand transition before measuring + await expect + .poll(async () => (await block.locator('.code-block__body').boundingBox())!.height) + .toBeGreaterThan(500); + + // Clicking away puts it back + await page.locator('h1').click(); + await expect(block).not.toHaveAttribute('data-expanded', ''); + }); +}); From 4c4a92996345ac5850040baa60ea51d2b1cf6652 Mon Sep 17 00:00:00 2001 From: William Laugesen Date: Fri, 7 Aug 2026 12:40:29 +1200 Subject: [PATCH 02/10] Clear the markdownlint errors in the files this branch touches The workflow lints only the files a branch changed, so touching these surfaced 93 violations that were already there. None came from this branch: the same files on main report the same errors. Most were mechanical and went through markdownlint --fix. The rest needed a decision: - The expanded-properties table in certificate-variables was missing its trailing pipes and its third column, so twelve rows were losing data. It is rebuilt with every row filled in, and `header\footer` reads header/footer. - Six fences had no language. They are `text` now, with a label saying what the service message does. - output-variables used **PowerShell**, **C#**, **Bash**, **F#** and **Python3** as headings above their fences. The section heading already names the language and the block header now shows it, so the emphasis is gone and each fence carries a label instead. - The two certificate screenshots have alt text. Co-Authored-By: Claude Opus 5 (1M context) --- .../logging-messages-in-scripts.md | 19 ++++---- .../custom-scripts/output-variables.md | 3 +- src/pages/docs/kubernetes/steps/kustomize.mdx | 7 +-- .../users-and-teams/add-azure-ad-to-users.mdx | 14 +++--- .../octopus.client/using-resources.md | 2 +- .../variables/certificate-variables.md | 46 +++++++++---------- .../projects/variables/output-variables.mdx | 40 ++++++---------- 7 files changed, 64 insertions(+), 67 deletions(-) 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 ce6dfc65c6..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 @@ -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 8d75318e06..4d00bbcf84 100644 --- a/src/pages/docs/deployments/custom-scripts/output-variables.md +++ b/src/pages/docs/deployments/custom-scripts/output-variables.md @@ -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/kubernetes/steps/kustomize.mdx b/src/pages/docs/kubernetes/steps/kustomize.mdx index 39facf4859..d6f4d0b16b 100644 --- a/src/pages/docs/kubernetes/steps/kustomize.mdx +++ b/src/pages/docs/kubernetes/steps/kustomize.mdx @@ -24,7 +24,7 @@ We list a few scenarios below to help you figure out what is the best setup for 1. **Multiple overlays** This is the recommended usage if you are already using Kustomize and just want Octopus to orchestrate the deployment. In this scenario, our recommendation is to use `.env` files with Octopus [variable substitution syntax](/docs/projects/variables/variable-substitutions), so we can replace secrets and any other data managed via Octopus variables. These `.env` files are then used by [secretGenerator](https://kubectl.docs.kubernetes.io/references/kustomize/builtins/#_secretgenerator_) and/or [configMapGenerator](https://kubectl.docs.kubernetes.io/references/kustomize/builtins/#_configmapgenerator_). - Everything else is defined in the `kustomization.yaml` files directly, and overlays should match the same environment structure defined in Octopus itself. + Everything else is defined in the `kustomization.yaml` files directly, and overlays should match the same environment structure defined in Octopus itself. 2. **Single overlay for Octopus** In this scenario, you may define two overlays, one being for local use outside Octopus, so you can test your `yaml` files. The other overlay is used exclusively by Octopus. @@ -49,7 +49,7 @@ This field must be a path to a directory containing the `kustomization.yaml` fil During deployment, **Kustomize** reads the `kustomization.yaml` file located at this path to perform manifest yaml transforms. The path is relative to the root of the git repository. When using overlays, ensure the path is to the overlay directory containing `kustomization.yaml` file. -Also, remember that in Linux workers, the paths are case-sensitive, so it is always good practice to check this. +Also, remember that in Linux workers, the paths are case-sensitive, so it is always good practice to check this. ## Substitute Variables in Files @@ -96,4 +96,5 @@ The "`#{Octopus.Action.Package[nginx].PackageVersion}`" Octostache expression wi - `Kustomize` was renamed to `Deploy with Kustomize`. - If you store your project configuration in a Git repository using the [Configuration as code feature](/docs/projects/version-control), you can source your Kustomize files from the same Git repository as your deployment process by selecting Project as the Git repository source. When creating a Release, the commit hash used for your deployment process will also be used to source the Kustomize files. You can learn more in [this blog post](https://octopus.com/blog/git-resources-in-deployments). -::: \ No newline at end of file + +::: diff --git a/src/pages/docs/octopus-rest-api/examples/users-and-teams/add-azure-ad-to-users.mdx b/src/pages/docs/octopus-rest-api/examples/users-and-teams/add-azure-ad-to-users.mdx index 5e10bfce67..823e0135ed 100644 --- a/src/pages/docs/octopus-rest-api/examples/users-and-teams/add-azure-ad-to-users.mdx +++ b/src/pages/docs/octopus-rest-api/examples/users-and-teams/add-azure-ad-to-users.mdx @@ -22,8 +22,8 @@ Provide values for: - Octopus URL - Octopus API Key - A list of users, supplied from either: - - The path to a CSV file containing user records - - The Octopus Username, Azure email address and (optionally) Azure display name + - The path to a CSV file containing user records + - The Octopus Username, Azure email address and (optionally) Azure display name - (Optional) whether or not to update the Octopus user's email address - (Optional) whether or not to update the Octopus user's display name - (Optional) whether or not to continue to the next user if an error occurs @@ -47,14 +47,16 @@ AddAzureADLogins -OctopusURL "https://your-octopus-url/" -OctopusAPIKey "API-YOU An example of the expected CSV file format is shown below: -``` +```text Expected CSV format OctopusUsername, AzureEmailAddress, AzureDisplayName OctoUser, octouser@exampledomain.com, Octo User ``` + The first row should be the header row containing the following columns: - - `OctopusUsername` - - `AzureEmailAddress` - - `AzureDisplayName` + +- `OctopusUsername` +- `AzureEmailAddress` +- `AzureDisplayName` ### Script diff --git a/src/pages/docs/octopus-rest-api/octopus.client/using-resources.md b/src/pages/docs/octopus-rest-api/octopus.client/using-resources.md index 36501eb74f..8cd9f735f7 100644 --- a/src/pages/docs/octopus-rest-api/octopus.client/using-resources.md +++ b/src/pages/docs/octopus-rest-api/octopus.client/using-resources.md @@ -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/projects/variables/certificate-variables.md b/src/pages/docs/projects/variables/certificate-variables.md index cfb2662a00..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 diff --git a/src/pages/docs/projects/variables/output-variables.mdx b/src/pages/docs/projects/variables/output-variables.mdx index 91bd66a9fb..d88e897c6d 100644 --- a/src/pages/docs/projects/variables/output-variables.mdx +++ b/src/pages/docs/projects/variables/output-variables.mdx @@ -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 +```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 From bbb3a24d63b69be4e9b5aa2f9eaab180c1d50e82 Mon Sep 17 00:00:00 2001 From: William Laugesen Date: Fri, 7 Aug 2026 13:05:43 +1200 Subject: [PATCH 03/10] Render the code block shell at build time The frame, header, label and language were being built by JavaScript after the page loaded. With scripting off, and in the window before hydration, a code block was bare text on the page background: the border, radius and padding used to sit on
 and now sit on the wrapper that script created.

A Shiki transformer emits the whole shell instead, including the copy button.
The copy handler is delegated at the document level, so it finds a statically
rendered button by the same selector.

Shiki, and not rehype, because plugins registered through `markdown.processor`
never reach .mdx pages. rehypeWbr adds 18  elements to the kubernetes-agent
permissions page and none to kustomize.mdx, which has eight matches for it.

code-blocks.js drops from 431 lines to 327: the wrapping, the copy button
markup and the language table all go. It keeps copying, collapsing, and folding
a 
set into one block with a language menu, which merges sibling blocks and so cannot be done per-block at build time. Two tests cover the shell with scripting disabled. Co-Authored-By: Claude Opus 5 (1M context) --- astro.config.mjs | 14 +- src/plugins/shiki-code-block.js | 118 ++++++++++++ src/scripts/modules/code-blocks.js | 286 +++++++++-------------------- tests/code-block.spec.ts | 40 +++- 4 files changed, 252 insertions(+), 206 deletions(-) create mode 100644 src/plugins/shiki-code-block.js diff --git a/astro.config.mjs b/astro.config.mjs index 7de765bb81..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({ @@ -36,16 +37,9 @@ export default defineConfig({ langAlias: { ocl: 'hcl' }, - transformers: [ - { - // Shiki drops the fence's meta string, which the code block - // header renders as the block's label - pre(node) { - const label = this.options.meta?.__raw?.trim(); - if (label) node.properties['data-label'] = label; - } - } - ] + // A transformer, because rehype plugins registered through + // `processor` below never reach .mdx pages + transformers: [shikiCodeBlock()] }, processor: unified({ remarkPlugins: [ diff --git a/src/plugins/shiki-code-block.js b/src/plugins/shiki-code-block.js new file mode 100644 index 0000000000..bf98eafc68 --- /dev/null +++ b/src/plugins/shiki-code-block.js @@ -0,0 +1,118 @@ +// 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 adds the behaviour: copying, collapsing, and folding a +//
set into one block with a language menu. + +const REST = 'Copy to clipboard'; + +/** 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() ?? ''; + + // Focusable so an overflowing panel can be scrolled from the keyboard. + pre.properties = { ...pre.properties, tabindex: '0' }; + + 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'] }, []), + ]); + + node.children = [h('div', { className: ['code-block'] }, [header, body])]; + }, + }; +} diff --git a/src/scripts/modules/code-blocks.js b/src/scripts/modules/code-blocks.js index 16a02bf616..717a057958 100644 --- a/src/scripts/modules/code-blocks.js +++ b/src/scripts/modules/code-blocks.js @@ -1,6 +1,9 @@ // @ts-check import { qs, qsa } from './query.js'; +// The shell around each block is rendered at build time by +// src/plugins/shiki-code-block.js. This adds the behaviour. + const REVERT_MS = 2000; const REST = 'Copy to clipboard'; @@ -10,61 +13,12 @@ const FAILED = 'Copy failed'; /** Taller than this and the block collapses until it is clicked. */ const COLLAPSE_HEIGHT = 500; -/** - * Display names for the fence languages used across the docs. Anything missing - * falls back to the raw value with its first letter capitalised. - */ -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', -}; - /** @type {WeakMap>} */ const timers = new WeakMap(); /** @type {HTMLElement | null} */ let status = null; -/** - * @param {string} language - */ -function displayName(language) { - const key = language.trim().toLowerCase(); - return LANGUAGE_NAMES[key] ?? key.charAt(0).toUpperCase() + key.slice(1); -} - /** * @param {string} tag * @param {string} className @@ -79,19 +33,6 @@ function el(tag, className, text) { /* Copying ---------------------------------------------------------------- */ -function buildCopyButton() { - const button = document.createElement('button'); - button.type = 'button'; - button.className = 'code-block__copy btn btn--small'; - button.dataset.tooltip = REST; - button.setAttribute('aria-label', 'Copy code to clipboard'); - - // Empty: the glyph is a CSS mask on the span itself. - button.appendChild(el('span', 'code-block__copy-icon btn__icon')); - - return button; -} - /** * @param {HTMLElement} button */ @@ -154,16 +95,26 @@ function announce(message) { }, 50); } -/* Language selector ------------------------------------------------------ */ +function addCopyListener() { + document.addEventListener('click', (event) => { + if (!(event.target instanceof Element)) return; + + const button = event.target.closest('.code-block__copy'); + if (button instanceof HTMLElement) copyCode(button); + }); +} + +/* Language menu ---------------------------------------------------------- */ /** - * @param {string[]} names - * @param {(index: number) => void} onSelect + * Swaps the static language text for a menu over the block's panels. + * + * @param {HTMLElement} block + * @param {{ name: string, label: string }[]} entries */ -function buildLanguageControl(names, onSelect) { - if (names.length === 1) { - return el('span', 'code-block__language', names[0]); - } +function addLanguageMenu(block, entries) { + const panels = Array.from(qsa('.code-block__panel', block)); + const label = qs('.code-block__label', block); const menu = document.createElement('details'); menu.className = 'code-block__languages'; @@ -174,31 +125,37 @@ function buildLanguageControl(names, onSelect) { const caret = el('i', 'fa-solid fa-caret-down btn__icon'); caret.setAttribute('aria-hidden', 'true'); - const triggerLabel = el('span', 'btn__label', names[0]); + const triggerLabel = el('span', 'btn__label', entries[0].name); trigger.append(triggerLabel, caret); // The visible text alone would name the control "PowerShell", which says // nothing about it being a control. const nameTrigger = (name) => trigger.setAttribute('aria-label', `Language: ${name}. Change language`); - nameTrigger(names[0]); + + const select = (index) => { + panels.forEach((panel, i) => (panel.hidden = i !== index)); + label.textContent = entries[index].label; + label.hidden = !entries[index].label; + measure(block); + }; const options = el('ul', 'code-block__language-options'); - names.forEach((name, index) => { + entries.forEach((entry, index) => { const option = document.createElement('button'); option.type = 'button'; option.className = 'code-block__language-option'; - option.textContent = name; + option.textContent = entry.name; option.setAttribute('aria-pressed', index === 0 ? 'true' : 'false'); option.addEventListener('click', () => { - triggerLabel.textContent = name; - nameTrigger(name); + triggerLabel.textContent = entry.name; + nameTrigger(entry.name); qsa('.code-block__language-option', options).forEach((other) => other.setAttribute('aria-pressed', String(other === option)) ); menu.open = false; - onSelect(index); + select(index); }); const item = document.createElement('li'); @@ -209,7 +166,9 @@ function buildLanguageControl(names, onSelect) { menu.append(trigger, options); addMenuListeners(menu, trigger); - return menu; + nameTrigger(entries[0].name); + qs('.code-block__language', block).replaceWith(menu); + select(0); } /** @@ -231,6 +190,61 @@ function addMenuListeners(menu, trigger) { }); } +/** + * 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()); + + addLanguageMenu(host, entries); + }); +} + /* Collapsing ------------------------------------------------------------- */ /** @@ -282,123 +296,6 @@ function addCollapseListeners() { }); } -/* Assembly --------------------------------------------------------------- */ - -/** - * @param {HTMLElement[]} pres - * @param {string[]} names - * @param {HTMLElement} anchor element the finished block takes the place of - */ -function buildBlock(pres, names, anchor) { - // For a plain block the anchor is the
 itself, which the panels below
-  // move out of the page. An element cannot be replaced by its own descendant,
-  // so the place is claimed before any of that happens.
-  const marker = document.createComment('code-block');
-  anchor.replaceWith(marker);
-
-  const block = el('div', 'code-block');
-  const header = el('div', 'code-block__header');
-  const body = el('div', 'code-block__body');
-
-  const label = el('p', 'code-block__label');
-  const setLabel = (index) => {
-    const text = pres[index].dataset.label ?? '';
-    label.textContent = text;
-    label.hidden = !text;
-  };
-  setLabel(0);
-  header.appendChild(label);
-
-  const panels = pres.map((pre, index) => {
-    const panel = el('div', 'code-block__panel');
-    panel.hidden = index !== 0;
-    // Focusable so an overflowing panel can be scrolled from the keyboard.
-    pre.setAttribute('tabindex', '0');
-    panel.appendChild(pre);
-    return panel;
-  });
-  body.append(...panels, el('div', 'code-block__fade'));
-
-  const actions = el('div', 'code-block__actions');
-  actions.append(
-    buildLanguageControl(names, (index) => {
-      panels.forEach((panel, i) => (panel.hidden = i !== index));
-      setLabel(index);
-      measure(block);
-    }),
-    buildCopyButton()
-  );
-  header.appendChild(actions);
-
-  block.append(header, body);
-  marker.replaceWith(block);
-
-  return block;
-}
-
-/**
- * A group qualifies for the language menu 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 panels = participants.map((details) => {
-      const summary = details.querySelector('summary');
-      const children = Array.from(details.children).filter(
-        (child) => child !== summary
-      );
-      const pre = children[0];
-      const isLoneCodeBlock =
-        children.length === 1 &&
-        pre instanceof HTMLElement &&
-        pre.tagName === 'PRE';
-
-      return isLoneCodeBlock && summary
-        ? { name: summary.textContent?.trim() ?? '', pre }
-        : null;
-    });
-
-    if (panels.some((panel) => !panel)) return;
-
-    buildBlock(
-      panels.map((panel) => panel.pre),
-      panels.map(
-        (panel) => panel.name || displayName(panel.pre.dataset.language ?? '')
-      ),
-      participants[0]
-    );
-
-    // detail-tabs.js keys off data-group, so the consumed ones have to go.
-    participants.forEach((details) => details.remove());
-  });
-}
-
-function enhanceSingleBlocks() {
-  qsa('pre.astro-code').forEach((pre) => {
-    if (pre.closest('.code-block')) return;
-    buildBlock([pre], [displayName(pre.dataset.language ?? '')], pre);
-  });
-}
-
-function addCopyListener() {
-  document.addEventListener('click', (event) => {
-    if (!(event.target instanceof Element)) return;
-
-    const button = event.target.closest('.code-block__copy');
-    if (button instanceof HTMLElement) copyCode(button);
-  });
-}
-
 /**
  * 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.
@@ -422,7 +319,6 @@ function measureAll() {
 
 function enhanceCodeBlocks() {
   enhanceGroups();
-  enhanceSingleBlocks();
   addCopyListener();
   addCollapseListeners();
   measureAll();
diff --git a/tests/code-block.spec.ts b/tests/code-block.spec.ts
index 9c228b0ecc..87385e953e 100644
--- a/tests/code-block.spec.ts
+++ b/tests/code-block.spec.ts
@@ -11,6 +11,41 @@ const SINGLE = '/docs/kubernetes/steps/kustomize';
 // A page holding a code block far taller than the collapse threshold
 const LONG = '/docs/octopus-rest-api/octopus.server.exe-command-line/configure';
 
+// The shell is rendered at build time, so it has to survive without scripting.
+test.describe('code block, no JavaScript', () => {
+  test.use({ javaScriptEnabled: false });
+
+  test('renders the frame, label, language and copy button', async ({
+    page,
+  }) => {
+    await page.goto(SINGLE);
+
+    const block = page.locator('.code-block').first();
+    await expect(block).toBeVisible();
+    await expect(block.locator('.code-block__label')).toHaveText(
+      'Reference a container image package by version'
+    );
+    await expect(block.locator('.code-block__language')).toHaveText('YAML');
+    await expect(block.locator('.code-block__copy')).toBeVisible();
+
+    // The frame, rather than bare text on the page background
+    const border = await block.evaluate(
+      (node) => getComputedStyle(node).borderTopWidth
+    );
+    expect(border).toBe('1px');
+  });
+
+  test('leaves a grouped block readable with every language shown', async ({
+    page,
+  }) => {
+    await page.goto(GROUPED);
+
+    // No menu to switch with, so each language keeps its own block
+    await expect(page.locator('.code-block')).toHaveCount(2);
+    await expect(page.locator('.code-block__languages')).toHaveCount(0);
+  });
+});
+
 test.describe('code block', () => {
   test('wraps a fenced block in a header with its language and a copy button', async ({
     page,
@@ -98,7 +133,10 @@ test.describe('code block', () => {
 
     // Waits out the expand transition before measuring
     await expect
-      .poll(async () => (await block.locator('.code-block__body').boundingBox())!.height)
+      .poll(
+        async () =>
+          (await block.locator('.code-block__body').boundingBox())!.height
+      )
       .toBeGreaterThan(500);
 
     // Clicking away puts it back

From 674bf0e460491f573e68c748542684be104a8739 Mon Sep 17 00:00:00 2001
From: William Laugesen 
Date: Fri, 7 Aug 2026 13:23:51 +1200
Subject: [PATCH 04/10] Share one copy-button module between the heading and
 the code block

Both had their own copy of the same fifty-five lines: the revert timer, the
tooltip swap, the live region, and the delegated click. The only thing that
differed was the string each one copies.

copy-button.js takes a selector and a function that reads the text, so a caller
is left with just that function. A button's own data-tooltip is its resting
label, which keeps "Copy URL" on the heading and "Copy to clipboard" on the
code block, and the two share one live region instead of one each.

headers.js goes from 125 lines to 52, code-blocks.js from 327 to 256.

copy-markdown.js stays as it is. It fetches the page over the network before
writing, so it needs the execCommand fallback and cannot read its text
synchronously, which is what keeps the clipboard write inside Safari's user
activation.

The heading button had no test. It has three now, covering both callers.

Co-Authored-By: Claude Opus 5 (1M context) 
---
 src/plugins/shiki-code-block.js    |   2 +-
 src/scripts/modules/code-blocks.js |  95 ++++---------------------
 src/scripts/modules/copy-button.js | 109 +++++++++++++++++++++++++++++
 src/scripts/modules/headers.js     |  82 ++--------------------
 src/styles/main.css                |   3 +-
 tests/copy-button.spec.ts          |  61 ++++++++++++++++
 6 files changed, 189 insertions(+), 163 deletions(-)
 create mode 100644 src/scripts/modules/copy-button.js
 create mode 100644 tests/copy-button.spec.ts

diff --git a/src/plugins/shiki-code-block.js b/src/plugins/shiki-code-block.js
index bf98eafc68..28772d7407 100644
--- a/src/plugins/shiki-code-block.js
+++ b/src/plugins/shiki-code-block.js
@@ -1,6 +1,6 @@
 // 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 adds the behaviour: copying, collapsing, and folding a
+// 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'; diff --git a/src/scripts/modules/code-blocks.js b/src/scripts/modules/code-blocks.js index 717a057958..a22d4a2127 100644 --- a/src/scripts/modules/code-blocks.js +++ b/src/scripts/modules/code-blocks.js @@ -1,24 +1,13 @@ // @ts-check import { qs, qsa } from './query.js'; +import { copyOnClick } from './copy-button.js'; -// The shell around each block is rendered at build time by -// src/plugins/shiki-code-block.js. This adds the behaviour. - -const REVERT_MS = 2000; - -const REST = 'Copy to clipboard'; -const COPIED = 'Copied'; -const FAILED = 'Copy failed'; +// 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. /** Taller than this and the block collapses until it is clicked. */ const COLLAPSE_HEIGHT = 500; -/** @type {WeakMap>} */ -const timers = new WeakMap(); - -/** @type {HTMLElement | null} */ -let status = null; - /** * @param {string} tag * @param {string} className @@ -31,77 +20,17 @@ function el(tag, className, text) { return node; } -/* Copying ---------------------------------------------------------------- */ - -/** - * @param {HTMLElement} button - */ -async function copyCode(button) { - const block = button.closest('.code-block'); - const code = block?.querySelector('.code-block__panel:not([hidden]) code'); - if (!code) return; - - let message = COPIED; - try { - // textContent because a collapsed block clips its last lines, and innerText - // returns only what is on screen. - // 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(code.textContent ?? ''); - } catch (error) { - console.warn('[code-blocks] 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) - ); -} - -/** - * @param {string} message - */ -function announce(message) { - if (!status) { - status = el('div', 'code-block-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); -} - -function addCopyListener() { - document.addEventListener('click', (event) => { - if (!(event.target instanceof Element)) return; - - const button = event.target.closest('.code-block__copy'); - if (button instanceof HTMLElement) copyCode(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 menu ---------------------------------------------------------- */ @@ -319,7 +248,7 @@ function measureAll() { function enhanceCodeBlocks() { enhanceGroups(); - addCopyListener(); + copyOnClick('.code-block__copy', visibleCode); addCollapseListeners(); measureAll(); } 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 e2094ed38e..fbedb15385 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -1826,8 +1826,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, -.code-block-status { +.copy-status { position: absolute; width: 1px; height: 1px; 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'); +}); From 7a0b8939723ce41e84e10a8562890ca01353128f Mon Sep 17 00:00:00 2001 From: William Laugesen Date: Fri, 7 Aug 2026 14:08:00 +1200 Subject: [PATCH 05/10] Switch the language control to a native select The menu was a
with a hand-built option list, and eighteen of its lines re-implemented Escape-to-close and click-away-to-close. A renders no pseudo-element of its own and a background image cannot follow the theme. Co-Authored-By: Claude Opus 5 (1M context) --- src/assets/icons/caret-down.svg | 4 ++ src/scripts/modules/code-blocks.js | 101 +++++++---------------------- src/styles/main.css | 72 +++++++------------- tests/code-block.spec.ts | 34 +++++++--- 4 files changed, 73 insertions(+), 138 deletions(-) create mode 100644 src/assets/icons/caret-down.svg diff --git a/src/assets/icons/caret-down.svg b/src/assets/icons/caret-down.svg new file mode 100644 index 0000000000..0f0507df88 --- /dev/null +++ b/src/assets/icons/caret-down.svg @@ -0,0 +1,4 @@ + + diff --git a/src/scripts/modules/code-blocks.js b/src/scripts/modules/code-blocks.js index a22d4a2127..ea9538a1c2 100644 --- a/src/scripts/modules/code-blocks.js +++ b/src/scripts/modules/code-blocks.js @@ -8,18 +8,6 @@ import { copyOnClick } from './copy-button.js'; /** Taller than this and the block collapses until it is clicked. */ const COLLAPSE_HEIGHT = 500; -/** - * @param {string} tag - * @param {string} className - * @param {string} [text] - */ -function el(tag, className, text) { - const node = document.createElement(tag); - node.className = className; - if (text) node.textContent = text; - return node; -} - /** * @param {HTMLElement} button */ @@ -33,90 +21,47 @@ function visibleCode(button) { return code?.textContent ?? null; } -/* Language menu ---------------------------------------------------------- */ +/* Language switcher ------------------------------------------------------ */ /** - * Swaps the static language text for a menu over the block's panels. + * A renders no pseudo-element to hang the caret on. + const switcher = document.createElement('span'); + switcher.className = 'code-block__language-switcher'; + switcher.appendChild(select); -/** - * @param {HTMLDetailsElement} menu - * @param {HTMLElement} trigger - */ -function addMenuListeners(menu, trigger) { - menu.addEventListener('keydown', (event) => { - if (!menu.open || event.key !== 'Escape') return; - event.preventDefault(); - menu.open = false; - trigger.focus(); - }); - - document.addEventListener('click', (event) => { - if (!menu.open) return; - if (event.target instanceof Node && menu.contains(event.target)) return; - menu.open = false; - }); + qs('.code-block__language', block).replaceWith(switcher); + show(); } /** @@ -170,7 +115,7 @@ function enhanceGroups() { participants[0].replaceWith(host); participants.forEach((details) => details.remove()); - addLanguageMenu(host, entries); + addLanguageSelect(host, entries); }); } diff --git a/src/styles/main.css b/src/styles/main.css index fbedb15385..80af6de8d5 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -2718,63 +2718,35 @@ a[data-youtube] { white-space: nowrap; } -/* The menu, when the block was written in several languages */ -.code-block__languages { +/* 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; } -.code-block__language-trigger { - list-style: none; +/* Qualified to outrank the `padding` shorthand `.btn` sets further down */ +.code-block .code-block__language-select { + padding-inline-end: calc(var(--space16) + var(--space8)); cursor: pointer; + appearance: none; + -webkit-appearance: none; } -/* Qualified to outrank the global rule that styles every as a link */ -.code-block .code-block__language-trigger, -.code-block .code-block__language-trigger > * { - color: var(--colorTextPrimary); - text-decoration: none; -} - -.code-block__language-trigger::-webkit-details-marker { - display: none; -} - -/* Qualified to outrank `.page-content ul`, which indents every list */ -.code-block .code-block__language-options { +/* On the wrapper, because a ; the old menu hand-rolled all of this + await select.press('ArrowDown'); + await expect(select).toHaveValue('1'); + }); + test('leaves a group holding more than code as a tab list', async ({ page, }) => { From 1ab732528f74bb05739f23937001066bb432cafb Mon Sep 17 00:00:00 2001 From: William Laugesen Date: Fri, 7 Aug 2026 14:20:06 +1200 Subject: [PATCH 06/10] Use the caret the rest of the site already uses The switcher had its own caret-down.svg. The Button component and the copy markdown menu both draw theirs from the FontAwesome glyph, so this does too and the asset goes. Rebasing also turned up a conflict git could not see. Main now sets `margin-block` on `.page-content :is(pre, figure)`, and every
 sits inside
a code block, so the code was pushed away from its own header. The rule points
at .code-block instead, which is the element that wanted the spacing, and the
block drops the 1rem it was setting for itself.

Co-Authored-By: Claude Opus 5 (1M context) 
---
 src/assets/icons/caret-down.svg |  4 ----
 src/styles/main.css             | 21 +++++++++++----------
 2 files changed, 11 insertions(+), 14 deletions(-)
 delete mode 100644 src/assets/icons/caret-down.svg

diff --git a/src/assets/icons/caret-down.svg b/src/assets/icons/caret-down.svg
deleted file mode 100644
index 0f0507df88..0000000000
--- a/src/assets/icons/caret-down.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
diff --git a/src/styles/main.css b/src/styles/main.css
index 80af6de8d5..36e0e99173 100644
--- a/src/styles/main.css
+++ b/src/styles/main.css
@@ -337,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);
 }
 
@@ -2651,9 +2653,9 @@ a[data-youtube] {
   font-family: fa-solid;
 }
 
-/* Code block, built around every fenced block by code-blocks.js */
+/* Code block, built around every fenced block by code-blocks.js. Spacing comes
+   from the .page-content rule it shares with figures. */
 .code-block {
-  margin-block: 1rem;
   border: var(--borderWidth1) solid var(--colorBorderPrimary);
   border-radius: var(--borderRadiusMedium);
   background: var(--colorBackgroundPrimaryDefault);
@@ -2735,17 +2737,16 @@ a[data-youtube] {
 }
 
 /* On the wrapper, because a