',
+ );
+ expect(html).toContain("
Currency | ");
+ expect(html).toContain("
EUR | ");
+ });
+
+ it("leaves tables owned by components alone", () => {
+ const componentTable = defineHastPlugin({
+ name: "component-table",
+ element: {
+ filter: ["table"],
+ visit(node, ctx) {
+ ctx.setProperty(node, "className", ["custom"]);
+ },
+ },
+ });
+ const { html } = markdownToHtml("| Name |\n| --- |\n| Example |", {
+ features: { gfm: true },
+ hastPlugins: [componentTable, tableScroll],
+ });
+
+ expect(html).toContain('class="custom"');
+ expect(html).not.toContain("nb-table-scroll");
+ });
+});
diff --git a/src/plugins/satteri/table-scroll.ts b/src/plugins/satteri/table-scroll.ts
new file mode 100644
index 00000000..1f09eefa
--- /dev/null
+++ b/src/plugins/satteri/table-scroll.ts
@@ -0,0 +1,26 @@
+import { defineHastPlugin } from "satteri";
+
+// Like Cloudflare Docs, make wide Markdown tables keyboard-scrollable.
+// Classed tables belong to components and keep their own layout.
+export default defineHastPlugin({
+ name: "sumup-table-scroll",
+ element: {
+ filter: ["table"],
+ visit(node, ctx) {
+ const className = node.properties.className;
+ if (Array.isArray(className) ? className.length > 0 : className) return;
+
+ ctx.wrapNode(node, {
+ type: "element",
+ tagName: "div",
+ properties: {
+ className: ["nb-table-scroll"],
+ tabIndex: 0,
+ role: "region",
+ "aria-label": "Table",
+ },
+ children: [],
+ });
+ },
+ },
+});
diff --git a/src/styles/callout.css b/src/styles/callout.css
index e5e2294f..da9be917 100644
--- a/src/styles/callout.css
+++ b/src/styles/callout.css
@@ -57,36 +57,25 @@
letter-spacing: var(--cui-letter-spacing);
}
-/* Starlight adds vertical spacing between all block siblings in prose. The icon
- * and content are flex columns, so that generic spacing must not apply here. */
-.sumup-callout__icon + .sumup-callout__content {
- margin-top: 0;
-}
-
.sumup-callout__title {
margin: 0 0 var(--cui-spacings-bit);
font-weight: var(--cui-font-weight-semibold);
}
-/* The title already owns the Circuit-sized gap before the callout body. */
-.sumup-callout__title + * {
- margin-top: 0;
+/* Callouts own their rich-content spacing, independent of article prose. */
+.sumup-callout__content > :not(.sumup-callout__title) + * {
+ margin-block-start: var(--cui-spacings-kilo);
}
-.sumup-callout__content > :first-child,
-.sumup-callout__content > .sl-markdown-content > :first-child {
- margin-top: 0;
+.sumup-callout__content :where(ul, ol) {
+ padding-inline-start: 1.5rem;
}
-.sumup-callout__content > :last-child,
-.sumup-callout__content > .sl-markdown-content > :last-child {
- margin-bottom: 0;
+.sumup-callout__content :where(li + li) {
+ margin-block-start: var(--cui-spacings-bit);
}
-.sumup-callout :is(.expressive-code, .expressive-code .frame, pre) {
+.sumup-callout pre {
max-width: 100%;
-}
-
-.sumup-callout :is(.expressive-code .frame pre, pre) {
overflow-x: auto;
}
diff --git a/src/styles/code.css b/src/styles/code.css
new file mode 100644
index 00000000..6fc56e10
--- /dev/null
+++ b/src/styles/code.css
@@ -0,0 +1,242 @@
+:root {
+ /* The editor surfaces from Shiki's github-light / github-dark themes. */
+ --code-frame-background: light-dark(#fff, #24292e);
+ --code-frame-header-background: color-mix(
+ in srgb,
+ var(--cui-bg-subtle) 50%,
+ var(--code-frame-background)
+ );
+}
+
+.nb-code-figure {
+ position: relative;
+ max-width: 100%;
+ margin: var(--cui-spacings-mega) 0;
+ overflow: hidden;
+ background: var(--code-frame-background);
+ border: var(--cui-border-width-kilo) solid var(--cui-border-subtle);
+ border-radius: var(--cui-border-radius-byte);
+}
+
+pre.astro-code {
+ max-width: 100%;
+ max-height: min(68vh, 62.5rem);
+ padding: var(--cui-spacings-kilo) var(--cui-spacings-mega);
+ margin: 0;
+ overflow: auto;
+ border: 0;
+ border-radius: 0;
+ color: var(--cui-fg-normal);
+ background: transparent;
+ font-family: var(--cui-font-stack-mono);
+ font-size: var(--portal-font-size-mono);
+ /* Keep mobile browsers from inflating wide code; page zoom still works. */
+ -webkit-text-size-adjust: 100%;
+ text-size-adjust: 100%;
+ line-height: 1.625;
+ tab-size: 4;
+ scrollbar-width: thin;
+}
+
+pre.astro-code code {
+ display: block;
+ width: max-content;
+ min-width: 100%;
+ padding: 0;
+ background: transparent;
+ white-space: pre;
+ counter-reset: code-line;
+}
+
+/* Nimbus emits these shared token variables in /_nimbus/shiki.css. */
+pre.astro-code span {
+ color: var(--shiki-light, inherit);
+}
+
+[data-mode="dark"] pre.astro-code span {
+ color: var(--shiki-dark, inherit);
+}
+
+.nb-code-title {
+ display: flex;
+ align-items: center;
+ gap: var(--cui-spacings-byte);
+ min-height: 2.5rem;
+ padding: var(--cui-spacings-byte) 2.75rem var(--cui-spacings-byte)
+ var(--cui-spacings-kilo);
+ border-bottom: var(--cui-border-width-kilo) solid var(--cui-border-subtle);
+ background: var(--code-frame-header-background);
+ color: var(--cui-fg-normal);
+ font-family: var(--cui-font-stack-mono);
+ font-size: var(--portal-font-size-mono);
+ line-height: 1.5;
+}
+
+.nb-code-title-name {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.nb-code-title-lang {
+ display: none;
+}
+
+/* Nimbus supplies the language; no client script is needed for the label. */
+.nb-code-figure[data-nb-lang]:not(.nb-code-figure-titled)::before {
+ content: attr(data-nb-lang);
+ position: absolute;
+ top: var(--cui-spacings-byte);
+ right: var(--cui-spacings-byte);
+ z-index: 1;
+ padding: 0 var(--cui-spacings-bit);
+ border-radius: var(--cui-border-radius-bit);
+ background: var(--code-frame-header-background);
+ color: var(--cui-fg-subtle);
+ font-family: var(--cui-font-stack-mono);
+ font-size: var(--portal-font-size-mono);
+ line-height: 1.75;
+ text-transform: uppercase;
+ pointer-events: none;
+ transition: opacity var(--cui-transitions-default);
+}
+
+.nb-code-figure[data-nb-lang]:not(.nb-code-figure-titled):is(
+ :hover,
+ :focus-within
+ )::before {
+ opacity: 0;
+}
+
+.nb-code-figure:is(
+ [data-nb-lang="text"],
+ [data-nb-lang="plaintext"],
+ [data-nb-lang="plain"],
+ [data-nb-lang="txt"]
+ )::before {
+ display: none;
+}
+
+.nb-code-figure:not(.nb-code-figure-titled) > pre.astro-code {
+ padding-right: 3.5rem;
+}
+
+.nb-code-copy {
+ position: absolute;
+ top: var(--cui-spacings-byte);
+ right: var(--cui-spacings-byte);
+ z-index: 2;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.75rem;
+ height: 1.75rem;
+ padding: 0;
+ color: var(--cui-fg-subtle);
+ background: var(--code-frame-background);
+ border: var(--cui-border-width-kilo) solid var(--cui-border-subtle);
+ border-radius: var(--cui-border-radius-bit);
+ cursor: pointer;
+ opacity: 0;
+ transition: opacity var(--cui-transitions-default);
+}
+
+.nb-code-figure:hover .nb-code-copy,
+.nb-code-figure:focus-within .nb-code-copy,
+.nb-code-copy:focus-visible {
+ opacity: 1;
+}
+
+.nb-code-figure-titled .nb-code-copy {
+ top: var(--cui-spacings-bit);
+ opacity: 1;
+ border-color: transparent;
+ background: transparent;
+}
+
+.nb-code-copy:hover {
+ color: var(--cui-fg-normal);
+ background: var(--code-frame-header-background);
+}
+
+.nb-code-copy:focus-visible {
+ outline: 2px solid var(--cui-border-focus);
+ outline-offset: 2px;
+}
+
+.nb-code-copy svg {
+ width: 0.875rem;
+ height: 0.875rem;
+}
+
+.nb-code-copy[data-state="copied"] {
+ color: var(--cui-fg-success);
+}
+
+pre.astro-code code > .line {
+ position: relative;
+ display: inline-block;
+ min-width: 100%;
+ /* Empty Shiki spans need a line box to keep their gutter numbers aligned. */
+ min-height: 1lh;
+ padding-inline-start: 4.5ch;
+ vertical-align: top;
+ counter-increment: code-line;
+}
+
+/* Generated numbers stay out of copied code and the accessibility text. */
+pre.astro-code code > .line::before {
+ content: counter(code-line) / "";
+ position: absolute;
+ inset-inline-start: 0;
+ width: 3ch;
+ color: var(--cui-fg-subtle);
+ text-align: end;
+ user-select: none;
+ pointer-events: none;
+}
+
+pre.astro-code .line.highlighted,
+pre.astro-code .highlighted-word {
+ background: var(--cui-bg-highlight);
+}
+
+pre.astro-code .line.diff.add {
+ background: var(--cui-bg-success);
+}
+
+pre.astro-code .line.diff.remove,
+pre.astro-code .line.error {
+ background: var(--cui-bg-danger);
+}
+
+pre.astro-code .line.warning {
+ background: var(--cui-bg-warning);
+}
+
+pre.astro-code.has-focused .line:not(.focused) {
+ opacity: 0.5;
+}
+
+pre.astro-code.has-focused:hover .line:not(.focused) {
+ opacity: 1;
+}
+
+pre.astro-code[data-nb-wrap] code {
+ width: 100%;
+ min-width: 0;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+@media (hover: none) {
+ .nb-code-copy {
+ opacity: 1;
+ }
+
+ .nb-code-figure[data-nb-lang]:not(.nb-code-figure-titled)::before {
+ display: none;
+ }
+}
diff --git a/src/styles/global.css b/src/styles/global.css
new file mode 100644
index 00000000..31db0bbc
--- /dev/null
+++ b/src/styles/global.css
@@ -0,0 +1,125 @@
+:root {
+ --portal-header-height: 3.5rem;
+ --portal-sidebar-width: 17rem;
+ --portal-toc-width: 15rem;
+ --portal-content-width: 50rem;
+ --portal-font-size-mono: 0.8125rem;
+}
+
+* {
+ box-sizing: border-box;
+ scrollbar-width: thin;
+}
+
+html {
+ background: var(--cui-bg-normal);
+ scroll-behavior: smooth;
+}
+
+body {
+ min-width: 20rem;
+ min-height: 100vh;
+ color: var(--cui-fg-normal);
+ background: var(--cui-bg-normal);
+ font-family: var(--cui-font-stack-default);
+ font-size: var(--cui-body-m-font-size);
+ line-height: var(--cui-body-m-line-height);
+ text-rendering: optimizeLegibility;
+}
+
+button,
+input,
+select,
+textarea {
+ font: inherit;
+}
+
+/* A fixed shared size keeps nested code from shrinking more than once. */
+code,
+pre,
+kbd,
+samp {
+ font-size: var(--portal-font-size-mono);
+}
+
+a {
+ color: var(--cui-fg-accent);
+}
+
+a,
+button {
+ touch-action: manipulation;
+}
+
+:where(h1, h2, h3, h4, h5, h6)[id] {
+ scroll-margin-top: calc(var(--portal-header-height) + 1.5rem);
+}
+
+:focus-visible {
+ outline: 2px solid var(--cui-border-focus);
+ outline-offset: 2px;
+}
+
+.visually-hidden {
+ position: absolute !important;
+ width: 1px !important;
+ height: 1px !important;
+ padding: 0 !important;
+ margin: -1px !important;
+ overflow: hidden !important;
+ clip: rect(0, 0, 0, 0) !important;
+ white-space: nowrap !important;
+ border: 0 !important;
+}
+
+.skip-link {
+ position: fixed;
+ z-index: 1000;
+ top: var(--cui-spacings-byte);
+ left: var(--cui-spacings-byte);
+ padding: var(--cui-spacings-byte) var(--cui-spacings-kilo);
+ color: var(--cui-fg-on-strong);
+ background: var(--cui-bg-strong);
+ border-radius: var(--cui-border-radius-byte);
+ transform: translateY(-200%);
+}
+
+.skip-link:focus {
+ transform: translateY(0);
+}
+
+a.external-link::after {
+ content: " \2197";
+}
+
+code[dir="auto"] {
+ color: var(--cui-fg-normal);
+ background-color: var(--cui-bg-subtle);
+ padding: 0 var(--cui-spacings-bit);
+ border-radius: var(--cui-border-radius-bit);
+ border: var(--cui-border-width-kilo) solid var(--cui-border-subtle);
+ font-size: var(--portal-font-size-mono);
+ overflow-wrap: anywhere;
+ white-space: normal;
+}
+
+body:has(#mobile-navigation[open], [data-search-dialog][open]) {
+ overflow: hidden;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ html {
+ scroll-behavior: auto;
+ }
+}
+
+@media print {
+ .portal-header,
+ .docs-sidebar,
+ .docs-toc,
+ .mobile-toc,
+ .page-actions,
+ .portal-footer-links {
+ display: none !important;
+ }
+}
diff --git a/src/styles/prose.css b/src/styles/prose.css
new file mode 100644
index 00000000..6c087376
--- /dev/null
+++ b/src/styles/prose.css
@@ -0,0 +1,187 @@
+.docs-content {
+ color: var(--cui-fg-normal);
+ overflow-wrap: anywhere;
+}
+
+/* Components marked with `data-prose="off"` own their typography and spacing. */
+.docs-content > :where(:first-child:not([data-prose="off"])) {
+ margin-top: 0;
+}
+
+.docs-content > :where(:last-child:not([data-prose="off"])) {
+ margin-bottom: 0;
+}
+
+/* Keep the opt-out in one low-specificity guard instead of every prose rule. */
+.docs-content :where(:not([data-prose="off"], [data-prose="off"] *)) {
+ &:where(h1, h2, h3, h4, h5, h6) {
+ color: var(--cui-fg-normal);
+ scroll-margin-top: calc(var(--portal-header-height) + 1.5rem);
+ text-wrap: balance;
+ }
+
+ &:where(h1) {
+ margin: var(--cui-spacings-giga) 0 var(--cui-spacings-mega);
+ font-size: var(--cui-headline-l-font-size);
+ font-weight: var(--cui-font-weight-bold);
+ line-height: var(--cui-headline-l-line-height);
+ }
+
+ &:where(h2) {
+ margin: var(--cui-spacings-exa) 0 var(--cui-spacings-mega);
+ font-size: var(--cui-headline-m-font-size);
+ font-weight: var(--cui-font-weight-bold);
+ line-height: var(--cui-headline-m-line-height);
+ }
+
+ &:where(h3) {
+ margin: var(--cui-spacings-tera) 0 var(--cui-spacings-kilo);
+ font-size: var(--cui-headline-s-font-size);
+ font-weight: var(--cui-font-weight-bold);
+ line-height: var(--cui-headline-s-line-height);
+ }
+
+ &:where(h4, h5, h6) {
+ margin: var(--cui-spacings-giga) 0 var(--cui-spacings-kilo);
+ font-weight: var(--cui-font-weight-bold);
+ }
+
+ &:where(p, ul, ol, blockquote, table, pre, .nb-table-scroll) {
+ margin: var(--cui-spacings-mega) 0;
+ }
+
+ &:where(ul, ol) {
+ padding-inline-start: 1.5rem;
+ }
+
+ &:where(li) + li {
+ margin-top: var(--cui-spacings-byte);
+ }
+
+ &:where(a) {
+ text-decoration-thickness: 1px;
+ text-underline-offset: 0.15em;
+ }
+
+ &:where(a):hover {
+ color: var(--cui-fg-accent-hovered);
+ }
+
+ &:where(code:not(:where(pre *))) {
+ padding: 0.1em 0.3em;
+ color: var(--cui-fg-normal);
+ background: var(--cui-bg-subtle);
+ border: 1px solid var(--cui-border-subtle);
+ border-radius: var(--cui-border-radius-bit);
+ font-size: var(--portal-font-size-mono);
+ overflow-wrap: anywhere;
+ white-space: normal;
+ }
+
+ &:where(pre) {
+ position: relative;
+ max-width: 100%;
+ overflow: auto;
+ border: 1px solid var(--cui-border-subtle);
+ border-radius: var(--cui-border-radius-byte);
+ scrollbar-width: thin;
+ }
+
+ &:where(pre) code {
+ display: block;
+ min-width: max-content;
+ padding: var(--cui-spacings-mega);
+ }
+
+ &:where(blockquote) {
+ padding: var(--cui-spacings-kilo) var(--cui-spacings-mega);
+ color: var(--cui-fg-subtle);
+ background: var(--cui-bg-subtle);
+ border-inline-start: 3px solid var(--cui-border-strong);
+ }
+
+ &:where(img, video) {
+ max-width: 100%;
+ height: auto;
+ }
+
+ &:where(table) {
+ width: 100%;
+ border-collapse: separate;
+ border-spacing: 0;
+ font-size: var(--cui-body-s-font-size);
+ line-height: var(--cui-body-s-line-height);
+ }
+
+ &:where(th, td) {
+ padding: var(--cui-spacings-kilo);
+ text-align: start;
+ vertical-align: top;
+ border-block-end: 1px solid var(--cui-border-divider);
+ }
+
+ &:where(th) {
+ background: var(--cui-bg-subtle);
+ font-weight: var(--cui-font-weight-semibold);
+ white-space: nowrap;
+ }
+
+ /* Count visible rows so filtered tables keep their alternating backgrounds. */
+ &:where(tbody > tr:nth-child(even of :not([hidden]))) {
+ background: var(--cui-bg-subtle);
+ }
+
+ &:where(tbody > tr:hover) {
+ background: var(--cui-bg-normal-hovered);
+ }
+
+ &:where(table > :last-child > tr:nth-last-child(1 of :not([hidden])) > th),
+ &:where(table > :last-child > tr:nth-last-child(1 of :not([hidden])) > td) {
+ border-block-end: 0;
+ }
+
+ &:where(.heading-anchor) {
+ margin-inline-start: 0.35em;
+ color: var(--cui-fg-subtle);
+ opacity: 0;
+ text-decoration: none;
+ }
+
+ &:where(h2, h3, h4, h5, h6):hover .heading-anchor,
+ &:where(.heading-anchor):focus {
+ opacity: 1;
+ }
+}
+
+.nb-table-scroll {
+ max-width: 100%;
+ overflow-x: auto;
+ border: 1px solid var(--cui-border-divider);
+ border-radius: var(--cui-border-radius-byte);
+}
+
+.nb-table-scroll > table {
+ margin: 0;
+}
+
+.nb-table-scroll:focus-visible {
+ outline: 2px solid var(--cui-border-accent);
+ outline-offset: 2px;
+}
+
+/* Circuit UI owns the Callout's appearance; these only support rich MDX body content. */
+[data-callout] > :last-child {
+ min-width: 0;
+}
+
+[data-callout] :is(ul, ol) {
+ padding-inline-start: 1.5rem;
+}
+.docs-content img[role="button"] {
+ cursor: zoom-in;
+}
+
+.docs-content img[role="button"]:focus-visible {
+ outline: 2px solid var(--cui-border-focus);
+ outline-offset: 4px;
+}
diff --git a/src/styles/reset.css b/src/styles/reset.css
new file mode 100644
index 00000000..23f326d9
--- /dev/null
+++ b/src/styles/reset.css
@@ -0,0 +1,123 @@
+/* http://meyerweb.com/eric/tools/css/reset/
+ v2.0 | 20110126
+ License: none (public domain)
+*/
+
+html,
+body,
+div,
+span,
+applet,
+object,
+iframe,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+p,
+blockquote,
+pre,
+a,
+abbr,
+acronym,
+address,
+big,
+cite,
+code,
+del,
+dfn,
+em,
+img,
+ins,
+kbd,
+q,
+s,
+samp,
+small,
+strike,
+strong,
+sub,
+sup,
+tt,
+var,
+b,
+u,
+i,
+center,
+dl,
+dt,
+dd,
+ol,
+ul,
+li,
+fieldset,
+form,
+label,
+legend,
+table,
+caption,
+tbody,
+tfoot,
+thead,
+tr,
+th,
+td,
+article,
+aside,
+canvas,
+details,
+embed,
+figure,
+figcaption,
+footer,
+header,
+hgroup,
+menu,
+nav,
+output,
+ruby,
+section,
+summary,
+time,
+mark,
+audio,
+video {
+ padding: 0;
+ margin: 0;
+ border: 0;
+}
+
+/* HTML5 display-role reset for older browsers */
+article,
+aside,
+details,
+figcaption,
+figure,
+footer,
+header,
+hgroup,
+menu,
+nav,
+section {
+ display: block;
+}
+
+blockquote,
+q {
+ quotes: none;
+}
+
+blockquote::before,
+blockquote::after,
+q::before,
+q::after {
+ content: "";
+ content: none;
+}
+
+table {
+ border-spacing: 0;
+ border-collapse: collapse;
+}
diff --git a/src/styles/starlight-vars.css b/src/styles/starlight-vars.css
deleted file mode 100644
index f12b3562..00000000
--- a/src/styles/starlight-vars.css
+++ /dev/null
@@ -1,79 +0,0 @@
-/* Customization of Starlight beyond the theme colors. */
-:root,
-:root[data-theme="light"] {
- /* Layout sizing */
- --sl-content-width: 50rem;
-
- /* Custom colors for page elements */
- --sl-color-hairline-shade: var(--cui-border-subtle);
- --sl-color-bg: var(--cui-bg-normal);
- --sl-color-bg-inline-code: var(--cui-bg-subtle);
-
- /* Typography */
- --sl-text-h1: var(--cui-headline-l-font-size);
- --sl-text-h2: var(--cui-headline-m-font-size);
- --sl-text-h3: var(--cui-headline-s-font-size);
- --sl-text-h4: var(--sl-text-base);
- --sl-text-h5: var(--sl-text-base);
-
- --sl-color-blue-high: var(--cui-fg-normal);
- --sl-color-blue: var(--cui-border-strong);
- --sl-color-blue-low: var(--cui-bg-subtle);
-
- --sl-color-purple-high: var(--cui-fg-promo);
- --sl-color-purple: var(--cui-border-promo);
- --sl-color-purple-low: var(--cui-bg-promo);
-
- --sl-color-orange-high: var(--cui-fg-warning);
- --sl-color-orange: var(--cui-border-warning);
- --sl-color-orange-low: var(--cui-bg-warning);
-}
-
-/* Bring Starlight LinkButton closer to Circuit UI button styling. */
-.sl-link-button {
- border-radius: var(--cui-border-radius-byte);
- font-weight: var(--cui-font-weight-semibold);
- line-height: var(--cui-body-m-line-height);
- padding: calc(var(--cui-spacings-kilo) - var(--cui-border-width-kilo)) calc(var(--cui-spacings-giga) - var(--cui-border-width-kilo));
- transition:
- opacity var(--cui-transitions-default),
- color var(--cui-transitions-default),
- background-color var(--cui-transitions-default),
- border-color var(--cui-transitions-default);
-}
-
-.sl-link-button.primary {
- color: var(--cui-fg-on-strong);
- background-color: var(--cui-bg-accent-strong);
- border-color: transparent;
-}
-
-.sl-link-button.primary:hover {
- color: var(--cui-fg-on-strong-hovered);
- background-color: var(--cui-bg-accent-strong-hovered);
- border-color: transparent;
-}
-
-.sl-link-button.primary:active {
- color: var(--cui-fg-on-strong-pressed);
- background-color: var(--cui-bg-accent-strong-pressed);
- border-color: transparent;
-}
-
-.sl-link-button.secondary {
- color: var(--cui-fg-normal);
- background-color: var(--cui-bg-normal);
- border-color: var(--cui-border-normal);
-}
-
-.sl-link-button.secondary:hover {
- color: var(--cui-fg-normal-hovered);
- background-color: var(--cui-bg-subtle-hovered);
- border-color: var(--cui-border-normal-hovered);
-}
-
-.sl-link-button.secondary:active {
- color: var(--cui-fg-normal-pressed);
- background-color: var(--cui-bg-subtle-pressed);
- border-color: var(--cui-border-normal-pressed);
-}
\ No newline at end of file
diff --git a/src/styles/theme-dark.css b/src/styles/theme-dark.css
deleted file mode 100644
index c4175f57..00000000
--- a/src/styles/theme-dark.css
+++ /dev/null
@@ -1,14 +0,0 @@
-/* Dark mode colors. */
-:root {
- --sl-color-accent-low: #242424;
- --sl-color-accent: var(--cui-fg-accent);
- --sl-color-accent-high: #c8c8c8;
- --sl-color-white: var(--cui-fg-normal);
- --sl-color-gray-1: var(--cui-fg-normal);
- --sl-color-gray-2: #c0c2c7;
- --sl-color-gray-3: #888b96;
- --sl-color-gray-4: #545861;
- --sl-color-gray-5: #353841;
- --sl-color-gray-6: #24272f;
- --sl-color-black: var(--cui-bg-normal);
-}
\ No newline at end of file
diff --git a/src/styles/theme-light.css b/src/styles/theme-light.css
deleted file mode 100644
index 969b0493..00000000
--- a/src/styles/theme-light.css
+++ /dev/null
@@ -1,15 +0,0 @@
-/* Light mode colors. */
-:root[data-theme="light"] {
- --sl-color-accent-low: #d7d7d7;
- --sl-color-accent: var(--cui-fg-accent);
- --sl-color-accent-high: #323232;
- --sl-color-white: var(--cui-fg-normal);
- --sl-color-gray-1: #24272f;
- --sl-color-gray-2: #353841;
- --sl-color-gray-3: #545861;
- --sl-color-gray-4: #888b96;
- --sl-color-gray-5: #c0c2c7;
- --sl-color-gray-6: #eceef2;
- --sl-color-gray-7: #f5f6f8;
- --sl-color-black: #ffffff;
-}
diff --git a/src/styles/utilities.css b/src/styles/utilities.css
deleted file mode 100644
index 28079a0d..00000000
--- a/src/styles/utilities.css
+++ /dev/null
@@ -1,28 +0,0 @@
-* {
- scrollbar-width: thin;
-}
-
-.visually-hidden {
- position: absolute !important;
- width: 1px !important;
- height: 1px !important;
- padding: 0 !important;
- margin: -1px !important;
- overflow: hidden !important;
- clip: rect(0, 0, 0, 0) !important;
- white-space: nowrap !important;
- border: 0 !important;
-}
-
-a.external-link::after {
- content: " \2197";
-}
-
-code[dir="auto"] {
- color: var(--cui-fg-normal);
- background-color: var(--cui-bg-subtle);
- padding: 0 var(--cui-spacings-bit);
- border-radius: var(--cui-border-radius-bit);
- border: var(--cui-border-width-kilo) solid var(--cui-border-subtle);
- font-size: 0.9em;
-}
diff --git a/src/types/openapi.ts b/src/types/openapi.ts
deleted file mode 100644
index d15040d9..00000000
--- a/src/types/openapi.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import type { OpenAPIV3_1 } from "openapi-types";
-
-type Modify
= Omit & R;
-
-export type TagObject = OpenAPIV3_1.TagObject & {
- slug: string;
- "x-beta"?: boolean;
- "x-core-objects"?: OpenAPIV3_1.ReferenceObject[];
- "x-deprecation-notice"?: string;
-};
-
-export type Document = Modify<
- OpenAPIV3_1.Document,
- {
- tags: TagObject[];
- }
->;
-
-export type PermissionRequirements = (
- | string
- | {
- relation: string;
- object_type: string;
- object_id_param: string;
- }
-)[];
-
-export type OperationObject = Modify<
- OpenAPIV3_1.OperationObject<{
- "x-beta"?: boolean;
- "x-scopes"?: string[];
- "x-permissions"?: PermissionRequirements;
- "x-codegen"?: {
- method_name: string;
- };
- }>,
- {
- parameters?: OpenAPIV3_1.ParameterObject[];
- requestBody?: OpenAPIV3_1.RequestBodyObject;
- responses?: OpenAPIV3_1.ResponsesObject;
- }
-> & {
- path: string;
- method: string;
- slug: string;
- tag: string;
- tagSlug: string;
-};
-
-export type Group = {
- objects: OpenAPIV3_1.ComponentsObject[];
- operations: OperationObject[];
-};
diff --git a/src/types/sidebar.ts b/src/types/sidebar.ts
deleted file mode 100644
index bf51369b..00000000
--- a/src/types/sidebar.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import type { StarlightRouteData } from "@astrojs/starlight/route-data";
-
-export type Link = Extract<
- StarlightRouteData["sidebar"][0],
- { type: "link" }
-> & {
- order?: number;
- icon?: string;
-};
-
-export type Group = Extract<
- StarlightRouteData["sidebar"][0],
- { type: "group" }
-> & {
- order?: number;
- icon?: string;
- entries: (Link | Group)[];
-};
-
-export type SidebarEntry = Link | Group;
diff --git a/src/utils/docsSidebar.ts b/src/utils/docsSidebar.ts
new file mode 100644
index 00000000..4bc0050b
--- /dev/null
+++ b/src/utils/docsSidebar.ts
@@ -0,0 +1,17 @@
+import type { SidebarItem } from "@cloudflare/nimbus-docs/types";
+
+const normalizePath = (path: string) => path.replace(/\/$/, "") || "/";
+
+export const addDocsHome = (
+ items: SidebarItem[],
+ currentPath: string,
+): SidebarItem[] => [
+ {
+ type: "link",
+ label: "Home",
+ href: "/",
+ isCurrent: normalizePath(currentPath) === "/",
+ order: -1,
+ },
+ ...items,
+];
diff --git a/src/utils/ogImageResponse.ts b/src/utils/ogImageResponse.ts
index da9e3ec3..fd876534 100644
--- a/src/utils/ogImageResponse.ts
+++ b/src/utils/ogImageResponse.ts
@@ -1,5 +1,5 @@
-import { Resvg } from "@resvg/resvg-js";
import satori from "satori";
+import sharp from "sharp";
import type { ReactNode } from "react";
import type { Font, SatoriOptions } from "satori";
@@ -24,14 +24,7 @@ export async function createOgImageResponse(
debug: options.debug,
fonts: options.fonts,
} satisfies SatoriOptions);
- const png = new Resvg(svg, {
- fitTo: {
- mode: "width",
- value: options.width,
- },
- })
- .render()
- .asPng();
+ const png = await sharp(Buffer.from(svg)).png().toBuffer();
const body = png.buffer.slice(
png.byteOffset,
png.byteOffset + png.byteLength,
diff --git a/src/utils/redirects.test.ts b/src/utils/redirects.test.ts
new file mode 100644
index 00000000..67f52ab4
--- /dev/null
+++ b/src/utils/redirects.test.ts
@@ -0,0 +1,40 @@
+import { execFileSync } from "node:child_process";
+import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { afterEach, beforeEach, expect, it } from "vitest";
+
+let output: string;
+beforeEach(() => {
+ output = mkdtempSync(join(tmpdir(), "sumup-redirects-"));
+ mkdirSync(join(output, "guide"));
+ writeFileSync(join(output, "guide/index.html"), 'Guide
');
+});
+afterEach(() => rmSync(output, { recursive: true, force: true }));
+const check = (redirects: string) => {
+ writeFileSync(join(output, "_redirects"), redirects);
+ return execFileSync(
+ process.execPath,
+ [resolve("scripts/check-redirects.mjs"), output],
+ { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
+ );
+};
+
+it("validates unused redirects, chains, and destination fragments", () => {
+ expect(
+ check("/first /second 301\n/second /guide/#start 301\n/old/* /:splat 301"),
+ ).toContain("2 static redirect destinations");
+});
+
+it.each([
+ "/unused /missing 301",
+ "/first /second 301\n/second /first 301",
+ "/first /guide/#missing 301",
+ "/first /guide/ 301\n/first /guide/ 302",
+ "/old/* /:splat 301\n/first /guide/ 301",
+])(
+ "rejects broken or ambiguous redirect rules even without links to them",
+ (redirects) => {
+ expect(() => check(redirects)).toThrow();
+ },
+);
diff --git a/src/utils/renderedLinks.test.ts b/src/utils/renderedLinks.test.ts
new file mode 100644
index 00000000..358c7810
--- /dev/null
+++ b/src/utils/renderedLinks.test.ts
@@ -0,0 +1,71 @@
+import { execFileSync } from "node:child_process";
+import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { afterEach, beforeEach, expect, it } from "vitest";
+
+let output: string;
+beforeEach(() => {
+ output = mkdtempSync(join(tmpdir(), "sumup-rendered-links-"));
+ mkdirSync(join(output, "guide"));
+ writeFileSync(
+ join(output, "guide/index.html"),
+ 'Guide
',
+ );
+ writeFileSync(
+ join(output, "_redirects"),
+ "/old/* /:splat 301\n/first /second 301\n/second /guide/ 301\n/loop /loop 301\n",
+ );
+});
+afterEach(() => rmSync(output, { recursive: true, force: true }));
+const check = () =>
+ execFileSync(
+ process.execPath,
+ [resolve("scripts/check-rendered-links.mjs"), output],
+ { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
+ );
+
+it("checks local fragments, relative URLs, and redirect chains outside the API reference", () => {
+ writeFileSync(
+ join(output, "index.html"),
+ `Home
+ HomeGuide
+ Legacy guideChain
+ External`,
+ );
+ expect(check()).toContain(
+ "4 internal links and 4 fragments across 2 rendered pages",
+ );
+});
+
+it.each(["/missing/", "/guide/#missing", "/loop"])(
+ "rejects a broken homepage link: %s",
+ (href) => {
+ writeFileSync(join(output, "index.html"), `Broken`);
+ expect(check).toThrow();
+ },
+);
+
+it.each(["", " ", "#"])("rejects an empty link destination: %j", (href) => {
+ writeFileSync(
+ join(output, "index.html"),
+ `Empty target`,
+ );
+ expect(check).toThrow();
+});
+
+it.each([
+ '
',
+ '
',
+])("rejects an image without an explicit alternative: %s", (image) => {
+ writeFileSync(join(output, "index.html"), image);
+ expect(check).toThrow();
+});
+
+it("accepts descriptive alt text and explicitly decorative images", () => {
+ writeFileSync(
+ join(output, "index.html"),
+ '
',
+ );
+ expect(check()).toContain("checked 2 image alt attributes");
+});
diff --git a/src/utils/staticHeaders.test.ts b/src/utils/staticHeaders.test.ts
new file mode 100644
index 00000000..04779bc6
--- /dev/null
+++ b/src/utils/staticHeaders.test.ts
@@ -0,0 +1,46 @@
+import { expect, it } from "vitest";
+import {
+ parseStaticHeaders,
+ staticHeaderValues,
+} from "../../scripts/lib/static-headers.mjs";
+
+const url = new URL("https://developer.sumup.com/.well-known/ai-catalog.json");
+
+it("detects CORS and MIME values duplicated across owner and generated rules", () => {
+ const rules = parseStaticHeaders(`/*
+ Access-Control-Allow-Origin: *
+/.well-known/ai-catalog.json
+ Content-Type: application/ai-catalog+json
+# Generated values extend the same rule.
+ Content-Type: application/json
+ Access-Control-Allow-Origin: *`);
+ expect(staticHeaderValues(rules, url, "access-control-allow-origin")).toEqual(
+ ["*", "*"],
+ );
+ expect(staticHeaderValues(rules, url, "content-type")).toHaveLength(2);
+});
+
+it("keeps scoped owner headers disjoint from native discovery headers", () => {
+ const rules = parseStaticHeaders(`/*.md
+ Access-Control-Allow-Origin: *
+/.well-known/ai-catalog.json
+ Cache-Control: public, max-age=3600
+ Content-Type: application/json
+ Access-Control-Allow-Origin: *
+https://:version.:subdomain.workers.dev/*
+ X-Robots-Tag: noindex`);
+ expect(staticHeaderValues(rules, url, "Access-Control-Allow-Origin")).toEqual(
+ ["*"],
+ );
+ expect(staticHeaderValues(rules, url, "Content-Type")).toEqual([
+ "application/json",
+ ]);
+ expect(
+ staticHeaderValues(
+ rules,
+ new URL("https://preview.portal.workers.dev/index.md"),
+ "X-Robots-Tag",
+ ),
+ ).toEqual(["noindex"]);
+ expect(staticHeaderValues(rules, url, "X-Robots-Tag")).toEqual([]);
+});
diff --git a/tests/worker.test.mjs b/tests/worker.test.mjs
new file mode 100644
index 00000000..ae11901a
--- /dev/null
+++ b/tests/worker.test.mjs
@@ -0,0 +1,121 @@
+import assert from "node:assert/strict";
+import { after, before, describe, it } from "node:test";
+import { readFile } from "node:fs/promises";
+import { createTestHarness } from "wrangler";
+
+// Exercise the built adapter and asset routing in workerd without deploying.
+const server = createTestHarness({
+ workers: [{ configPath: "dist/server/wrangler.json" }],
+});
+before(() => server.listen(), { timeout: 30_000 });
+after(() => server.close(), { timeout: 10_000 });
+
+describe("built documentation Worker", { timeout: 60_000 }, () => {
+ it("serves API pages and Markdown containing every SDK sample", async () => {
+ const page = await server.fetch("/api/checkouts/process/");
+ assert.equal(page.status, 200);
+ assert.match(await page.text(), /Process a checkout/);
+ const markdown = await server.fetch("/api/checkouts/process/index.md");
+ assert.equal(markdown.status, 200);
+ const text = await markdown.text();
+ for (const [sdk, language] of [
+ ["CLI", "bash"],
+ ["JavaScript", "typescript"],
+ [".NET", "csharp"],
+ ["Java", "java"],
+ ["Python", "python"],
+ ["PHP", "php"],
+ ["Go", "go"],
+ ["Rust", "rust"],
+ ["cURL", "bash"],
+ ]) {
+ assert(
+ text.includes(`### ${sdk}\n\n\`\`\`${language}\n`),
+ `Missing ${sdk} sample`,
+ );
+ }
+ });
+
+ it("redirects the API specification to the original public contract", async () => {
+ const response = await server.fetch("/api/openapi.json", {
+ redirect: "manual",
+ });
+ assert.equal(response.status, 301);
+ assert.equal(
+ new URL(response.headers.get("location"), "https://developer.sumup.com")
+ .pathname,
+ "/openapi.json",
+ );
+ const contract = await server.fetch("/openapi.json");
+ assert.equal(contract.status, 200);
+ assert.deepEqual(
+ await contract.json(),
+ JSON.parse(await readFile("openapi.json", "utf8")),
+ );
+ });
+
+ it("returns 404 for a missing API operation", async () => {
+ const response = await server.fetch("/api/not-a-real-operation/");
+ assert.equal(response.status, 404);
+ });
+
+ it("routes invalid contact submissions back to the contact page", async () => {
+ const response = await server.fetch("/contact/submit", {
+ method: "POST",
+ body: new URLSearchParams(),
+ redirect: "manual",
+ });
+ assert.equal(response.status, 303);
+ const target = new URL(
+ response.headers.get("location"),
+ "https://developer.sumup.com",
+ );
+ assert.equal(target.pathname, "/contact");
+ assert.equal(target.searchParams.get("status"), "error");
+ const page = await server.fetch(target.pathname + target.search);
+ assert.equal(page.status, 200);
+ assert.match(await page.text(), /action="\/contact\/submit"/);
+ });
+
+ it("serves documentation MCP metadata and operation Markdown", async () => {
+ const card = await server.fetch("/mcp/server-card");
+ assert.equal(card.status, 200);
+ assert.match(card.headers.get("content-type"), /json/);
+ const rpc = async (id, method, params) => {
+ const response = await server.fetch("/mcp", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "application/json, text/event-stream",
+ },
+ body: JSON.stringify({ jsonrpc: "2.0", id, method, params }),
+ });
+ assert.equal(response.status, 200);
+ const message = await response.json();
+ assert.equal(message.error, undefined);
+ return message.result;
+ };
+ await rpc(1, "initialize", {
+ protocolVersion: "2025-06-18",
+ capabilities: {},
+ clientInfo: { name: "docs-smoke-test", version: "1.0.0" },
+ });
+ const tools = await rpc(2, "tools/list", {});
+ for (const name of ["get_page", "list_pages"])
+ assert(tools.tools.some((tool) => tool.name === name));
+ const listing = await rpc(3, "tools/call", {
+ name: "list_pages",
+ arguments: { section: "api" },
+ });
+ assert(!listing.isError);
+ assert(JSON.parse(listing.content[0].text).pages.length > 0);
+ const result = await rpc(4, "tools/call", {
+ name: "get_page",
+ arguments: { url: "/api/checkouts/process/" },
+ });
+ assert(!result.isError);
+ const page = JSON.parse(result.content[0].text);
+ assert.match(page.content, /Process a checkout/);
+ assert.match(page.content, /<\?php/);
+ });
+});
diff --git a/tsconfig.json b/tsconfig.json
index deffabba..abc2df51 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -7,16 +7,12 @@
"name": "@astrojs/ts-plugin"
}
],
- "baseUrl": ".",
"verbatimModuleSyntax": true,
"paths": {
- "@components/*": ["src/components/*"],
- "@assets/*": ["src/assets/*"],
- "@types/*": ["src/types/*"],
- "@lib/*": ["src/lib/*"],
- "@services/*": ["src/services/*"],
- "@hooks/*": ["src/hooks/*"],
- "@api/*": ["src/api/*"]
+ "@/*": ["./src/*"],
+ "@components/*": ["./src/components/*"],
+ "@assets/*": ["./src/assets/*"],
+ "@lib/*": ["./src/lib/*"]
}
},
"exclude": ["dist"]
diff --git a/vitest.config.ts b/vitest.config.ts
index 5876ad70..e4cab445 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,14 +1,25 @@
///
import { getViteConfig } from "astro/config";
+import { fileURLToPath } from "node:url";
+import react from "@astrojs/react";
export default getViteConfig(
{
+ resolve: {
+ alias: {
+ "@components": fileURLToPath(
+ new URL("./src/components", import.meta.url),
+ ),
+ "@lib": fileURLToPath(new URL("./src/lib", import.meta.url)),
+ },
+ },
test: {
environment: "node",
- include: ["src/**/*.test.ts"],
+ include: ["src/**/*.test.{ts,tsx}"],
},
},
{
configFile: false,
+ integrations: [react()],
},
);
diff --git a/wrangler.jsonc b/wrangler.jsonc
index 57f362f4..a5996755 100644
--- a/wrangler.jsonc
+++ b/wrangler.jsonc
@@ -9,8 +9,8 @@
"assets": {
"directory": "./dist",
"not_found_handling": "404-page",
- "html_handling": "drop-trailing-slash",
- "run_worker_first": ["/contact", "/robots.txt"],
+ "html_handling": "force-trailing-slash",
+ "run_worker_first": ["/contact/submit", "/robots.txt", "/mcp", "/mcp/*"],
},
"observability": {
"enabled": true,