diff --git a/.github/actions/keep-alive/__tests__/test_main.py b/.github/actions/keep-alive/__tests__/test_main.py index 2bc48ac68..a1fc504fd 100644 --- a/.github/actions/keep-alive/__tests__/test_main.py +++ b/.github/actions/keep-alive/__tests__/test_main.py @@ -123,5 +123,32 @@ def fake_create_client_sync(**kwargs): module.run() - assert captured["url"] == "libsql://input.turso.io" + assert captured["url"] == "libsql://input.turso.io/" assert captured["auth_token"] == "input_token" + + +def test_normalizes_libsql_url_to_include_trailing_slash(monkeypatch: pytest.MonkeyPatch) -> None: + module = load_action_module() + + monkeypatch.setenv("ASTRO_DB_REMOTE_URL", "libsql://example.turso.io") + monkeypatch.setenv("ASTRO_DB_APP_TOKEN", "token") + monkeypatch.setattr(module.core, "get_input", lambda name, required=False: "") + + captured: dict[str, str] = {} + + class MockClient: + def execute(self, sql: str) -> None: + pass + + def close(self) -> None: + pass + + def fake_create_client_sync(**kwargs): + captured.update({"url": kwargs.get("url"), "auth_token": kwargs.get("auth_token")}) + return MockClient() + + monkeypatch.setattr(module, "create_client_sync", fake_create_client_sync) + + module.run() + + assert captured["url"] == "libsql://example.turso.io/" diff --git a/.github/actions/keep-alive/src/main.py b/.github/actions/keep-alive/src/main.py index 9f0893e34..a7e812b7b 100644 --- a/.github/actions/keep-alive/src/main.py +++ b/.github/actions/keep-alive/src/main.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import urllib.parse from actions_toolkit import core from libsql_client import create_client_sync @@ -20,12 +21,25 @@ def get_required_value(value: str, label: str) -> str: return trimmed +def normalize_libsql_url(url: str) -> str: + parsed = urllib.parse.urlparse(url) + + # libsql-client transforms libsql:// URLs into ws(s):// for Hrana WebSockets. + # When the path is empty, some servers reject the handshake for `wss://host` + # (no trailing slash). Normalize to `.../`. + if parsed.scheme == "libsql" and parsed.netloc and parsed.path == "": + parsed = parsed._replace(path="/") + return urllib.parse.urlunparse(parsed) + + return url + + def run() -> None: try: url = get_optional_input_or_env("astro-db-remote-url", "ASTRO_DB_REMOTE_URL") auth_token = get_optional_input_or_env("astro-db-app-token", "ASTRO_DB_APP_TOKEN") - required_url = get_required_value(url, "ASTRO_DB_REMOTE_URL") + required_url = normalize_libsql_url(get_required_value(url, "ASTRO_DB_REMOTE_URL")) required_auth_token = get_required_value(auth_token, "ASTRO_DB_APP_TOKEN") client = create_client_sync(url=required_url, auth_token=required_auth_token) diff --git a/@types/svg.d.ts b/@types/svg.d.ts index 08d7b822c..6c9805a87 100644 --- a/@types/svg.d.ts +++ b/@types/svg.d.ts @@ -2,3 +2,8 @@ declare module '*.svg' { const content: unknown export default content } + +declare module '*.svg?raw' { + const content: string + export default content +} diff --git a/_TODO.md b/_TODO.md index 9e177fc6f..d0fa8e8d0 100644 --- a/_TODO.md +++ b/_TODO.md @@ -107,14 +107,9 @@ cat.structure: Rules related to the document's overall structure, like the prope cat.tables: Rules for data tables, including headers and associations. cat.text-alternatives: Rules for ensuring that text alternatives are provided for non-text content, such as images. -## Set up webmentions - -Needs to add real API key and test +## Performance -- Get API token from webmention.io -- Add WEBMENTION_IO_TOKEN to .env -- (Optional) Set up Bridgy for social media -- Test with sample webmentions +Implement mitigations in test/e2e/specs/07-performance/PERFORMANCE.md ## Prefetch Links @@ -149,13 +144,9 @@ You can then opt-out of prefetching for individual links by setting data-astro-p About ``` -## Mobile Social Shares UI - -See the example image in Social Shares. The social shares UI on mobile should be a modal that slides in from the bottom. - -## Performance +## Email Templates -Implement mitigations in test/e2e/specs/07-performance/PERFORMANCE.md +Right now we're using string literals to define HTML email templates for site mails. We should use Nunjucks with the rule-checking for valid CSS in HTML emails like we have in the corporate email footer repo. ## Analytics @@ -171,9 +162,23 @@ npm i @vercel/analytics import Analytics from '@vercel/analytics/astro' https://vercel.com/docs/analytics/quickstart#add-the-analytics-component-to-your-app +## Set up webmentions + +Needs to add real API key and test + +- Get API token from webmention.io +- Add WEBMENTION_IO_TOKEN to .env +- (Optional) Set up Bridgy for social media +- Test with sample webmentions + +## Mobile Social Shares UI + +See the example image in Social Shares. The social shares UI on mobile should be a modal that slides in from the bottom. + ## Themepicker tooltips, extra themes -- Add additional themes +- Add additional themes (high contrast) +- Add Carousel - Add tooltip that makes use of the description field for the theme, explaining what the intent of the theme is ## Sentry feedback, chat bot tying into my phone and email @@ -192,21 +197,9 @@ Where to upload to? Add Upstash Search as a Vercel Marketplace Integration. -Lunr is a JS search library using an inverted index. Client-side search for statically hosted pages. - -### [`@jackcarey/astro-lunr`](https://www.npmjs.com/package/@jackcarey/astro-lunr) - -### [`@siverv/astro-lunr`](https://www.npmjs.com/package/@siverv/astro-lunr) - -## Email Templates - -Right now we're using string literals to define HTML email templates for site mails. We should use Nunjucks with the rule-checking for valid CSS in HTML emails like we have in the corporate email footer repo. - -## Astro Components to Add - ### "Add to Calendar" button -Google Calendar, Apple Calendar, Yahoo Calender, Microsoft 365, Outlook, and Teams, and generate iCal/ics files (for all other calendars and cases). +Google Calendar, Apple Calendar, Microsoft Outlook and Teams, and generate iCal/ics files (for all other calendars and cases). `https://github.com/add2cal/add-to-calendar-button` `https://add-to-calendar-button.com/` diff --git a/package-lock.json b/package-lock.json index 671506f03..d08ebeafc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,8 +26,8 @@ "@playwright/browser-chromium": "^1.57.0", "@playwright/test": "1.57.0", "@semantic-ui/astro-lit": "^5.1.1", - "@sentry/astro": "^10.32.0", - "@sentry/browser": "^10.32.0", + "@sentry/astro": "^10.32.1", + "@sentry/browser": "^10.32.1", "@shikijs/transformers": "^3.20.0", "@tailwindcss/forms": "0.5.11", "@tailwindcss/typography": "0.5.19", @@ -64,17 +64,17 @@ "email-validator": "^2.0.4", "embla-carousel": "^8.6.0", "embla-carousel-autoplay": "^8.6.0", - "focus-trap": "7.6.6", + "focus-trap": "7.7.0", "gsap": "^3.14.2", "html-element-attributes": "^3.5.0", "is-whitespace-character": "^2.0.1", "isomorphic-git": "^1.36.1", "js-cookie": "^3.0.5", - "libphonenumber-js": "1.12.31", + "libphonenumber-js": "1.12.33", "lit": "^3.3.1", "md-attr-parser": "^1.3.0", "nanostores": "^1.1.0", - "nodemailer": "^7.0.11", + "nodemailer": "^7.0.12", "postcss": "8.5.6", "postcss-html": "1.8.0", "preact": "^10.28.0", @@ -109,6 +109,7 @@ "sanitize-html": "^2.17.0", "schema-dts": "^1.1.5", "sharp": "^0.34.5", + "shiki": "^3.20.0", "space-separated-tokens": "^2.0.2", "tailwindcss": "^4.1.18", "title-case": "4.3.2", @@ -6882,64 +6883,64 @@ } }, "node_modules/@sentry-internal/browser-utils": { - "version": "10.32.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.32.0.tgz", - "integrity": "sha512-LI83ZKv5ItRajfY7xmQpNs00nWNOXO+A6MCj8LNDpPouYA8m7VvqkCKG9Yh50a/5eIO9lbSWTrARO8rWR3c9jA==", + "version": "10.32.1", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.32.1.tgz", + "integrity": "sha512-sjLLep1es3rTkbtAdTtdpc/a6g7v7bK5YJiZJsUigoJ4NTiFeMI5uIDCxbH/tjJ1q23YE1LzVn7T96I+qBRjHA==", "license": "MIT", "dependencies": { - "@sentry/core": "10.32.0" + "@sentry/core": "10.32.1" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/feedback": { - "version": "10.32.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.32.0.tgz", - "integrity": "sha512-YjDdVR8Ep7lOGilRfSrioBKQlFzWP+j1ibL+9rfwYPWWeQSfK2mbg8+PmbWOcTIXZ/MpuZRMF6ZdkVi7VYBSJw==", + "version": "10.32.1", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.32.1.tgz", + "integrity": "sha512-O24G8jxbfBY1RE/v2qFikPJISVMOrd/zk8FKyl+oUVYdOxU2Ucjk2cR3EQruBFlc7irnL6rT3GPfRZ/kBgLkmQ==", "license": "MIT", "dependencies": { - "@sentry/core": "10.32.0" + "@sentry/core": "10.32.1" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/replay": { - "version": "10.32.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.32.0.tgz", - "integrity": "sha512-P6paw7bLDP72ZNJkcyKSXCXfd+/NKwRfnJpwgkRI9kjy9o0KZrIW2P/xTGftp5q1XxQ7tCt94j2gc1HelOpngw==", + "version": "10.32.1", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.32.1.tgz", + "integrity": "sha512-KKmLUgIaLRM0VjrMA1ByQTawZyRDYSkG2evvEOVpEtR9F0sumidAQdi7UY71QEKE1RYe/Jcp/3WoaqsMh8tbnQ==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "10.32.0", - "@sentry/core": "10.32.0" + "@sentry-internal/browser-utils": "10.32.1", + "@sentry/core": "10.32.1" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/replay-canvas": { - "version": "10.32.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.32.0.tgz", - "integrity": "sha512-GdhyRKywIP9IQc7RoTrBFjx2EFBjiRSndxeiW43qybO1xD2S5Cq/S7ZwkaJOXN8Ie1awm3/E6+mVct5jc6KKAg==", + "version": "10.32.1", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.32.1.tgz", + "integrity": "sha512-/XGTzWNWVc+B691fIVekV2KeoHFEDA5KftrLFAhEAW7uWOwk/xy3aQX4TYM0LcPm2PBKvoumlAD+Sd/aXk63oA==", "license": "MIT", "dependencies": { - "@sentry-internal/replay": "10.32.0", - "@sentry/core": "10.32.0" + "@sentry-internal/replay": "10.32.1", + "@sentry/core": "10.32.1" }, "engines": { "node": ">=18" } }, "node_modules/@sentry/astro": { - "version": "10.32.0", - "resolved": "https://registry.npmjs.org/@sentry/astro/-/astro-10.32.0.tgz", - "integrity": "sha512-MSLPr0YzTkLHdEn+Ox2Vjr5cKDjAWHb/k881Hjf5LM3HMw5UZ+qz9FD6UE6TDA0MY28KItB3iLfzTivaN5e9qA==", + "version": "10.32.1", + "resolved": "https://registry.npmjs.org/@sentry/astro/-/astro-10.32.1.tgz", + "integrity": "sha512-/fUn27a+DAlqMjhRhdUcGvpE3H5q17JOLW42XCi3gOhtnE7zVqd3JJ1TbA4Q3G6BlaSAIsX1xqNOL9HGXhuLYQ==", "license": "MIT", "dependencies": { - "@sentry/browser": "10.32.0", - "@sentry/core": "10.32.0", - "@sentry/node": "10.32.0", + "@sentry/browser": "10.32.1", + "@sentry/core": "10.32.1", + "@sentry/node": "10.32.1", "@sentry/vite-plugin": "^4.1.0" }, "engines": { @@ -6959,16 +6960,16 @@ } }, "node_modules/@sentry/browser": { - "version": "10.32.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.32.0.tgz", - "integrity": "sha512-NtQlXQybrWMbUOPENS4bvxzhMX1Oi+IUH9NXA/mZ06KbQntPLES7yfIKhLNpRipuCzeS16hCJp6HMao2bZB2Hg==", + "version": "10.32.1", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.32.1.tgz", + "integrity": "sha512-NPNCXTZ05ZGTFyJdKNqjykpFm+urem0ebosILQiw3C4BxNVNGH4vfYZexyl6prRhmg91oB6GjVNiVDuJiap1gg==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "10.32.0", - "@sentry-internal/feedback": "10.32.0", - "@sentry-internal/replay": "10.32.0", - "@sentry-internal/replay-canvas": "10.32.0", - "@sentry/core": "10.32.0" + "@sentry-internal/browser-utils": "10.32.1", + "@sentry-internal/feedback": "10.32.1", + "@sentry-internal/replay": "10.32.1", + "@sentry-internal/replay-canvas": "10.32.1", + "@sentry/core": "10.32.1" }, "engines": { "node": ">=18" @@ -7171,18 +7172,18 @@ } }, "node_modules/@sentry/core": { - "version": "10.32.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.32.0.tgz", - "integrity": "sha512-E+ihb8+5PBfYMamnXHalgsmxkcG2YQqhRdgYf3yWJ5dJvi4njh1VWK3kNVj1GvsU6ktaielAx4Rg5dwEFMnbZg==", + "version": "10.32.1", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.32.1.tgz", + "integrity": "sha512-PH2ldpSJlhqsMj2vCTyU0BI2Fx1oIDhm7Izo5xFALvjVCS0gmlqHt1udu6YlKn8BtpGH6bGzssvv5APrk+OdPQ==", "license": "MIT", "engines": { "node": ">=18" } }, "node_modules/@sentry/node": { - "version": "10.32.0", - "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.32.0.tgz", - "integrity": "sha512-KENGLH34gUlrNd9QVJFp37w64DZmorWarm67sFJ2J+VmBII0JMkbIJy1SdHyHxGtgitbokotMTjjf9isVnWwlw==", + "version": "10.32.1", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.32.1.tgz", + "integrity": "sha512-oxlybzt8QW0lx/QaEj1DcvZDRXkgouewFelu/10dyUwv5So3YvipfvWInda+yMLmn25OggbloDQ0gyScA2jU3g==", "license": "MIT", "dependencies": { "@opentelemetry/api": "^1.9.0", @@ -7215,9 +7216,9 @@ "@opentelemetry/sdk-trace-base": "^2.2.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@prisma/instrumentation": "6.19.0", - "@sentry/core": "10.32.0", - "@sentry/node-core": "10.32.0", - "@sentry/opentelemetry": "10.32.0", + "@sentry/core": "10.32.1", + "@sentry/node-core": "10.32.1", + "@sentry/opentelemetry": "10.32.1", "import-in-the-middle": "^2", "minimatch": "^9.0.0" }, @@ -7226,14 +7227,14 @@ } }, "node_modules/@sentry/node-core": { - "version": "10.32.0", - "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.32.0.tgz", - "integrity": "sha512-O+TVuF1fO0j37W6IzdHCpTIr4uUkFzcSKgxNmH9ihYpRzkQgfLDZJWVxtov+H8/1pC5lkvl2VZhWmY+SWj2kHA==", + "version": "10.32.1", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.32.1.tgz", + "integrity": "sha512-w56rxdBanBKc832zuwnE+zNzUQ19fPxfHEtOhK8JGPu3aSwQYcIxwz9z52lOx3HN7k/8Fj5694qlT3x/PokhRw==", "license": "MIT", "dependencies": { "@apm-js-collab/tracing-hooks": "^0.3.1", - "@sentry/core": "10.32.0", - "@sentry/opentelemetry": "10.32.0", + "@sentry/core": "10.32.1", + "@sentry/opentelemetry": "10.32.1", "import-in-the-middle": "^2" }, "engines": { @@ -7250,12 +7251,12 @@ } }, "node_modules/@sentry/opentelemetry": { - "version": "10.32.0", - "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.32.0.tgz", - "integrity": "sha512-owGL94JAgbwxgaeUNLktJWMShZPo04ZKTaQhhLz3YmVDJFj8VFOQXdWBMqv1Gv6T6/fCuTlwzJ3rvpSOImxXUQ==", + "version": "10.32.1", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.32.1.tgz", + "integrity": "sha512-YLssSz5Y+qPvufrh2cDaTXDoXU8aceOhB+YTjT8/DLF6SOj7Tzen52aAcjNaifawaxEsLCC8O+B+A2iA+BllvA==", "license": "MIT", "dependencies": { - "@sentry/core": "10.32.0" + "@sentry/core": "10.32.1" }, "engines": { "node": ">=18" @@ -7282,54 +7283,54 @@ } }, "node_modules/@shikijs/core": { - "version": "3.19.0", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.19.0.tgz", - "integrity": "sha512-L7SrRibU7ZoYi1/TrZsJOFAnnHyLTE1SwHG1yNWjZIVCqjOEmCSuK2ZO9thnRbJG6TOkPp+Z963JmpCNw5nzvA==", + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.20.0.tgz", + "integrity": "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.19.0", + "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "node_modules/@shikijs/engine-javascript": { - "version": "3.19.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.19.0.tgz", - "integrity": "sha512-ZfWJNm2VMhKkQIKT9qXbs76RRcT0SF/CAvEz0+RkpUDAoDaCx0uFdCGzSRiD9gSlhm6AHkjdieOBJMaO2eC1rQ==", + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.20.0.tgz", + "integrity": "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.19.0", + "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "node_modules/@shikijs/engine-oniguruma": { - "version": "3.19.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.19.0.tgz", - "integrity": "sha512-1hRxtYIJfJSZeM5ivbUXv9hcJP3PWRo5prG/V2sWwiubUKTa+7P62d2qxCW8jiVFX4pgRHhnHNp+qeR7Xl+6kg==", + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.20.0.tgz", + "integrity": "sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.19.0", + "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "node_modules/@shikijs/langs": { - "version": "3.19.0", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.19.0.tgz", - "integrity": "sha512-dBMFzzg1QiXqCVQ5ONc0z2ebyoi5BKz+MtfByLm0o5/nbUu3Iz8uaTCa5uzGiscQKm7lVShfZHU1+OG3t5hgwg==", + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.20.0.tgz", + "integrity": "sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.19.0" + "@shikijs/types": "3.20.0" } }, "node_modules/@shikijs/themes": { - "version": "3.19.0", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.19.0.tgz", - "integrity": "sha512-H36qw+oh91Y0s6OlFfdSuQ0Ld+5CgB/VE6gNPK+Hk4VRbVG/XQgkjnt4KzfnnoO6tZPtKJKHPjwebOCfjd6F8A==", + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.20.0.tgz", + "integrity": "sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.19.0" + "@shikijs/types": "3.20.0" } }, "node_modules/@shikijs/transformers": { @@ -7342,19 +7343,7 @@ "@shikijs/types": "3.20.0" } }, - "node_modules/@shikijs/transformers/node_modules/@shikijs/core": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.20.0.tgz", - "integrity": "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.20.0", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4", - "hast-util-to-html": "^9.0.5" - } - }, - "node_modules/@shikijs/transformers/node_modules/@shikijs/types": { + "node_modules/@shikijs/types": { "version": "3.20.0", "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.20.0.tgz", "integrity": "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw==", @@ -7364,16 +7353,6 @@ "@types/hast": "^3.0.4" } }, - "node_modules/@shikijs/types": { - "version": "3.19.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.19.0.tgz", - "integrity": "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ==", - "license": "MIT", - "dependencies": { - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - } - }, "node_modules/@shikijs/vscode-textmate": { "version": "10.0.2", "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", @@ -19755,9 +19734,9 @@ } }, "node_modules/focus-trap": { - "version": "7.6.6", - "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.6.tgz", - "integrity": "sha512-v/Z8bvMCajtx4mEXmOo7QEsIzlIOqRXTIwgUfsFOF9gEsespdbD0AkPIka1bSXZ8Y8oZ+2IVDQZePkTfEHZl7Q==", + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.7.0.tgz", + "integrity": "sha512-DJJDHpEgoSbP8ZE1MNeU2IzCpfFyFdNZZRilqmfH2XiQsPK6PtD8AfJqWzEBudUQB2yHwZc5iq54rjTaGQ+ljw==", "license": "MIT", "dependencies": { "tabbable": "^6.3.0" @@ -21640,9 +21619,9 @@ } }, "node_modules/import-in-the-middle": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.0.tgz", - "integrity": "sha512-yNZhyQYqXpkT0AKq3F3KLasUSK4fHvebNH5hOsKQw2dhGSALvQ4U0BqUc5suziKvydO5u5hgN2hy1RJaho8U5A==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.1.tgz", + "integrity": "sha512-bruMpJ7xz+9jwGzrwEhWgvRrlKRYCRDBrfU+ur3FcasYXLJDxTruJ//8g2Noj+QFyRBeqbpj8Bhn4Fbw6HjvhA==", "license": "Apache-2.0", "dependencies": { "acorn": "^8.14.0", @@ -23191,9 +23170,9 @@ } }, "node_modules/libphonenumber-js": { - "version": "1.12.31", - "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.31.tgz", - "integrity": "sha512-Z3IhgVgrqO1S5xPYM3K5XwbkDasU67/Vys4heW+lfSBALcUZjeIIzI8zCLifY+OCzSq+fpDdywMDa7z+4srJPQ==", + "version": "1.12.33", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.33.tgz", + "integrity": "sha512-r9kw4OA6oDO4dPXkOrXTkArQAafIKAU71hChInV4FxZ69dxCfbwQGDPzqR5/vea94wU705/3AZroEbSoeVWrQw==", "license": "MIT" }, "node_modules/libsql": { @@ -26958,9 +26937,9 @@ "license": "MIT" }, "node_modules/nodemailer": { - "version": "7.0.11", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz", - "integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==", + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.12.tgz", + "integrity": "sha512-H+rnK5bX2Pi/6ms3sN4/jRQvYSMltV6vqup/0SFOrxYYY/qoNvhXPlYq3e+Pm9RFJRwrMGbMIwi81M4dxpomhA==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -32174,17 +32153,17 @@ } }, "node_modules/shiki": { - "version": "3.19.0", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.19.0.tgz", - "integrity": "sha512-77VJr3OR/VUZzPiStyRhADmO2jApMM0V2b1qf0RpfWya8Zr1PeZev5AEpPGAAKWdiYUtcZGBE4F5QvJml1PvWA==", + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.20.0.tgz", + "integrity": "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg==", "license": "MIT", "dependencies": { - "@shikijs/core": "3.19.0", - "@shikijs/engine-javascript": "3.19.0", - "@shikijs/engine-oniguruma": "3.19.0", - "@shikijs/langs": "3.19.0", - "@shikijs/themes": "3.19.0", - "@shikijs/types": "3.19.0", + "@shikijs/core": "3.20.0", + "@shikijs/engine-javascript": "3.20.0", + "@shikijs/engine-oniguruma": "3.20.0", + "@shikijs/langs": "3.20.0", + "@shikijs/themes": "3.20.0", + "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } diff --git a/package.json b/package.json index 6d2e95f1f..9172ec1a4 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "containers:logs": "FORCE_COLOR=1 docker compose --env-file test/containers/.env -f test/containers/docker-compose.e2e.yml logs -f", "dev": "npm run sync && cross-env NODE_ENV=development FORCE_COLOR=1 npx astro dev", "dev:env": "FORCE_COLOR=1 dotenv -e .env.development -- npm run dev", + "dev:env:verbose": "FORCE_COLOR=1 dotenv -e .env.development -- npm run dev -- --verbose", "format": "npm run format:code && npm run format:style", "format:code": "FORCE_COLOR=1 npx prettier --write \"@types/**/*.{js,ts}\" \"src/**/*.{js,ts,tsx,astro}\" --plugin=prettier-plugin-astro", "format:style": "FORCE_COLOR=1 npx stylelint --fix \"src/**/*.{css,astro}\"", @@ -75,8 +76,8 @@ "@playwright/browser-chromium": "^1.57.0", "@playwright/test": "1.57.0", "@semantic-ui/astro-lit": "^5.1.1", - "@sentry/astro": "^10.32.0", - "@sentry/browser": "^10.32.0", + "@sentry/astro": "^10.32.1", + "@sentry/browser": "^10.32.1", "@shikijs/transformers": "^3.20.0", "@tailwindcss/forms": "0.5.11", "@tailwindcss/typography": "0.5.19", @@ -113,17 +114,17 @@ "email-validator": "^2.0.4", "embla-carousel": "^8.6.0", "embla-carousel-autoplay": "^8.6.0", - "focus-trap": "7.6.6", + "focus-trap": "7.7.0", "gsap": "^3.14.2", "html-element-attributes": "^3.5.0", "is-whitespace-character": "^2.0.1", "isomorphic-git": "^1.36.1", "js-cookie": "^3.0.5", - "libphonenumber-js": "1.12.31", + "libphonenumber-js": "1.12.33", "lit": "^3.3.1", "md-attr-parser": "^1.3.0", "nanostores": "^1.1.0", - "nodemailer": "^7.0.11", + "nodemailer": "^7.0.12", "postcss": "8.5.6", "postcss-html": "1.8.0", "preact": "^10.28.0", @@ -158,6 +159,7 @@ "sanitize-html": "^2.17.0", "schema-dts": "^1.1.5", "sharp": "^0.34.5", + "shiki": "^3.20.0", "space-separated-tokens": "^2.0.2", "tailwindcss": "^4.1.18", "title-case": "4.3.2", diff --git a/src/components/Animations/Computers/client/__tests__/index.spec.ts b/src/components/Animations/Computers/client/__tests__/index.spec.ts index 1c91f57d2..9fb4aa889 100644 --- a/src/components/Animations/Computers/client/__tests__/index.spec.ts +++ b/src/components/Animations/Computers/client/__tests__/index.spec.ts @@ -259,7 +259,7 @@ describe('ComputersAnimationElement', () => { }) }) - it('pauses when the element scrolls out of the viewport and resumes when it returns', async () => { + it('pauses when the element is not fully visible and resumes when it returns', async () => { await renderComputersAnimation(async ({ element, window }) => { void window element.initialize() @@ -271,8 +271,8 @@ describe('ComputersAnimationElement', () => { intersectionObserverCallback?.( [ { - isIntersecting: false, - intersectionRatio: 0, + isIntersecting: true, + intersectionRatio: 0.5, } as unknown as IntersectionObserverEntry, ], {} as IntersectionObserver, diff --git a/src/components/Animations/Computers/client/index.ts b/src/components/Animations/Computers/client/index.ts index 8f768e5d5..ef400bbe9 100644 --- a/src/components/Animations/Computers/client/index.ts +++ b/src/components/Animations/Computers/client/index.ts @@ -171,10 +171,12 @@ export class ComputersAnimationElement extends LitElement { (entries) => { const entry = entries.at(0) const ratio = entry?.intersectionRatio ?? 0 - this.isInViewport = Boolean(entry?.isIntersecting && ratio > 0) + // Pause as soon as the element is even partially out of view. + // Only consider it "in viewport" when it is fully visible. + this.isInViewport = Boolean(entry?.isIntersecting && ratio >= 0.999) this.syncPlaybackWithVisibility() }, - { threshold: 0.01 }, + { threshold: [0, 0.999] }, ) this.intersectionObserver.observe(this) @@ -375,9 +377,9 @@ export class ComputersAnimationElement extends LitElement { this.timeline = gsap .timeline({ - defaults: { duration: 1 }, + defaults: { duration: 1, immediateRender: false }, delay: 1, - paused: false, + paused: true, repeat: -1, yoyo: false, }) diff --git a/src/components/Carousel/client/__tests__/index.spec.ts b/src/components/Carousel/client/__tests__/index.spec.ts index b480d8c77..ee56eb05b 100644 --- a/src/components/Carousel/client/__tests__/index.spec.ts +++ b/src/components/Carousel/client/__tests__/index.spec.ts @@ -1,5 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { experimental_AstroContainer as AstroContainer } from 'astro/container' import CarouselComponent from '@components/Carousel/index.astro' import type { CarouselProps } from '@components/Carousel/@types' @@ -41,6 +41,21 @@ const setMockScrollSnaps = (snapCount: number) => { mockScrollSnaps = Array.from({ length: count }, (_, index) => index) } +let intersectionObserverCallback: IntersectionObserverCallback | undefined + +const originalIntersectionObserver = ( + globalThis as unknown as { IntersectionObserver?: typeof IntersectionObserver } +).IntersectionObserver + +class IntersectionObserverMock { + observe = vi.fn() + disconnect = vi.fn() + + constructor(callback: IntersectionObserverCallback) { + intersectionObserverCallback = callback + } +} + vi.mock('@components/scripts/store', () => ({ createAnimationController: createAnimationControllerMock, })) @@ -139,6 +154,18 @@ describe('Carousel component (server output)', () => { autoplayPluginInstances.length = 0 createAutoplayPluginMock.mockClear() setMockScrollSnaps(3) + intersectionObserverCallback = undefined + ;(globalThis as unknown as { IntersectionObserver?: typeof IntersectionObserver }).IntersectionObserver = + IntersectionObserverMock as unknown as typeof IntersectionObserver + }) + + afterEach(() => { + const globalIntersection = globalThis as unknown as { IntersectionObserver?: typeof IntersectionObserver } + if (originalIntersectionObserver) { + globalIntersection.IntersectionObserver = originalIntersectionObserver + return + } + delete globalIntersection.IntersectionObserver }) it('renders the provided title and respects the requested limit', async () => { @@ -231,6 +258,44 @@ describe('Carousel component (server output)', () => { } }) + it('pauses autoplay when partially out of the viewport and resumes when fully visible again', async () => { + vi.useFakeTimers() + try { + await renderCarousel(async () => { + const pluginInstance = autoplayPluginInstances.at(-1) + expect(pluginInstance).toBeDefined() + expect(intersectionObserverCallback).toBeTypeOf('function') + + intersectionObserverCallback?.( + [ + { + isIntersecting: true, + intersectionRatio: 0.5, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + + await vi.runAllTimersAsync() + expect(pluginInstance?.play).not.toHaveBeenCalled() + + intersectionObserverCallback?.( + [ + { + isIntersecting: true, + intersectionRatio: 1, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + + expect(pluginInstance?.play).toHaveBeenCalled() + }, { currentSlug: 'article-four' }) + } finally { + vi.useRealTimers() + } + }) + it('skips autoplay wiring when Embla reports a single snap', async () => { setMockScrollSnaps(1) await renderCarousel(({ root }) => { diff --git a/src/components/Carousel/client/index.ts b/src/components/Carousel/client/index.ts index 05cc58f90..6ef0c7053 100644 --- a/src/components/Carousel/client/index.ts +++ b/src/components/Carousel/client/index.ts @@ -52,6 +52,9 @@ export class CarouselElement extends HTMLElement { private autoplayReady = false private autoplayReadyScheduled = false private autoplayReadyTimer: TimerHandle | null = null + private intersectionObserver: IntersectionObserver | undefined + private isFullyInViewport = true + private requestedAutoplayState: AnimationPlayState = 'paused' private readonly animationInstanceId: string private readonly domReadyHandler = () => { document.removeEventListener('DOMContentLoaded', this.domReadyHandler) @@ -126,6 +129,7 @@ export class CarouselElement extends HTMLElement { this.hasAutoplaySupport = supportsAutoplay this.autoplayPlugin = supportsAutoplay ? requestedAutoplay : null this.setAutoplayState('paused') + this.requestedAutoplayState = 'paused' if (this.autoplayPlugin) { const emblaWithEvents = this.emblaApi as EmblaCarouselType & { @@ -150,6 +154,7 @@ export class CarouselElement extends HTMLElement { }) if (this.hasAutoplaySupport) { this.registerAnimationLifecycle() + this.setupViewportObserver() this.scheduleAutoplayReady() } } catch (error) { @@ -163,6 +168,7 @@ export class CarouselElement extends HTMLElement { } private teardown(): void { + this.teardownViewportObserver() this.removeEventListener('keydown', this.keydownHandler) if (this.emblaApi) { const emblaWithEvents = this.emblaApi as EmblaCarouselType & { @@ -347,7 +353,8 @@ export class CarouselElement extends HTMLElement { pause(): void { try { - this.updateAutoplayState('paused') + this.requestedAutoplayState = 'paused' + this.syncAutoplayWithViewport() } catch (error) { handleScriptError(error, { scriptName: SCRIPT_NAME, operation: 'pause' }) } @@ -355,12 +362,56 @@ export class CarouselElement extends HTMLElement { resume(): void { try { - this.updateAutoplayState('playing') + this.requestedAutoplayState = 'playing' + this.syncAutoplayWithViewport() } catch (error) { handleScriptError(error, { scriptName: SCRIPT_NAME, operation: 'resume' }) } } + private setupViewportObserver(): void { + if (typeof document === 'undefined') return + if (this.intersectionObserver) return + + try { + const IntersectionObserverCtor = ( + globalThis as unknown as { IntersectionObserver?: typeof IntersectionObserver } + ).IntersectionObserver + + if (typeof IntersectionObserverCtor !== 'function') return + + this.intersectionObserver = new IntersectionObserverCtor( + (entries) => { + const entry = entries.at(0) + const ratio = entry?.intersectionRatio ?? 0 + this.isFullyInViewport = Boolean(entry?.isIntersecting && ratio >= 0.999) + this.syncAutoplayWithViewport() + }, + { threshold: [0, 0.999] }, + ) + + const observedTarget = this.emblaRoot ?? this + this.intersectionObserver.observe(observedTarget) + } catch { + // Best effort only + } + } + + private teardownViewportObserver(): void { + try { + this.intersectionObserver?.disconnect() + this.intersectionObserver = undefined + this.isFullyInViewport = true + } catch { + // Best effort only + } + } + + private syncAutoplayWithViewport(): void { + const shouldPlay = this.requestedAutoplayState === 'playing' && this.isFullyInViewport + this.updateAutoplayState(shouldPlay ? 'playing' : 'paused') + } + private setAutoplayState(state: 'playing' | 'paused'): void { this.setAttribute('data-carousel-autoplay', state) } diff --git a/src/components/Code/CodeBlock/client/index.ts b/src/components/Code/CodeBlock/client/index.ts new file mode 100644 index 000000000..2112fec78 --- /dev/null +++ b/src/components/Code/CodeBlock/client/index.ts @@ -0,0 +1,11 @@ +export function registerCodeBlockWebComponent(): void { + if (customElements.get('code-block')) return + customElements.define('code-block', CodeBlockElement) +} + +class CodeBlockElement extends HTMLElement { + connectedCallback(): void { + if (this.dataset['enhanced'] === 'true') return + this.dataset['enhanced'] = 'true' + } +} diff --git a/src/components/Code/CodeBlock/index.astro b/src/components/Code/CodeBlock/index.astro new file mode 100644 index 000000000..da7c55bcd --- /dev/null +++ b/src/components/Code/CodeBlock/index.astro @@ -0,0 +1,12 @@ +--- +/** + * Registers the `code-block` web component. + * + * Not wired into markdown yet; provided for manual usage. + */ +--- + + diff --git a/src/components/Code/CodeTabs/client/index.ts b/src/components/Code/CodeTabs/client/index.ts new file mode 100644 index 000000000..330ae3986 --- /dev/null +++ b/src/components/Code/CodeTabs/client/index.ts @@ -0,0 +1,163 @@ +export function registerCodeTabsWebComponent(): void { + if (customElements.get('code-tabs')) return + customElements.define('code-tabs', CodeTabsElement) +} + +import { addButtonEventListeners } from '@components/scripts/elementListeners' +import copyIcon from '../../../../icons/copy.svg?raw' +import checkIcon from '../../../../icons/check.svg?raw' + +function getIconMarkup(svgRaw: string): string { + // Ensure the icon inherits currentColor and sizing via Tailwind classes. + return svgRaw + .replace(' pre')) + + if (this.codeBlocks.length === 0) return + + const hasTabs = this.codeBlocks.length >= 2 + this.buildUi(hasTabs) + + if (hasTabs) { + this.setActive(0) + } + } + + disconnectedCallback(): void { + if (this.copyTimer !== null) { + window.clearTimeout(this.copyTimer) + this.copyTimer = null + } + } + + private buildUi(hasTabs: boolean): void { + const header = document.createElement('div') + header.className = 'flex w-full min-w-0 items-center justify-between border-b border-gray-200 bg-gray-50' + + const copyButton = this.createCopyButton() + + if (hasTabs) { + const list = document.createElement('ul') + list.className = 'flex-1 min-w-0 p-0 whitespace-nowrap overflow-auto select-none' + + this.tabButtons = this.codeBlocks.map((pre, index) => { + const tabLabel = pre.getAttribute('data-code-tabs-tab') || `Tab ${index + 1}` + + const li = document.createElement('li') + li.className = 'list-none inline-block relative' + + const button = document.createElement('button') + button.type = 'button' + button.className = 'inline-block px-2 py-1 m-2 text-gray-400 hover:text-gray-600' + button.textContent = tabLabel + button.setAttribute('data-code-tabs-button', String(index)) + + addButtonEventListeners(button, () => this.setActive(index), this) + + li.append(button) + list.append(li) + + return button + }) + + header.append(list) + } else { + const spacer = document.createElement('div') + spacer.className = 'flex-1 min-w-0' + header.append(spacer) + this.tabButtons = [] + } + + header.append(copyButton) + + this.prepend(header) + } + + private createCopyButton(): HTMLButtonElement { + const title = this.getAttribute('copy-button-title') || 'Copy' + const tooltip = this.getAttribute('copy-button-tooltip') || title + + const button = document.createElement('button') + button.type = 'button' + button.className = + 'flex shrink-0 items-center gap-2 px-3 py-2 text-gray-600 hover:text-gray-900 border-l border-gray-200' + button.setAttribute('aria-label', tooltip) + button.title = tooltip + + const copySvg = getIconMarkup(copyIcon) + const checkSvg = getIconMarkup(checkIcon) + + button.innerHTML = ` + ${copySvg} + + ${title} + ` + + addButtonEventListeners(button, () => { + void this.copyActiveCode(button) + }, this) + + return button + } + + private setActive(index: number): void { + this.activeIndex = index + + if (this.codeBlocks.length > 1) { + this.codeBlocks.forEach((pre, i) => { + pre.classList.toggle('hidden', i !== index) + }) + } + + this.tabButtons.forEach((btn, i) => { + btn.classList.toggle('text-gray-900', i === index) + btn.classList.toggle('text-gray-400', i !== index) + }) + } + + private async copyActiveCode(button: HTMLButtonElement): Promise { + const active = this.codeBlocks[this.activeIndex] + if (!active) return + + const text = getCodeText(active) + if (!text) return + + await navigator.clipboard.writeText(text) + + this.toggleCopiedState(button, true) + + if (this.copyTimer !== null) window.clearTimeout(this.copyTimer) + + this.copyTimer = window.setTimeout(() => { + this.toggleCopiedState(button, false) + this.copyTimer = null + }, 1500) + } + + private toggleCopiedState(button: HTMLButtonElement, copied: boolean): void { + const copy = button.querySelector('[data-code-tabs-copy-icon="copy"]') + const check = button.querySelector('[data-code-tabs-copy-icon="check"]') + + copy?.classList.toggle('hidden', copied) + check?.classList.toggle('hidden', !copied) + } +} diff --git a/src/components/Code/CodeTabs/index.astro b/src/components/Code/CodeTabs/index.astro new file mode 100644 index 000000000..07b608043 --- /dev/null +++ b/src/components/Code/CodeTabs/index.astro @@ -0,0 +1,12 @@ +--- +/** + * Registers the `code-tabs` web component. + * + * The actual `` markup is emitted by the markdown pipeline. + */ +--- + + diff --git a/src/components/Testimonials/client/__tests__/index.spec.ts b/src/components/Testimonials/client/__tests__/index.spec.ts index e0984fc15..f254f9b2a 100644 --- a/src/components/Testimonials/client/__tests__/index.spec.ts +++ b/src/components/Testimonials/client/__tests__/index.spec.ts @@ -1,5 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { experimental_AstroContainer as AstroContainer } from 'astro/container' import TestimonialsComponent from '@components/Testimonials/index.astro' import type { TestimonialsProps } from '@components/Testimonials/props' @@ -34,6 +34,21 @@ const createAutoplayPluginMock = vi.fn(() => { return instance }) +let intersectionObserverCallback: IntersectionObserverCallback | undefined + +const originalIntersectionObserver = ( + globalThis as unknown as { IntersectionObserver?: typeof IntersectionObserver } +).IntersectionObserver + +class IntersectionObserverMock { + observe = vi.fn() + disconnect = vi.fn() + + constructor(callback: IntersectionObserverCallback) { + intersectionObserverCallback = callback + } +} + vi.mock('@components/scripts/store', () => ({ createAnimationController: createAnimationControllerMock, })) @@ -107,6 +122,18 @@ describe('Testimonials component', () => { createAnimationControllerMock.mockClear() autoplayPluginInstances.length = 0 createAutoplayPluginMock.mockClear() + intersectionObserverCallback = undefined + ;(globalThis as unknown as { IntersectionObserver?: typeof IntersectionObserver }).IntersectionObserver = + IntersectionObserverMock as unknown as typeof IntersectionObserver + }) + + afterEach(() => { + const globalIntersection = globalThis as unknown as { IntersectionObserver?: typeof IntersectionObserver } + if (originalIntersectionObserver) { + globalIntersection.IntersectionObserver = originalIntersectionObserver + return + } + delete globalIntersection.IntersectionObserver }) it('renders the supplied title and respects the limit', async () => { @@ -195,6 +222,44 @@ describe('Testimonials component', () => { } }) + it('pauses autoplay when partially out of the viewport and resumes when fully visible again', async () => { + vi.useFakeTimers() + try { + await renderTestimonials(async () => { + const pluginInstance = autoplayPluginInstances.at(-1) + expect(pluginInstance).toBeDefined() + expect(intersectionObserverCallback).toBeTypeOf('function') + + intersectionObserverCallback?.( + [ + { + isIntersecting: true, + intersectionRatio: 0.5, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + + await vi.runAllTimersAsync() + expect(pluginInstance?.play).not.toHaveBeenCalled() + + intersectionObserverCallback?.( + [ + { + isIntersecting: true, + intersectionRatio: 1, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + + expect(pluginInstance?.play).toHaveBeenCalled() + }) + } finally { + vi.useRealTimers() + } + }) + it('toggles autoplay via the pause/play button', async () => { await renderTestimonials(async ({ root }) => { const autoplayToggle = root.querySelector('[data-testimonials-autoplay-toggle]') diff --git a/src/components/Testimonials/client/index.ts b/src/components/Testimonials/client/index.ts index 649df641d..eaac7629a 100644 --- a/src/components/Testimonials/client/index.ts +++ b/src/components/Testimonials/client/index.ts @@ -54,6 +54,9 @@ export class TestimonialsCarouselElement extends HTMLElement { private autoplayReady = false private autoplayReadyScheduled = false private autoplayReadyTimer: TimerHandle | null = null + private intersectionObserver: IntersectionObserver | undefined + private isFullyInViewport = true + private requestedAutoplayState: AnimationPlayState = 'paused' private readonly animationInstanceId: string private readonly domReadyHandler = () => { document.removeEventListener('DOMContentLoaded', this.domReadyHandler) @@ -131,6 +134,7 @@ export class TestimonialsCarouselElement extends HTMLElement { this.hasAutoplaySupport = supportsAutoplay this.autoplayPlugin = supportsAutoplay ? requestedAutoplay : null this.setAutoplayState('paused') + this.requestedAutoplayState = 'paused' if (this.autoplayPlugin) { const emblaWithEvents = this.emblaApi as EmblaCarouselType & { @@ -156,6 +160,7 @@ export class TestimonialsCarouselElement extends HTMLElement { if (this.hasAutoplaySupport) { this.registerAnimationLifecycle() + this.setupViewportObserver() this.scheduleAutoplayReady() } } catch (error) { @@ -169,6 +174,7 @@ export class TestimonialsCarouselElement extends HTMLElement { } private teardown(): void { + this.teardownViewportObserver() if (this.emblaApi) { const emblaWithEvents = this.emblaApi as EmblaCarouselType & { off: (_event: string, _handler: () => void) => EmblaCarouselType @@ -325,7 +331,8 @@ export class TestimonialsCarouselElement extends HTMLElement { pause(): void { try { - this.updateAutoplayState('paused') + this.requestedAutoplayState = 'paused' + this.syncAutoplayWithViewport() } catch (error) { handleScriptError(error, { scriptName: SCRIPT_NAME, operation: 'pause' }) } @@ -333,12 +340,56 @@ export class TestimonialsCarouselElement extends HTMLElement { resume(): void { try { - this.updateAutoplayState('playing') + this.requestedAutoplayState = 'playing' + this.syncAutoplayWithViewport() } catch (error) { handleScriptError(error, { scriptName: SCRIPT_NAME, operation: 'resume' }) } } + private setupViewportObserver(): void { + if (typeof document === 'undefined') return + if (this.intersectionObserver) return + + try { + const IntersectionObserverCtor = ( + globalThis as unknown as { IntersectionObserver?: typeof IntersectionObserver } + ).IntersectionObserver + + if (typeof IntersectionObserverCtor !== 'function') return + + this.intersectionObserver = new IntersectionObserverCtor( + (entries) => { + const entry = entries.at(0) + const ratio = entry?.intersectionRatio ?? 0 + this.isFullyInViewport = Boolean(entry?.isIntersecting && ratio >= 0.999) + this.syncAutoplayWithViewport() + }, + { threshold: [0, 0.999] }, + ) + + const observedTarget = this.emblaRoot ?? this + this.intersectionObserver.observe(observedTarget) + } catch { + // Best effort only + } + } + + private teardownViewportObserver(): void { + try { + this.intersectionObserver?.disconnect() + this.intersectionObserver = undefined + this.isFullyInViewport = true + } catch { + // Best effort only + } + } + + private syncAutoplayWithViewport(): void { + const shouldPlay = this.requestedAutoplayState === 'playing' && this.isFullyInViewport + this.updateAutoplayState(shouldPlay ? 'playing' : 'paused') + } + private setAutoplayState(state: 'playing' | 'paused'): void { this.setAttribute('data-carousel-autoplay', state) this.syncAutoplayToggleButton(state) diff --git a/src/content/articles/demo/index.mdx b/src/content/articles/demo/index.mdx index acf6aa8a9..4d436f2d5 100644 --- a/src/content/articles/demo/index.mdx +++ b/src/content/articles/demo/index.mdx @@ -102,23 +102,80 @@ Showcase related articles using the production carousel component: limit={3} /> -### Code Component - -Here's a standard code block for reference: +### CodeTabs Component ```typescript -// Example TypeScript code -interface DemoInterface { - title: string; - description: string; - tags: string[]; -} - -const demo: DemoInterface = { - title: "Demo Article", - description: "This is a demo", - tags: ["typescript", "demo"] -}; + console.log("hello, world!") +``` + +#### Grouped Code Tabs + +The following examples substitute apostrophes for backticks so that they're not rendered to code blocks. + +There can only be white space between two code blocks. Display name is set by `tabName` and can only contain characters in `[A-Za-z0-9_]`. Syntax for the language block on the first line of the code block is `language [group:tabName]` + +```javascript [g1:Javascript] + console.log('Hello World') +``` +```typescript [g1:Typescript] + type myType = "hello, world!" +``` + +#### Add a title to the code block + +You can add a title to the code block by adding a `title` prop to the code block like `js title="script.js"`: + +```js title="script.js" + console.log('Hello World') +``` + +Note: the `title` prop is optional. If you don't add it, the code block will not have a title. + +#### Highlight lines + +You can highlight lines in the code block by adding a prop to the code blocks as a list of comma separated numbers in curly brackets like `js {1,3,5}`: + +`{1}` will highlight line 1 + +`{1,3}` will highlight lines 1 and 3 + +`{2-5, 7}` will highlight lines 1 to 5(not included) and 7 + +```js {1,3,5} + console.log('Hello World') + console.log('Hello World') + console.log('Hello World') + console.log('Hello World') + console.log('Hello World') +``` + +#### Highlight strings + +You can highlight strings in the code block by adding a prop to the code blocks as a regular expression. The following example will highlight all occurrences of "astro" using a language string like `sh /astro/`: + +`/astro/` will highlight all occurrences of "astro" +`/\w*$/` will highlight the last word of each line + +```sh /astro/ + # Using NPM + npx astro add @thewebforge/astro-code-blocks + # Using Yarn + yarn astro add @thewebforge/astro-code-blocks + # Using PNPM + pnpm astro add @thewebforge/astro-code-blocks +``` + +#### Insertions and Deletions + +You can highlight insertions and deletions in the code block by adding `ins` and / or `del` props to the code blocks as a list of lines in curly brackets with a language string like `sh ins={3,4} del={5,6}`: + +```sh ins={3,4} del={5,6} + # Using NPM + npx astro add @thewebforge/astro-code-blocks + # Using Yarn + yarn astro add @thewebforge/astro-code-blocks + # Using PNPM + pnpm astro add @thewebforge/astro-code-blocks ``` ### Contact Callout Component diff --git a/src/icons/calendar-generic-ical.svg b/src/icons/calendar-generic-ical.svg new file mode 100644 index 000000000..6b76002d3 --- /dev/null +++ b/src/icons/calendar-generic-ical.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/icons/calendar-google.svg b/src/icons/calendar-google.svg new file mode 100644 index 000000000..359170506 --- /dev/null +++ b/src/icons/calendar-google.svg @@ -0,0 +1,17 @@ + + Google calendar icon + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/icons/calendar-outlook.svg b/src/icons/calendar-outlook.svg new file mode 100644 index 000000000..3514418ab --- /dev/null +++ b/src/icons/calendar-outlook.svg @@ -0,0 +1,20 @@ + + Outlook calendar icon + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/icons/calendar_apple.svg b/src/icons/calendar_apple.svg new file mode 100644 index 000000000..52355d4fa --- /dev/null +++ b/src/icons/calendar_apple.svg @@ -0,0 +1,30 @@ + + check mark icon + + + + + + + + + + + + + + + diff --git a/src/layouts/MarkdownLayout.astro b/src/layouts/MarkdownLayout.astro index 33d028b40..68b5d1301 100644 --- a/src/layouts/MarkdownLayout.astro +++ b/src/layouts/MarkdownLayout.astro @@ -27,6 +27,7 @@ import Newsletter from '@components/CallToAction/Newsletter/index.astro' import Shares from '@components/Social/Shares/index.astro' import Time from '@components/Time/index.astro' import Testimonials from '@components/Testimonials/index.astro' +import CodeTabs from '@components/Code/CodeTabs/index.astro' /** Export components for use in MDX */ const Components = { Avatar, Callout, Carousel, Contact, Copy, Embed, Featured, Highlighter, Icon, Image, MastodonModal, Newsletter, Picture, Shares, Testimonials, Time } @@ -139,7 +140,7 @@ const shouldRenderToc = showToc !== false && tocHeadings.length > 0 itemprop="articleBody" > {shouldRenderToc && } -
+
@@ -153,4 +154,7 @@ const shouldRenderToc = showToc !== false && tocHeadings.length > 0 + + + diff --git a/src/lib/config/markdown.ts b/src/lib/config/markdown.ts index 08f84725c..b9a063da6 100644 --- a/src/lib/config/markdown.ts +++ b/src/lib/config/markdown.ts @@ -45,6 +45,12 @@ Object.defineProperty(rehypeFootnotesTitle, 'name', { value: 'rehypeFootnotesTit import { rehypeInlineCodeColorSwatch } from '../markdown/plugins/rehype-inline-code-color-swatch' Object.defineProperty(rehypeInlineCodeColorSwatch, 'name', { value: 'rehypeInlineCodeColorSwatch' }) +import rehypeCodeTabs from '../markdown/plugins/rehype-code-tabs' +Object.defineProperty(rehypeCodeTabs, 'name', { value: 'rehypeCodeTabs' }) + +import rehypeShiki from '../markdown/plugins/rehype-shiki' +Object.defineProperty(rehypeShiki, 'name', { value: 'rehypeShiki' }) + /** * ============================================================== * @@ -116,6 +122,9 @@ Object.defineProperty(remarkAttribution, 'name', { value: 'remarkAttribution' }) import remarkAlign from '../markdown/plugins/remark-align' Object.defineProperty(remarkAlign, 'name', { value: 'remarkAlign' }) +import remarkCodeTabs from '../markdown/plugins/remark-code-tabs' +Object.defineProperty(remarkCodeTabs, 'name', { value: 'remarkCodeTabs' }) + import remarkReplacements from '../markdown/plugins/remark-replacements' Object.defineProperty(remarkReplacements, 'name', { value: 'remarkReplacements' }) @@ -352,8 +361,7 @@ export const markdownConfig: Partial = { /** Disabled because we include `remarkSmartypants` explicitly (test coverage + avoid double-processing). */ smartypants: false, /** Code syntax highlighting */ - syntaxHighlight: { type: 'shiki', excludeLangs: ['mermaid', 'math'] }, - shikiConfig: shikiConfigOptions, + syntaxHighlight: false, remarkPlugins: [ /** GitHub Flavored Markdown (explicit, configured) */ [remarkGfm, remarkGfmConfig], @@ -375,6 +383,8 @@ export const markdownConfig: Partial = { remarkDeflist, /** Parse grid tables (+---+ / |...| syntax) into standard table nodes */ remarkGridTables, + /** Tab groups via fenced code meta: ```lang [group:Tab Name] */ + remarkCodeTabs, /** * Add HTML attributes to elements using {.class #id key=value} syntax * Supports: headings, links, images, code blocks, lists, and bracketed spans @@ -427,6 +437,16 @@ export const markdownConfig: Partial = { rehypeMathjax, /** Render Mermaid diagrams to inline SVG at build-time */ [rehypeMermaid, rehypeMermaidConfig], + /** Wrap grouped code blocks in containers */ + rehypeCodeTabs, + /** Highlight fenced code blocks with Shiki (owned pipeline; skips mermaid/math) */ + [rehypeShiki, { + themes: shikiConfigOptions.themes, + defaultColor: shikiConfigOptions.defaultColor, + langAlias: shikiConfigOptions.langAlias, + wrap: false, + excludeLangs: ['mermaid', 'math'], + }], /** * Automatically add Tailwind classes to markdown elements as * specified in src/lib/markdown/rehype-tailwind-classes.ts diff --git a/src/lib/markdown/__tests__/coverage.spec.ts b/src/lib/markdown/__tests__/coverage.spec.ts index 9e05c1e80..576d5804d 100644 --- a/src/lib/markdown/__tests__/coverage.spec.ts +++ b/src/lib/markdown/__tests__/coverage.spec.ts @@ -7,7 +7,7 @@ */ import { describe, it, beforeAll, expect } from 'vitest' -import { readdirSync, statSync } from 'node:fs' +import { existsSync, readdirSync, statSync } from 'node:fs' import { join } from 'node:path' import { markdownConfig } from '@lib/config/markdown' @@ -16,19 +16,15 @@ interface TestFileMap { units: Set integration: Set e2e: Set - pluginUnits: Set } -// Local plugins maintained in this repo (don't need tests in units/) -const LOCAL_PLUGINS = new Set([ - 'remarkAbbreviations', - 'remarkAlign', - 'remarkAttributes', - 'remarkAttribution', - 'remarkReplacements', - 'rehypeInlineCodeColorSwatch', - 'rehypeTailwindClasses', -]) +type LocalPluginInfo = { + dirName: string + pluginName: string + pluginNameKebab: string + expectedUnitTestPath: string + hasUnitTest: boolean +} /** * Convert camelCase to kebab-case @@ -38,11 +34,57 @@ function toKebabCase(str: string): string { return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() } +function discoverLocalPlugins(pluginsRoot: string): Map { + const localPlugins = new Map() + + // Discover local plugin modules via Vite glob. This catches both default exports + // and named exports (e.g. rehype-tailwind exports rehypeTailwindClasses). + const modules = import.meta.glob('../plugins/**/index.ts', { eager: true }) as Record + + for (const [modulePath, moduleExports] of Object.entries(modules)) { + const match = modulePath.match(/\.\.\/plugins\/(?.+)\/index\.ts$/) + const dirName = match?.groups?.['dir'] + if (!dirName) continue + + const expectedUnitTestPath = join(pluginsRoot, dirName, '__tests__', 'index.spec.ts') + + const exportedFunctions: unknown[] = [] + const typed = moduleExports as Record & { default?: unknown } + + if (typeof typed.default === 'function') exportedFunctions.push(typed.default) + + for (const [exportName, value] of Object.entries(typed)) { + if (exportName === 'default') continue + if (typeof value === 'function') exportedFunctions.push(value) + } + + for (const fn of exportedFunctions) { + const pluginName = (fn as { name?: unknown }).name + if (typeof pluginName !== 'string') continue + if (!/^(remark|rehype)[A-Z]/.test(pluginName)) continue + if (localPlugins.has(pluginName)) continue + + const pluginNameKebab = toKebabCase(pluginName) + const hasUnitTest = existsSync(expectedUnitTestPath) + + localPlugins.set(pluginName, { + dirName, + pluginName, + pluginNameKebab, + expectedUnitTestPath, + hasUnitTest, + }) + } + } + + return localPlugins +} + let testFiles: TestFileMap +const localPlugins: Map = discoverLocalPlugins(join(__dirname, '../plugins')) beforeAll(() => { const testRoot = join(__dirname) - const pluginsRoot = join(__dirname, '../plugins') // Scan all test directories for spec files const scanDirectory = (dir: string): Set => { @@ -58,13 +100,11 @@ beforeAll(() => { const subFiles = scanDirectory(fullPath) subFiles.forEach(f => files.add(f)) } else if (item.match(/\.spec\.tsx?$/)) { - // Extract test name without extension or suffix + // Extract test name without extension // Units: remark-breaks.spec.ts -> remark-breaks - // Units with Astro: remark-breaks-astro.spec.ts -> remark-breaks // E2E: remark-breaks.spec.tsx -> remark-breaks const testName = item .replace(/\.spec\.tsx?$/, '') - .replace(/-astro$/, '') files.add(testName) } } @@ -78,7 +118,6 @@ beforeAll(() => { units: scanDirectory(join(testRoot, 'units')), integration: scanDirectory(join(testRoot, 'integration')), e2e: scanDirectory(join(testRoot, 'e2e')), - pluginUnits: scanDirectory(pluginsRoot), } }) @@ -94,7 +133,7 @@ function buildPluginTestCases(pluginType: 'remark' | 'rehype') { const plugin = Array.isArray(pluginEntry) ? pluginEntry[0] : pluginEntry const pluginName = (plugin as any).name || `plugin-${index}` const pluginNameKebab = toKebabCase(pluginName) - const isLocal = LOCAL_PLUGINS.has(pluginName) + const isLocal = localPlugins.has(pluginName) testCases.push({ pluginName, pluginNameKebab, isLocal }) } @@ -111,6 +150,14 @@ function maybeAddGfmRemarkTestCase( } } +function getFriendlyMisplacedLocalTestMessage(pluginInfo: LocalPluginInfo): string { + return [ + `Did you add the test file in the wrong place?`, + `Local plugins must have their unit tests at: src/lib/markdown/plugins/${pluginInfo.dirName}/__tests__/index.spec.ts`, + `Do not add local plugin tests under: src/lib/markdown/__tests__/units`, + ].join(' ') +} + describe('Markdown Plugin Test Coverage', () => { describe('remarkPlugins', () => { const remarkPlugins = buildPluginTestCases('remark') @@ -120,7 +167,7 @@ describe('Markdown Plugin Test Coverage', () => { 'should have integration test for $pluginName', ({ pluginNameKebab }) => { expect( - testFiles.integration.has(pluginNameKebab), + testFiles.integration.has(`${pluginNameKebab}-astro`), `Missing test in integration/${pluginNameKebab}-astro.spec.ts` ).toBe(true) } @@ -137,6 +184,29 @@ describe('Markdown Plugin Test Coverage', () => { expect(testFiles.units.has(pluginNameKebab), `Missing test in units/${pluginNameKebab}.spec.ts`).toBe(true) } ) + + it.each(remarkPlugins.filter(p => p.isLocal))( + 'should have unit test for $pluginName (local plugin)', + ({ pluginName, pluginNameKebab }) => { + const info = localPlugins.get(pluginName) + expect( + info?.hasUnitTest, + info ? `Missing local plugin unit test: ${info.expectedUnitTestPath}` : `Missing local plugin folder: ${pluginNameKebab}` + ).toBe(true) + } + ) + + it.each(remarkPlugins.filter(p => p.isLocal))( + 'should not have a misplaced units test for $pluginName (local plugin)', + ({ pluginName }) => { + const info = localPlugins.get(pluginName) + expect(info, `Expected local plugin metadata for ${pluginName}`).toBeTruthy() + if (!info) return + + const hasMisplaced = testFiles.units.has(info.pluginNameKebab) || testFiles.units.has(`${info.pluginNameKebab}-astro`) + expect(hasMisplaced, getFriendlyMisplacedLocalTestMessage(info)).toBe(false) + } + ) }) describe('rehypePlugins', () => { @@ -146,7 +216,7 @@ describe('Markdown Plugin Test Coverage', () => { 'should have integration test for $pluginName', ({ pluginNameKebab }) => { expect( - testFiles.integration.has(pluginNameKebab), + testFiles.integration.has(`${pluginNameKebab}-astro`), `Missing test in integration/${pluginNameKebab}-astro.spec.ts` ).toBe(true) } @@ -163,5 +233,28 @@ describe('Markdown Plugin Test Coverage', () => { expect(testFiles.units.has(pluginNameKebab), `Missing test in units/${pluginNameKebab}.spec.ts`).toBe(true) } ) + + it.each(rehypePlugins.filter(p => p.isLocal))( + 'should have unit test for $pluginName (local plugin)', + ({ pluginName, pluginNameKebab }) => { + const info = localPlugins.get(pluginName) + expect( + info?.hasUnitTest, + info ? `Missing local plugin unit test: ${info.expectedUnitTestPath}` : `Missing local plugin folder: ${pluginNameKebab}` + ).toBe(true) + } + ) + + it.each(rehypePlugins.filter(p => p.isLocal))( + 'should not have a misplaced units test for $pluginName (local plugin)', + ({ pluginName }) => { + const info = localPlugins.get(pluginName) + expect(info, `Expected local plugin metadata for ${pluginName}`).toBeTruthy() + if (!info) return + + const hasMisplaced = testFiles.units.has(info.pluginNameKebab) || testFiles.units.has(`${info.pluginNameKebab}-astro`) + expect(hasMisplaced, getFriendlyMisplacedLocalTestMessage(info)).toBe(false) + } + ) }) }) diff --git a/src/lib/markdown/__tests__/e2e/fixtures/rehype-code-tabs.fixture.astro b/src/lib/markdown/__tests__/e2e/fixtures/rehype-code-tabs.fixture.astro new file mode 100644 index 000000000..b153e0522 --- /dev/null +++ b/src/lib/markdown/__tests__/e2e/fixtures/rehype-code-tabs.fixture.astro @@ -0,0 +1,10 @@ +--- +--- + +```js [g1:JavaScript] +console.log(1) +``` + +```ts [g1:TypeScript] +console.log(2) +``` diff --git a/src/lib/markdown/__tests__/e2e/fixtures/remark-code-tabs.fixture.astro b/src/lib/markdown/__tests__/e2e/fixtures/remark-code-tabs.fixture.astro new file mode 100644 index 000000000..aa46bb171 --- /dev/null +++ b/src/lib/markdown/__tests__/e2e/fixtures/remark-code-tabs.fixture.astro @@ -0,0 +1,6 @@ +--- +--- + +```js [g1:JavaScript] +console.log(1) +``` diff --git a/src/lib/markdown/__tests__/e2e/rehype-code-tabs.spec.tsx b/src/lib/markdown/__tests__/e2e/rehype-code-tabs.spec.tsx new file mode 100644 index 000000000..60515f505 --- /dev/null +++ b/src/lib/markdown/__tests__/e2e/rehype-code-tabs.spec.tsx @@ -0,0 +1,28 @@ +// @vitest-environment node + +import { describe, it, expect, beforeAll } from 'vitest' + +import { processWithFullPipeline } from '@lib/markdown/helpers/processors' + +let html: string + +beforeAll(async () => { + const markdown = [ + '```js [g1:JavaScript]', + 'console.log(1)', + '```', + '', + '```ts [g1:TypeScript]', + 'console.log(2)', + '```', + ].join('\n') + html = await processWithFullPipeline(markdown) +}) + +describe('rehype-code-tabs (Layer 3: E2E)', () => { + it('should render wrapper', () => { + expect(html).toContain(' { + const markdown = [ + '```ts [g1:TypeScript]', + 'const x: number = 1', + '```', + ].join('\n') + + html = await processWithFullPipeline(markdown) +}) + +describe('rehype-shiki (Layer 3: E2E)', () => { + it('should emit Shiki-highlighted
', () => {
+    expect(html).toContain(' {
+  const markdown = ['```js [g1:JavaScript]', 'console.log(1)', '```'].join('\n')
+  html = await processWithFullPipeline(markdown)
+})
+
+describe('remark-code-tabs (Layer 3: E2E)', () => {
+  it('should render data attributes in final HTML', () => {
+    expect(html).toContain('data-code-tabs-group="g1"')
+    expect(html).toContain('data-code-tabs-tab="JavaScript"')
+  })
+})
diff --git a/src/lib/markdown/__tests__/integration/rehype-code-tabs-astro.spec.ts b/src/lib/markdown/__tests__/integration/rehype-code-tabs-astro.spec.ts
new file mode 100644
index 000000000..7bf033d59
--- /dev/null
+++ b/src/lib/markdown/__tests__/integration/rehype-code-tabs-astro.spec.ts
@@ -0,0 +1,22 @@
+import { describe, it, expect } from 'vitest'
+import { processWithFullPipeline } from '@lib/markdown/helpers/processors'
+
+describe('rehype-code-tabs (Layer 2: Astro pipeline)', () => {
+  it('should wrap consecutive grouped code blocks in ', async () => {
+    const markdown = [
+      '```js [g1:JavaScript]',
+      'console.log(1)',
+      '```',
+      '',
+      '```ts [g1:TypeScript]',
+      'console.log(2)',
+      '```',
+    ].join('\n')
+
+    const html = await processWithFullPipeline(markdown)
+
+    expect(html).toContain(' {
+  it('should highlight code fences and preserve code-tabs data attributes', async () => {
+    const markdown = [
+      '```ts [g1:TypeScript]',
+      'const x: number = 1',
+      '```',
+      '',
+      '```ts [g1:TypeScript]',
+      'const y: number = 2',
+      '```',
+    ].join('\n')
+
+    const html = await processWithFullPipeline(markdown)
+
+    expect(html).toContain(' {
+  it('should keep code block output and attach group/tab data attributes', async () => {
+    const markdown = ['```js [g1:JavaScript]', 'console.log(1)', '```'].join('\n')
+
+    const html = await processWithFullPipeline(markdown)
+
+    expect(html).toContain('data-code-tabs-group="g1"')
+    expect(html).toContain('data-code-tabs-tab="JavaScript"')
+  })
+})
diff --git a/src/lib/markdown/__tests__/units/remark-smartypants.spec.ts b/src/lib/markdown/__tests__/units/remark-smartypants.spec.ts
deleted file mode 100644
index 1cdf938ad..000000000
--- a/src/lib/markdown/__tests__/units/remark-smartypants.spec.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import { describe, it, expect } from 'vitest'
-import { remark } from 'remark'
-import remarkRehype from 'remark-rehype'
-import rehypeStringify from 'rehype-stringify'
-import remarkSmartypants from '@lib/markdown/plugins/remark-smartypants'
-
-import { processIsolated } from '@lib/markdown/helpers/processors'
-
-describe('remark-smartypants (Layer 1: Isolated)', () => {
-  it('should convert straight quotes into curly quotes', async () => {
-    const markdown = 'He said, "Hello" and \'goodbye\'.'
-
-    const html = await processIsolated({ markdown, plugin: remarkSmartypants })
-
-    expect(html).toContain('He said, “Hello”')
-    expect(html).toContain('‘goodbye’')
-  })
-
-  it('should convert backticks-style quotes (``like this\'\') into curly quotes', async () => {
-    const markdown = "He said, ``like this'' and left."
-
-    const html = await processIsolated({ markdown, plugin: remarkSmartypants })
-
-    expect(html).toContain('He said, “like this”')
-  })
-
-  it('should convert -- and --- into en and em dashes', async () => {
-    const markdown = 'One -- two --- three.'
-
-    const html = await processIsolated({ markdown, plugin: remarkSmartypants })
-
-    expect(html).toContain('One – two — three')
-  })
-
-  it('should convert ... and . . . into ellipsis', async () => {
-    const markdown = 'Wait... or wait . . .'
-
-    const html = await processIsolated({ markdown, plugin: remarkSmartypants })
-
-    expect(html).toContain('Wait… or wait …')
-  })
-
-  it('should not modify content inside inline code or code blocks', async () => {
-    const markdown = [
-      'Outside: "Hello" -- ...',
-      '',
-      'Inline: `"Hello" -- ...`',
-      '',
-      '```text',
-      '"Hello" -- ...',
-      '```',
-    ].join('\n')
-
-    const html = await processIsolated({ markdown, plugin: remarkSmartypants })
-
-    // Outside should be transformed
-    expect(html).toContain('Outside: “Hello” – …')
-
-    // Inline + block code should remain literal
-    expect(html).toContain('"Hello" -- ...')
-    expect(html).toContain('"Hello" -- ...\n')
-  })
-
-  it('should not modify content within raw HTML blocks like 
, , , , 
"Hello" -- ...
', - '"Hello" -- ...', - '"Hello" -- ...', - '"Hello" -- ...', - '', - ].join('\n\n') - - const html = String( - await remark() - .use(remarkSmartypants) - .use(remarkRehype, { allowDangerousHtml: true }) - .use(rehypeStringify, { allowDangerousHtml: true }) - .process(markdown) - ) - - expect(html).toContain('
"Hello" -- ...
') - expect(html).toContain('"Hello" -- ...') - expect(html).toContain('"Hello" -- ...') - expect(html).toContain('"Hello" -- ...') - expect(html).toContain('') - }) -}) diff --git a/src/lib/markdown/plugins/rehype-code-tabs/__tests__/index.spec.ts b/src/lib/markdown/plugins/rehype-code-tabs/__tests__/index.spec.ts new file mode 100644 index 000000000..28431a512 --- /dev/null +++ b/src/lib/markdown/plugins/rehype-code-tabs/__tests__/index.spec.ts @@ -0,0 +1,228 @@ +import { describe, it, expect } from 'vitest' +import type { Root, Element } from 'hast' +import { unified } from 'unified' +import rehypeCodeTabs from '@lib/markdown/plugins/rehype-code-tabs' + +describe('rehype-code-tabs (Layer 1: Isolated)', () => { + it('should wrap consecutive grouped
 blocks in ', async () => {
+    const pre1: Element = {
+      type: 'element',
+      tagName: 'pre',
+      properties: {},
+      children: [
+        {
+          type: 'element',
+          tagName: 'code',
+          properties: {
+            'data-code-tabs-group': 'g1',
+            'data-code-tabs-tab': 'JS',
+          },
+          children: [{ type: 'text', value: 'console.log(1)' }],
+        },
+      ],
+    }
+
+    const pre2: Element = {
+      type: 'element',
+      tagName: 'pre',
+      properties: {},
+      children: [
+        {
+          type: 'element',
+          tagName: 'code',
+          properties: {
+            'data-code-tabs-group': 'g1',
+            'data-code-tabs-tab': 'TS',
+          },
+          children: [{ type: 'text', value: 'console.log(2)' }],
+        },
+      ],
+    }
+
+    const tree: Root = {
+      type: 'root',
+      children: [pre1, { type: 'text', value: '\n' }, pre2],
+    }
+
+    const processor = unified().use(rehypeCodeTabs)
+    await processor.run(tree as never)
+
+    const wrapper = tree.children[0] as Element
+    expect(wrapper.tagName).toBe('code-tabs')
+    expect((wrapper.properties?.['className'] as string[])?.includes('code-tabs')).toBe(true)
+    expect(wrapper.properties?.['data-code-tabs-group']).toBe('g1')
+    expect(wrapper.children.length).toBe(2)
+  })
+
+  it('should wrap a standalone 
 block in  (single block mode)', async () => {
+    const pre: Element = {
+      type: 'element',
+      tagName: 'pre',
+      properties: {
+        'data-language': 'ts',
+      },
+      children: [
+        {
+          type: 'element',
+          tagName: 'code',
+          properties: {
+            className: ['language-ts'],
+          },
+          children: [{ type: 'text', value: 'const x: number = 1' }],
+        },
+      ],
+    }
+
+    const tree: Root = {
+      type: 'root',
+      children: [pre],
+    }
+
+    const processor = unified().use(rehypeCodeTabs)
+    await processor.run(tree as never)
+
+    const wrapper = tree.children[0] as Element
+    expect(wrapper.tagName).toBe('code-tabs')
+    expect((wrapper.properties?.['className'] as string[])?.includes('code-tabs')).toBe(true)
+    expect(wrapper.children.length).toBe(1)
+    expect((wrapper.children[0] as Element).tagName).toBe('pre')
+  })
+
+  it.each(['mermaid', 'math', 'text'])('should not wrap excluded language %s', async (lang) => {
+    const pre: Element = {
+      type: 'element',
+      tagName: 'pre',
+      properties: {
+        'data-language': lang,
+      },
+      children: [
+        {
+          type: 'element',
+          tagName: 'code',
+          properties: {
+            className: [`language-${lang}`],
+          },
+          children: [{ type: 'text', value: 'x' }],
+        },
+      ],
+    }
+
+    const tree: Root = {
+      type: 'root',
+      children: [pre],
+    }
+
+    const processor = unified().use(rehypeCodeTabs)
+    await processor.run(tree as never)
+
+    const first = tree.children[0] as Element
+    expect(first.tagName).toBe('pre')
+  })
+
+  it('should not wrap blocks from different groups', async () => {
+    const pre1: Element = {
+      type: 'element',
+      tagName: 'pre',
+      properties: {},
+      children: [
+        {
+          type: 'element',
+          tagName: 'code',
+          properties: {
+            'data-code-tabs-group': 'g1',
+            'data-code-tabs-tab': 'JS',
+          },
+          children: [{ type: 'text', value: 'a' }],
+        },
+      ],
+    }
+
+    const pre2: Element = {
+      type: 'element',
+      tagName: 'pre',
+      properties: {},
+      children: [
+        {
+          type: 'element',
+          tagName: 'code',
+          properties: {
+            'data-code-tabs-group': 'g2',
+            'data-code-tabs-tab': 'TS',
+          },
+          children: [{ type: 'text', value: 'b' }],
+        },
+      ],
+    }
+
+    const tree: Root = {
+      type: 'root',
+      children: [pre1, { type: 'text', value: '\n' }, pre2],
+    }
+
+    const processor = unified().use(rehypeCodeTabs)
+    await processor.run(tree as never)
+
+    const first = tree.children[0] as Element
+    expect(first.tagName).toBe('code-tabs')
+    expect(first.children.length).toBe(1)
+    expect((first.children[0] as Element).tagName).toBe('pre')
+
+    const second = tree.children[2] as Element
+    expect(second.tagName).toBe('code-tabs')
+    expect(second.children.length).toBe(1)
+    expect((second.children[0] as Element).tagName).toBe('pre')
+  })
+
+  it('should support camelCase data props (MDX-style) on ', async () => {
+    const pre1: Element = {
+      type: 'element',
+      tagName: 'pre',
+      properties: {},
+      children: [
+        {
+          type: 'element',
+          tagName: 'code',
+          properties: {
+            dataCodeTabsGroup: 'g1',
+            dataCodeTabsTab: 'JavaScript',
+          },
+          children: [{ type: 'text', value: 'console.log(1)' }],
+        },
+      ],
+    }
+
+    const pre2: Element = {
+      type: 'element',
+      tagName: 'pre',
+      properties: {},
+      children: [
+        {
+          type: 'element',
+          tagName: 'code',
+          properties: {
+            dataCodeTabsGroup: 'g1',
+            dataCodeTabsTab: 'TypeScript',
+          },
+          children: [{ type: 'text', value: 'console.log(2)' }],
+        },
+      ],
+    }
+
+    const tree: Root = {
+      type: 'root',
+      children: [pre1, { type: 'text', value: '\n' }, pre2],
+    }
+
+    const processor = unified().use(rehypeCodeTabs)
+    await processor.run(tree as never)
+
+    const wrapper = tree.children[0] as Element
+    expect(wrapper.tagName).toBe('code-tabs')
+    expect(wrapper.properties?.['data-code-tabs-group']).toBe('g1')
+    expect(wrapper.children.length).toBe(2)
+
+    const firstPre = wrapper.children[0] as Element
+    expect(firstPre.tagName).toBe('pre')
+    expect(firstPre.properties?.['data-code-tabs-tab']).toBe('JavaScript')
+  })
+})
diff --git a/src/lib/markdown/plugins/rehype-code-tabs/index.ts b/src/lib/markdown/plugins/rehype-code-tabs/index.ts
new file mode 100644
index 000000000..6694372fa
--- /dev/null
+++ b/src/lib/markdown/plugins/rehype-code-tabs/index.ts
@@ -0,0 +1,216 @@
+import type { Element, Parent, Root, Text } from 'hast'
+import type { Plugin } from 'unified'
+import { SKIP, visit } from 'unist-util-visit'
+
+type TabInfo = {
+  group: string
+  tab: string
+}
+
+const EXCLUDED_SINGLE_WRAP_LANGS = new Set(['mermaid', 'math', 'text'])
+
+function toDataPropName(attributeName: string): string | null {
+  // Convert `data-foo-bar` -> `dataFooBar` (some pipelines normalize this way)
+  if (!attributeName.startsWith('data-')) return null
+  const rest = attributeName.slice('data-'.length)
+  if (!rest) return null
+
+  const camel = rest
+    .split('-')
+    .filter(Boolean)
+    .map((part, index) => {
+      const lower = part.toLowerCase()
+      if (index === 0) return lower
+      return lower.charAt(0).toUpperCase() + lower.slice(1)
+    })
+    .join('')
+
+  return `data${camel.charAt(0).toUpperCase()}${camel.slice(1)}`
+}
+
+function toStringProp(value: unknown): string | null {
+  if (typeof value === 'string') return value
+  if (typeof value === 'number') return String(value)
+  return null
+}
+
+function getProp(node: Element, name: string): string | null {
+  const direct = toStringProp(node.properties?.[name])
+  if (direct) return direct
+
+  const alt = toDataPropName(name)
+  if (!alt) return null
+  return toStringProp(node.properties?.[alt])
+}
+
+function getTabInfoFromPre(pre: Element): TabInfo | null {
+  const group = getProp(pre, 'data-code-tabs-group')
+  const tab = getProp(pre, 'data-code-tabs-tab')
+
+  if (group && tab) {
+    pre.properties = pre.properties || {}
+    pre.properties['data-code-tabs-group'] = group
+    pre.properties['data-code-tabs-tab'] = tab
+    return { group, tab }
+  }
+
+  const codeChild = pre.children.find((child): child is Element => {
+    return !!child && typeof child === 'object' && (child as Element).type === 'element'
+  })
+
+  if (!codeChild) return null
+
+  const childGroup = getProp(codeChild, 'data-code-tabs-group')
+  const childTab = getProp(codeChild, 'data-code-tabs-tab')
+
+  if (!childGroup || !childTab) return null
+
+  pre.properties = pre.properties || {}
+  pre.properties['data-code-tabs-group'] = childGroup
+  pre.properties['data-code-tabs-tab'] = childTab
+
+  // Normalize alternate property names onto the standard data-* form.
+  const altGroup = toDataPropName('data-code-tabs-group')
+  const altTab = toDataPropName('data-code-tabs-tab')
+  if (altGroup && pre.properties[altGroup] === undefined) pre.properties[altGroup] = childGroup
+  if (altTab && pre.properties[altTab] === undefined) pre.properties[altTab] = childTab
+
+  return { group: childGroup, tab: childTab }
+}
+
+function isParent(node: unknown): node is Parent {
+  return !!node && typeof node === 'object' && Array.isArray((node as Parent).children)
+}
+
+function isElement(node: unknown): node is Element {
+  return !!node && typeof node === 'object' && (node as Element).type === 'element'
+}
+
+function isWhitespaceText(node: unknown): node is Text {
+  return !!node && typeof node === 'object' && (node as Text).type === 'text' && !((node as Text).value || '').trim()
+}
+
+function getLanguageFromCode(code: Element): string | null {
+  const classNames = nodeToStringArray(code.properties?.['className'])
+  const langClass = classNames.find(cn => cn.startsWith('language-'))
+  if (langClass) return langClass.replace(/^language-/, '').trim() || null
+
+  const dataLang = code.properties?.['data-language']
+  if (typeof dataLang === 'string' && dataLang.trim()) return dataLang.trim()
+
+  return null
+}
+
+function nodeToStringArray(value: unknown): string[] {
+  if (Array.isArray(value)) return value.map(entry => String(entry))
+  if (typeof value === 'string' && value.trim()) return value.trim().split(/\s+/g)
+  return []
+}
+
+function getLanguageFromPre(pre: Element): string | null {
+  const dataLang = pre.properties?.['data-language']
+  if (typeof dataLang === 'string' && dataLang.trim()) return dataLang.trim()
+
+  const altLang = pre.properties?.['dataLanguage']
+  if (typeof altLang === 'string' && altLang.trim()) return altLang.trim()
+
+  const codeChild = pre.children.find((child): child is Element => isElement(child) && child.tagName === 'code')
+  if (!codeChild) return null
+
+  return getLanguageFromCode(codeChild)
+}
+
+function shouldSkipSingleWrap(pre: Element): boolean {
+  const lang = getLanguageFromPre(pre)
+  if (!lang) return false
+  return EXCLUDED_SINGLE_WRAP_LANGS.has(lang.toLowerCase())
+}
+
+function wrapCodeTabRuns(parent: Parent): void {
+  if (isElement(parent) && parent.tagName === 'code-tabs') return
+
+  const children = parent.children
+
+  for (let i = 0; i < children.length; i += 1) {
+    const first = children[i]
+    if (!isElement(first) || first.tagName !== 'pre') continue
+
+    const startInfo = getTabInfoFromPre(first)
+    if (!startInfo) continue
+
+    const run: Element[] = []
+
+    let lastIndex = i
+    for (let j = i; j < children.length; j += 1) {
+      const current = children[j]
+
+      if (isWhitespaceText(current)) {
+        lastIndex = j
+        continue
+      }
+
+      if (!isElement(current) || current.tagName !== 'pre') break
+
+      const info = getTabInfoFromPre(current)
+      if (!info || info.group !== startInfo.group) break
+
+      run.push(current)
+      lastIndex = j
+    }
+
+    if (run.length < 2) continue
+
+    const wrapper: Element = {
+      type: 'element',
+      tagName: 'code-tabs',
+      properties: {
+        className: ['code-tabs'],
+        'data-code-tabs-group': startInfo.group,
+      },
+      children: run,
+    }
+
+    children.splice(i, lastIndex - i + 1, wrapper)
+  }
+}
+
+function wrapStandaloneCodeBlocks(parent: Parent): void {
+  if (isElement(parent) && parent.tagName === 'code-tabs') return
+
+  const children = parent.children
+
+  for (let i = 0; i < children.length; i += 1) {
+    const node = children[i]
+    if (!isElement(node) || node.tagName !== 'pre') continue
+    if (shouldSkipSingleWrap(node)) continue
+
+    const info = getTabInfoFromPre(node)
+    const wrapper: Element = {
+      type: 'element',
+      tagName: 'code-tabs',
+      properties: {
+        className: ['code-tabs'],
+        ...(info ? { 'data-code-tabs-group': info.group } : {}),
+      },
+      children: [node],
+    }
+
+    children.splice(i, 1, wrapper)
+  }
+}
+
+const rehypeCodeTabs: Plugin<[], Root> = () => {
+  return tree => {
+    wrapCodeTabRuns(tree as unknown as Parent)
+    wrapStandaloneCodeBlocks(tree as unknown as Parent)
+
+    visit(tree, 'element', (node: Element): typeof SKIP | void => {
+      if (node.tagName === 'code-tabs') return SKIP
+      if (!isParent(node)) return
+      wrapCodeTabRuns(node)
+      wrapStandaloneCodeBlocks(node)
+    })
+  }
+}
+
+export default rehypeCodeTabs
diff --git a/src/lib/markdown/__tests__/units/rehype-footnotes-title.spec.ts b/src/lib/markdown/plugins/rehype-footnotes-title/__tests__/index.spec.ts
similarity index 100%
rename from src/lib/markdown/__tests__/units/rehype-footnotes-title.spec.ts
rename to src/lib/markdown/plugins/rehype-footnotes-title/__tests__/index.spec.ts
diff --git a/src/lib/markdown/plugins/rehype-inline-code-color-swatch/__tests__/index.spec.ts b/src/lib/markdown/plugins/rehype-inline-code-color-swatch/__tests__/index.spec.ts
new file mode 100644
index 000000000..175ceddf9
--- /dev/null
+++ b/src/lib/markdown/plugins/rehype-inline-code-color-swatch/__tests__/index.spec.ts
@@ -0,0 +1,33 @@
+import { describe, it, expect } from 'vitest'
+import { rehypeInlineCodeColorSwatch } from '@lib/markdown/plugins/rehype-inline-code-color-swatch'
+import { processIsolated } from '@lib/markdown/helpers/processors'
+
+describe('rehype-inline-code-color-swatch (Layer 1: Isolated)', () => {
+  it('adds a swatch only for inline code colors', async () => {
+    const markdown = 'Colors: `#0969DA` `rgb(9, 105, 218)` `hsl(212, 92%, 45%)`.'
+
+    const html = await processIsolated({ markdown, plugin: rehypeInlineCodeColorSwatch, stage: 'rehype' })
+
+    expect(html).toContain('data-color-swatch="true"')
+    expect(html).toContain('background-color: #0969DA')
+    expect(html).toContain('background-color: rgb(9, 105, 218)')
+    expect(html).toContain('background-color: hsl(212, 92%, 45%)')
+  })
+
+  it('does not add a swatch for non-backticked colors', async () => {
+    const markdown = 'Color: #0969DA rgb(9, 105, 218) hsl(212, 92%, 45%).'
+
+    const html = await processIsolated({ markdown, plugin: rehypeInlineCodeColorSwatch, stage: 'rehype' })
+
+    expect(html).not.toContain('data-color-swatch="true"')
+  })
+
+  it('does not add a swatch for code blocks', async () => {
+    const markdown = ['```css', 'color: #0969DA;', 'background: rgb(9, 105, 218);', '```'].join('\n')
+
+    const html = await processIsolated({ markdown, plugin: rehypeInlineCodeColorSwatch, stage: 'rehype' })
+
+    expect(html).toContain(' {
+  return {
+    createHighlighter: vi.fn(async () => {
+      return {
+        loadLanguage: vi.fn(async () => undefined),
+        codeToHast: (_code: string) => {
+          const highlightedPre: Element = {
+            type: 'element',
+            tagName: 'pre',
+            properties: { class: 'shiki', tabindex: '0' },
+            children: [
+              {
+                type: 'element',
+                tagName: 'code',
+                properties: {},
+                children: [{ type: 'text', value: 'highlighted' }],
+              },
+            ],
+          }
+
+          const root: Root = {
+            type: 'root',
+            children: [highlightedPre],
+          }
+
+          return root
+        },
+      }
+    }),
+  }
+})
+
+import rehypeShiki from '../index'
+
+function getPre(tree: Root): Element {
+  const pre = tree.children[0] as Element | undefined
+  if (!pre || pre.type !== 'element' || pre.tagName !== 'pre') {
+    throw new Error('Expected pre element at root')
+  }
+  return pre
+}
+
+describe('rehype-shiki', () => {
+  it('highlights code blocks and preserves existing data attributes', async () => {
+    const tree: Root = {
+      type: 'root',
+      children: [
+        {
+          type: 'element',
+          tagName: 'pre',
+          properties: {
+            'data-code-tabs-group': 'g1',
+            'data-code-tabs-tab': 'JavaScript',
+          },
+          children: [
+            {
+              type: 'element',
+              tagName: 'code',
+              properties: {
+                className: ['language-js'],
+              },
+              children: [{ type: 'text', value: 'console.log(1)\n' }],
+            },
+          ],
+        },
+      ],
+    }
+
+    const transformer = (rehypeShiki as unknown as (_opts: unknown) => unknown)({
+      themes: { light: 'github-light', dark: 'github-dark' },
+      defaultColor: 'light',
+      langAlias: { js: 'javascript' },
+      wrap: false,
+    })
+
+    const run = transformer as unknown as (_tree: Root) => Promise
+    await run(tree)
+
+    const pre = getPre(tree)
+    expect(pre.properties?.['data-code-tabs-group']).toBe('g1')
+    expect(pre.properties?.['data-code-tabs-tab']).toBe('JavaScript')
+    expect(pre.properties?.['data-language']).toBe('javascript')
+    expect(pre.properties?.['tabIndex']).toBe(0)
+
+    const classNames = pre.properties?.['className'] as string[]
+    expect(classNames).toEqual(expect.arrayContaining(['shiki', 'overflow-x-auto', 'whitespace-pre']))
+  })
+
+  it('normalizes common language aliases by default', async () => {
+    const tree: Root = {
+      type: 'root',
+      children: [
+        {
+          type: 'element',
+          tagName: 'pre',
+          properties: {},
+          children: [
+            {
+              type: 'element',
+              tagName: 'code',
+              properties: {
+                className: ['language-ts'],
+              },
+              children: [{ type: 'text', value: 'const x: number = 1\n' }],
+            },
+          ],
+        },
+      ],
+    }
+
+    const transformer = (rehypeShiki as unknown as (_opts: unknown) => unknown)({
+      themes: { light: 'github-light', dark: 'github-dark' },
+    })
+
+    const run = transformer as unknown as (_tree: Root) => Promise
+    await run(tree)
+
+    const pre = getPre(tree)
+    expect(pre.properties?.['data-language']).toBe('typescript')
+  })
+
+  it('treats js and javascript consistently via alias map', async () => {
+    const tree: Root = {
+      type: 'root',
+      children: [
+        {
+          type: 'element',
+          tagName: 'pre',
+          properties: {},
+          children: [
+            {
+              type: 'element',
+              tagName: 'code',
+              properties: {
+                className: ['language-javascript'],
+              },
+              children: [{ type: 'text', value: 'console.log(1)\n' }],
+            },
+          ],
+        },
+      ],
+    }
+
+    const transformer = (rehypeShiki as unknown as (_opts: unknown) => unknown)({
+      themes: { light: 'github-light', dark: 'github-dark' },
+      langAlias: { javascript: 'js' },
+    })
+
+    const run = transformer as unknown as (_tree: Root) => Promise
+    await run(tree)
+
+    const pre = getPre(tree)
+    expect(pre.properties?.['data-language']).toBe('js')
+  })
+
+  it('skips excluded languages', async () => {
+    const originalPre: Element = {
+      type: 'element',
+      tagName: 'pre',
+      properties: {},
+      children: [
+        {
+          type: 'element',
+          tagName: 'code',
+          properties: { className: ['language-mermaid'] },
+          children: [{ type: 'text', value: 'graph TD; A-->B' }],
+        },
+      ],
+    }
+
+    const tree: Root = {
+      type: 'root',
+      children: [originalPre],
+    }
+
+    const transformer = (rehypeShiki as unknown as (_opts: unknown) => unknown)({
+      themes: { light: 'github-light', dark: 'github-dark' },
+      excludeLangs: ['mermaid', 'math'],
+    })
+
+    const run = transformer as unknown as (_tree: Root) => Promise
+    await run(tree)
+
+    expect(tree.children[0]).toBe(originalPre)
+  })
+})
diff --git a/src/lib/markdown/plugins/rehype-shiki/index.ts b/src/lib/markdown/plugins/rehype-shiki/index.ts
new file mode 100644
index 000000000..a34557d10
--- /dev/null
+++ b/src/lib/markdown/plugins/rehype-shiki/index.ts
@@ -0,0 +1,300 @@
+import type { Root, Element, Parent, Text } from 'hast'
+import type { Plugin } from 'unified'
+import { visit } from 'unist-util-visit'
+import { createHighlighter } from 'shiki'
+
+type ShikiThemes =
+  | string
+  | {
+      light: string
+      dark: string
+    }
+
+export type RehypeShikiOptions = {
+  themes: ShikiThemes
+  defaultColor?: 'light' | 'dark' | false
+  langAlias?: Record
+  /**
+   * Use Tailwind for wrapping instead of Shiki inline wrap styles.
+   * Defaults to false (no wrap).
+   */
+  wrap?: boolean
+  /** Languages that should not be highlighted. */
+  excludeLangs?: string[]
+}
+
+type HighlighterLike = {
+  codeToHast: (
+    _code: string,
+    _options: {
+      lang: string
+      themes: ShikiThemes
+      defaultColor?: 'light' | 'dark' | false
+      wrap?: boolean
+    },
+  ) => Root | Element
+  loadLanguage: (_lang: string) => Promise
+}
+
+function getThemeNames(themes: ShikiThemes): string[] {
+  if (typeof themes === 'string') return [themes]
+
+  const names = [themes.light, themes.dark].filter(Boolean)
+  return Array.from(new Set(names))
+}
+
+function toStringArray(value: unknown): string[] {
+  if (!value) return []
+  if (Array.isArray(value)) return value.filter(v => typeof v === 'string') as string[]
+  if (typeof value === 'string') return value.split(/\s+/).filter(Boolean)
+  return []
+}
+
+function mergeClassNames(existing: unknown, extra: string[]): string[] {
+  const merged = new Set([...toStringArray(existing), ...extra].filter(Boolean))
+  return Array.from(merged)
+}
+
+function isElement(node: unknown): node is Element {
+  return !!node && typeof node === 'object' && (node as Element).type === 'element'
+}
+
+function isParent(node: unknown): node is Parent {
+  return !!node && typeof node === 'object' && Array.isArray((node as Parent).children)
+}
+
+function getText(node: unknown): string {
+  if (!node || typeof node !== 'object') return ''
+  const typed = node as Partial & Partial
+
+  if (typed.type === 'text') {
+    return String((typed as Text).value ?? '')
+  }
+
+  if (!Array.isArray(typed.children)) return ''
+  return typed.children.map(getText).join('')
+}
+
+function parseLanguageFromCode(code: Element): string | null {
+  const classNames = toStringArray(code.properties?.['className'])
+  const langClass = classNames.find(cn => cn.startsWith('language-'))
+  if (langClass) return langClass.replace(/^language-/, '').trim() || null
+
+  const dataLang = code.properties?.['data-language']
+  if (typeof dataLang === 'string' && dataLang.trim()) return dataLang.trim()
+
+  return null
+}
+
+const DEFAULT_LANG_ALIAS: Record = {
+  js: 'javascript',
+  javascript: 'javascript',
+  ts: 'typescript',
+  typescript: 'typescript',
+}
+
+function normalizeAliasMap(alias: Record | undefined): Record {
+  if (!alias) return {}
+  return Object.fromEntries(
+    Object.entries(alias)
+      .map(([key, value]) => [key.trim().toLowerCase(), value.trim()])
+      .filter(([key, value]) => Boolean(key) && Boolean(value)),
+  )
+}
+
+function normalizeLanguage(lang: string, alias: Record | undefined): string {
+  const trimmed = lang.trim()
+  if (!trimmed) return trimmed
+
+  const key = trimmed.toLowerCase()
+  const mapped = alias?.[key]
+  return (mapped ?? key).trim()
+}
+
+function hasShikiClass(pre: Element): boolean {
+  const classNames = mergeClassNames(pre.properties?.['className'], toStringArray(pre.properties?.['class']))
+  return classNames.some(cn => cn === 'shiki' || cn.startsWith('shiki-'))
+}
+
+function normalizeShikiProperties(pre: Element): void {
+  pre.properties = pre.properties || {}
+
+  const classes = mergeClassNames(pre.properties['className'], toStringArray(pre.properties['class']))
+  if (classes.length > 0) {
+    pre.properties['className'] = classes
+  }
+  delete (pre.properties as Record)['class']
+
+  const tabindex = pre.properties['tabindex']
+  if (typeof tabindex === 'string' || typeof tabindex === 'number') {
+    const numeric = typeof tabindex === 'number' ? tabindex : Number(tabindex)
+    pre.properties['tabIndex'] = Number.isFinite(numeric) ? numeric : 0
+    delete (pre.properties as Record)['tabindex']
+  }
+}
+
+function getDataPropValue(node: Element | null, attributeName: string): unknown {
+  if (!node?.properties) return undefined
+
+  const direct = node.properties[attributeName]
+  if (direct !== undefined) return direct
+
+  if (!attributeName.startsWith('data-')) return undefined
+  const rest = attributeName.slice('data-'.length)
+  if (!rest) return undefined
+
+  const camel = rest
+    .split('-')
+    .filter(Boolean)
+    .map((part, index) => {
+      const lower = part.toLowerCase()
+      if (index === 0) return lower
+      return lower.charAt(0).toUpperCase() + lower.slice(1)
+    })
+    .join('')
+
+  const alt = `data${camel.charAt(0).toUpperCase()}${camel.slice(1)}`
+  return node.properties[alt]
+}
+
+function extractPre(highlighted: Root | Element): Element | null {
+  if (isElement(highlighted) && highlighted.tagName === 'pre') return highlighted
+
+  const root = highlighted as Root
+  const first = (root.children || []).find(isElement)
+  if (first && first.tagName === 'pre') return first
+
+  return null
+}
+
+const DEFAULT_EXCLUDED_LANGS = ['mermaid', 'math']
+
+const rehypeShiki: Plugin<[RehypeShikiOptions], Root> = (options: RehypeShikiOptions) => {
+  const excluded = new Set([...(options.excludeLangs ?? DEFAULT_EXCLUDED_LANGS)].map(s => s.toLowerCase()))
+  const wrap = options.wrap ?? false
+  const langAlias = normalizeAliasMap({ ...DEFAULT_LANG_ALIAS, ...(options.langAlias ?? {}) })
+
+  let highlighterPromise: Promise | null = null
+
+  async function getHighlighter(): Promise {
+    if (highlighterPromise) return highlighterPromise
+
+    highlighterPromise = (async () => {
+      // Shiki requires themes to be loaded before use.
+      const highlighter = await createHighlighter({
+        themes: getThemeNames(options.themes),
+        langs: [],
+      })
+
+      return highlighter as unknown as HighlighterLike
+    })()
+
+    // If initialization fails (e.g., during a Vite dev-server hot restart where
+    // the module runner is being torn down), clear the cached promise so a
+    // subsequent transform can retry.
+    highlighterPromise = highlighterPromise.catch((error: unknown) => {
+      highlighterPromise = null
+      throw error
+    })
+
+    return highlighterPromise
+  }
+
+  return async (tree: Root) => {
+    const highlighter = await getHighlighter()
+
+    const replacements: Array<{
+      parent: Parent
+      index: number
+      original: Element
+      lang: string
+      codeText: string
+    }> = []
+
+    visit(tree, 'element', (node: Element, index: number | undefined, parent: Parent | undefined) => {
+      if (!parent || index === undefined) return
+      if (node.tagName !== 'pre') return
+      if (!isParent(parent)) return
+      if (hasShikiClass(node)) return
+
+      const codeChild = node.children.find(isElement)
+      if (!codeChild || codeChild.tagName !== 'code') return
+
+      const rawLang = parseLanguageFromCode(codeChild)
+      if (!rawLang) return
+
+      const lang = normalizeLanguage(rawLang, langAlias)
+      if (excluded.has(lang.toLowerCase())) return
+
+      const codeText = getText(codeChild)
+      if (!codeText.trim()) return
+
+      replacements.push({ parent, index, original: node, lang, codeText })
+    })
+
+    for (const replacement of replacements) {
+      try {
+        await highlighter.loadLanguage(replacement.lang)
+      } catch {
+        // Unknown language; keep original markup.
+        continue
+      }
+
+      const shikiOptions: {
+        lang: string
+        themes: ShikiThemes
+        defaultColor?: 'light' | 'dark' | false
+        wrap?: boolean
+      } = {
+        lang: replacement.lang,
+        themes: options.themes,
+        wrap: false,
+      }
+
+      if (options.defaultColor !== undefined) {
+        shikiOptions.defaultColor = options.defaultColor
+      }
+
+      const highlighted = highlighter.codeToHast(replacement.codeText, shikiOptions)
+      const highlightedPre = extractPre(highlighted)
+      if (!highlightedPre) continue
+
+      normalizeShikiProperties(highlightedPre)
+
+      // Preserve existing properties from the original 
 (not classes; those are merged).
+      const existingProps = replacement.original.properties || {}
+      highlightedPre.properties = highlightedPre.properties || {}
+
+      // Some pipelines attach custom data props to the  child rather than the 
.
+      // Promote them onto the 
 so downstream consumers (like ) can rely on them.
+      const originalCodeChild = replacement.original.children.find(isElement) || null
+      for (const key of ['data-code-tabs-group', 'data-code-tabs-tab']) {
+        const fromPre = existingProps[key]
+        const fromCode = getDataPropValue(originalCodeChild, key)
+        const value = fromPre ?? fromCode
+        if (value !== undefined) highlightedPre.properties[key] = value as never
+      }
+
+      for (const [key, value] of Object.entries(existingProps)) {
+        if (key === 'className' || key === 'class') continue
+        if (key === 'data-code-tabs-group' || key === 'data-code-tabs-tab') continue
+        highlightedPre.properties[key] = value as never
+      }
+
+      highlightedPre.properties['tabIndex'] = 0
+      highlightedPre.properties['data-language'] = replacement.lang
+
+      highlightedPre.properties['className'] = mergeClassNames(
+        highlightedPre.properties['className'],
+        [
+          'overflow-x-auto',
+          wrap ? 'whitespace-pre-wrap' : 'whitespace-pre',
+        ],
+      )
+
+      replacement.parent.children[replacement.index] = highlightedPre
+    }
+  }
+}
+
+export default rehypeShiki
diff --git a/src/lib/markdown/plugins/rehype-tailwind/__tests__/html.spec.ts b/src/lib/markdown/plugins/rehype-tailwind/__tests__/index.spec.ts
similarity index 100%
rename from src/lib/markdown/plugins/rehype-tailwind/__tests__/html.spec.ts
rename to src/lib/markdown/plugins/rehype-tailwind/__tests__/index.spec.ts
diff --git a/src/lib/markdown/plugins/remark-attribution/__tests__/remark-attribution.spec.ts b/src/lib/markdown/plugins/remark-attribution/__tests__/index.spec.ts
similarity index 100%
rename from src/lib/markdown/plugins/remark-attribution/__tests__/remark-attribution.spec.ts
rename to src/lib/markdown/plugins/remark-attribution/__tests__/index.spec.ts
diff --git a/src/lib/markdown/plugins/remark-code-tabs/__tests__/index.spec.ts b/src/lib/markdown/plugins/remark-code-tabs/__tests__/index.spec.ts
new file mode 100644
index 000000000..801e94b0c
--- /dev/null
+++ b/src/lib/markdown/plugins/remark-code-tabs/__tests__/index.spec.ts
@@ -0,0 +1,34 @@
+import { describe, it, expect } from 'vitest'
+import remarkCodeTabs, { parseGroupMeta } from '@lib/markdown/plugins/remark-code-tabs'
+import { processIsolated } from '@lib/markdown/helpers/processors'
+
+describe('remark-code-tabs (Layer 1: Isolated)', () => {
+  it('should parse [group:tab] tokens and return cleaned meta', () => {
+    expect(parseGroupMeta('[g1:JavaScript]')).toEqual({
+      group: 'g1',
+      tab: 'JavaScript',
+      cleanedMeta: undefined,
+    })
+
+    expect(parseGroupMeta('title=foo [g1:JS]')).toEqual({
+      group: 'g1',
+      tab: 'JS',
+      cleanedMeta: 'title=foo',
+    })
+
+    expect(parseGroupMeta('title=foo')).toBeNull()
+  })
+
+  it('should attach data attributes to fenced code blocks', async () => {
+    const markdown = ['```js [g1:JavaScript]', 'console.log(1)', '```'].join('\n')
+
+    const html = await processIsolated({
+      markdown,
+      plugin: remarkCodeTabs,
+      stage: 'remark',
+    })
+
+    expect(html).toContain('data-code-tabs-group="g1"')
+    expect(html).toContain('data-code-tabs-tab="JavaScript"')
+  })
+})
diff --git a/src/lib/markdown/plugins/remark-code-tabs/index.ts b/src/lib/markdown/plugins/remark-code-tabs/index.ts
new file mode 100644
index 000000000..770759803
--- /dev/null
+++ b/src/lib/markdown/plugins/remark-code-tabs/index.ts
@@ -0,0 +1,52 @@
+import type { Code, Root } from 'mdast'
+import type { Plugin } from 'unified'
+import { visit } from 'unist-util-visit'
+
+type GroupMeta = {
+  group: string
+  tab: string
+  /** Meta string with the [group:tab] token removed */
+  cleanedMeta: string | undefined
+}
+
+const groupTokenRegex = /\[(?[^\]:]+):(?[^\]]+)\]/
+
+function parseGroupMeta(meta: string | null | undefined): GroupMeta | null {
+  if (!meta) return null
+
+  const match = groupTokenRegex.exec(meta)
+  if (!match || !match.groups) return null
+
+  const group = match.groups['group']?.trim()
+  const tab = match.groups['tab']?.trim()
+
+  if (!group || !tab) return null
+
+  const cleanedMeta = meta.replace(match[0], '').trim() || undefined
+
+  return { group, tab, cleanedMeta }
+}
+
+const remarkCodeTabs: Plugin<[], Root> = () => {
+  return tree => {
+    visit(tree, 'code', (node: Code) => {
+      const parsed = parseGroupMeta(node.meta)
+      if (!parsed) return
+
+      node.meta = parsed.cleanedMeta
+
+      node.data = node.data || {}
+
+      const existingProps = (node.data as { hProperties?: Record }).hProperties || {}
+
+      ;(node.data as { hProperties: Record }).hProperties = {
+        ...existingProps,
+        'data-code-tabs-group': parsed.group,
+        'data-code-tabs-tab': parsed.tab,
+      }
+    })
+  }
+}
+
+export default remarkCodeTabs
+export { parseGroupMeta }
diff --git a/src/lib/markdown/__tests__/units/remark-custom-blocks.spec.ts b/src/lib/markdown/plugins/remark-custom-blocks/__tests__/index.spec.ts
similarity index 100%
rename from src/lib/markdown/__tests__/units/remark-custom-blocks.spec.ts
rename to src/lib/markdown/plugins/remark-custom-blocks/__tests__/index.spec.ts
diff --git a/src/lib/markdown/__tests__/units/remark-mark-plus.spec.ts b/src/lib/markdown/plugins/remark-mark-plus/__tests__/index.spec.ts
similarity index 100%
rename from src/lib/markdown/__tests__/units/remark-mark-plus.spec.ts
rename to src/lib/markdown/plugins/remark-mark-plus/__tests__/index.spec.ts
diff --git a/src/styles/markdown.css b/src/styles/markdown.css
index c4f2457cd..4c83c2f6f 100644
--- a/src/styles/markdown.css
+++ b/src/styles/markdown.css
@@ -10,6 +10,18 @@
   overflow-x: auto;
 }
 
+.markdown-content__prose code-tabs {
+  display: block;
+  max-width: 100%;
+  min-width: 0;
+  overflow: hidden;
+  width: 100%;
+}
+
+.markdown-content__prose code-tabs pre {
+  max-width: 100%;
+}
+
 .markdown-content__prose table {
   display: block;
   overflow-x: auto;
diff --git a/src/styles/shiki.css b/src/styles/shiki.css
index d80b916c7..6eb993631 100644
--- a/src/styles/shiki.css
+++ b/src/styles/shiki.css
@@ -14,6 +14,22 @@ each line is wrapped in a  so line number
 can be done like this, here with a different starting number:
 ...
 */
+
+/** CodeTabs custom CSS variables */
+:root {
+  --astro-code-color-text: white;
+  --astro-code-color-background: black;
+  --astro-code-token-constant: plum;
+  --astro-code-token-string: purple;
+  --astro-code-token-comment: tomato;
+  --astro-code-token-keyword: darkslategrey;
+  --astro-code-token-parameter: coral;
+  --astro-code-token-function: green;
+  --astro-code-token-string-expression: chartreuse;
+  --astro-code-token-punctuation: gray;
+  --astro-code-token-link: firebrick;
+}
+
 code {
 	counter-increment: step calc(var(--start, 1) - 1);
 	counter-reset: step;