Skip to content
Merged
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
38 changes: 38 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,37 @@ 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 malformed marker instead of silently ignoring it', () => {
const css = `/* awsui:style-api-slot name=column layout tokens=color-text */`;
expect(() => extractStyleApiDocs(css)).toThrow(/malformed style-api docs annotation/);
});

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"/);
});
65 changes: 48 additions & 17 deletions src/internal/style-api/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,49 +3,80 @@

// 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=([^*]*)|component=([\w-]+)\s+slot=([\w-]+)\s*)\*\//;

const MARKER = /awsui:style-api-slot\s+name=([\w-]+)\s+tokens=([^*]*)\*\//g;
// Matches any slot marker loosely (just the sentinel up to the comment close). We use this to detect
// markers that MARKER fails to parse — e.g. a name or token containing a space.
const MARKER_LOOSE = /awsui:style-api-slot[\s\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);
for (const looseMatch of css.matchAll(MARKER_LOOSE)) {
const raw = looseMatch[0];
const match = MARKER.exec(raw);
if (!match) {
throw new Error(`Found a malformed style-api docs annotation: "${raw}"`);
}
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 } });

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think it's possible to check the values at this stage? At least that the component exists, and possibly that the slot is declared in the component's styles (this part might introduce a circular dependency risk)?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would not do this in this util, as its purpose is simply to parse the data from CSS. However, this is a nice suggestion for the components part - which uses the tool to read metadata from all components, and can therefore ensure the validity. We can even check that the slots correspond to the components classNames structure. I will definitely add these validations!

}
}
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