Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/internal/style-api/__tests__/docs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down Expand Up @@ -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"/);
});
54 changes: 38 additions & 16 deletions src/internal/style-api/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<slot> tokens=<t1>, <t2> */
// token slot — `@include style-api.docs($name, $tokens)`:
// /* awsui:style-api-slot name=<slot> tokens=<t1>, <t2> */
//
// This module parses those markers.
// forward slot — `@include style-api.docs-forward($name, $component, $slot)`:
// /* awsui:style-api-slot name=<slot> component=<component> slot=<target-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<StyleApiSlotDocs>();
const usedSlots = new Set<string>();

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 };
Expand Down
10 changes: 9 additions & 1 deletion src/internal/style-api/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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} */
}
Loading