From 1925f4e361895230f9570d0544d15460e2f99fe1 Mon Sep 17 00:00:00 2001 From: Andrei Zhaleznichenka Date: Thu, 13 Aug 2026 12:04:27 +0200 Subject: [PATCH] chore: Style api docs-forward tools --- src/internal/style-api/__tests__/docs.test.ts | 33 ++++++++++++ src/internal/style-api/docs.ts | 54 +++++++++++++------ src/internal/style-api/index.scss | 10 +++- 3 files changed, 80 insertions(+), 17 deletions(-) diff --git a/src/internal/style-api/__tests__/docs.test.ts b/src/internal/style-api/__tests__/docs.test.ts index d77ec7a..2004898 100644 --- a/src/internal/style-api/__tests__/docs.test.ts +++ b/src/internal/style-api/__tests__/docs.test.ts @@ -7,6 +7,10 @@ import { extractStyleApiDocs } from '../docs'; const marker = (name: string, tokens: string[]) => `/* awsui:style-api-slot name=${name} tokens=${tokens.join(', ')} */`; +// Emulates the compiled output of `@include style-api.docs-forward($name, $component, $slot)`. +const forwardMarker = (name: string, component: string, slot: string) => + `/* awsui:style-api-slot name=${name} component=${component} slot=${slot} */`; + test('returns no slots when there are no markers', () => { const css = ` .root { padding-inline: var(--awsui-style-padding-inline, 8px); } @@ -53,3 +57,32 @@ test('tolerates whitespaces inside the marker', () => { const css = `/* \nawsui:style-api-slot name=header tokens=color-text, color-border */`; expect(extractStyleApiDocs(css).slots).toEqual([{ name: 'header', tokens: ['color-text', 'color-border'] }]); }); + +test('reads a forward slot that points to another component slot', () => { + const css = ` + ${forwardMarker('dismissButton', 'button', 'button')} + .root { padding-inline: var(--awsui-style-padding-inline, 8px); } + `; + expect(extractStyleApiDocs(css).slots).toEqual([ + { name: 'dismissButton', forwardsTo: { component: 'button', slot: 'button' } }, + ]); +}); + +test('reads token slots and forward slots together, preserving order', () => { + const css = ` + ${marker('root', ['color-text', 'color-background'])} + ${forwardMarker('dismissButton', 'button', 'button')} + `; + expect(extractStyleApiDocs(css).slots).toEqual([ + { name: 'root', tokens: ['color-text', 'color-background'] }, + { name: 'dismissButton', forwardsTo: { component: 'button', slot: 'button' } }, + ]); +}); + +test('throws on a duplicate slot name across token and forward markers', () => { + const css = ` + ${marker('dismissButton', ['color-text'])} + ${forwardMarker('dismissButton', 'button', 'button')} + `; + expect(() => extractStyleApiDocs(css)).toThrow(/multiple .+ annotations with the same name: "dismissButton"/); +}); diff --git a/src/internal/style-api/docs.ts b/src/internal/style-api/docs.ts index d6a659e..5f529f1 100644 --- a/src/internal/style-api/docs.ts +++ b/src/internal/style-api/docs.ts @@ -3,49 +3,71 @@ // Extracts the Style API documentation surface from a component's *compiled* CSS. // -// Slots are declared explicitly by the author with the `style-api.docs($name, $tokens)` mixin, which -// emits a machine-readable marker comment into the compiled CSS: +// Slots are declared explicitly by the author with the style-api docs mixins, which emit a +// machine-readable marker comment into the compiled CSS. Two forms exist: // -// /* awsui:style-api-slot name= tokens=, */ +// token slot — `@include style-api.docs($name, $tokens)`: +// /* awsui:style-api-slot name= tokens=, */ // -// This module parses those markers. +// forward slot — `@include style-api.docs-forward($name, $component, $slot)`: +// /* awsui:style-api-slot name= component= slot= */ +// +// A forward slot reuses another component's slot (e.g. a nested Button) instead of owning tokens; +// the docs consumer resolves it to that component's slot, so it never goes stale. This module parses +// both forms. -const MARKER = /awsui:style-api-slot\s+name=([\w-]+)\s+tokens=([^*]*)\*\//g; +const MARKER = /awsui:style-api-slot\s+name=([\w-]+)\s+(?:tokens=([^*]*)|component=([\w-]+)\s+slot=([\w-]+)\s*)\*\//g; export interface StyleApiDocs { /** - * The component's themeable slots (defined by classNames), each with its own set of style tokens. + * The component's themeable slots (defined by classNames). Each slot either owns a set of style + * tokens or forwards to another component's slot. */ slots: StyleApiSlotDocs[]; } -export interface StyleApiSlotDocs { +export type StyleApiSlotDocs = StyleApiTokenSlotDocs | StyleApiForwardSlotDocs; + +interface StyleApiSlotDocsBase { /** - * The first argument of `style-api.docs(...)` - must match the corresponding classNames slot. + * The first argument of the docs mixin - must match the corresponding classNames slot. */ name: string; +} + +export interface StyleApiTokenSlotDocs extends StyleApiSlotDocsBase { /** * The public style tokens this slot supports (without "--awsui-style" prefix). */ tokens: string[]; } +export interface StyleApiForwardSlotDocs extends StyleApiSlotDocsBase { + /** + * The slot this one forwards to. Its tokens are whatever the referenced component's slot documents. + */ + forwardsTo: { component: string; slot: string }; +} + /** - * Extracts the Style API slot documentation from a component's compiled CSS by reading the - * explicit slot markers emitted by `style-api.docs(...)`. + * Extracts the Style API slot documentation from a component's compiled CSS by reading the explicit + * slot markers emitted by the style-api docs mixins. */ export function extractStyleApiDocs(css: string): StyleApiDocs { const slots = new Array(); const usedSlots = new Set(); for (const match of css.matchAll(MARKER)) { - const name = match[1]; - const tokens = match[2].split(/[\s,]+/).filter(Boolean); - slots.push({ name, tokens }); - if (!usedSlots.has(name)) { - usedSlots.add(name); + const [, name, tokens, component, slot] = match; + if (usedSlots.has(name)) { + throw new Error(`Found multiple style-api docs annotations with the same name: "${name}"`); + } + usedSlots.add(name); + + if (tokens !== undefined) { + slots.push({ name, tokens: tokens.split(/[\s,]+/).filter(Boolean) }); } else { - throw new Error(`Found multiple style-api.docs(...) annotations with the same name: "${name}"`); + slots.push({ name, forwardsTo: { component, slot } }); } } return { slots }; diff --git a/src/internal/style-api/index.scss b/src/internal/style-api/index.scss index 8ed72f0..4e9a4fe 100644 --- a/src/internal/style-api/index.scss +++ b/src/internal/style-api/index.scss @@ -100,7 +100,15 @@ } // Documents a themeable slot (emits docs only — no styling effect), from the slot map. -// The `$name` must match the component's `classNames` property entry. +// The `$name` must match the component's `classNames` property entry. Emits docs only. @mixin docs($name, $map) { /* awsui:style-api-slot name=#{$name} tokens=#{map.keys($map)} */ } + +// Documents a slot that forwards to another component's slot (e.g. a nested Button) instead of +// owning tokens. `$name` must match the component's `classNames` property entry; `$component` and +// `$slot` name the target component and its slot. The slot's tokens are whatever that target slot +// documents — resolved by the docs consumer, so this stays in sync automatically. Emits docs only. +@mixin docs-forward($name, $component, $slot) { + /* awsui:style-api-slot name=#{$name} component=#{$component} slot=#{$slot} */ +}