Skip to content

Commit 27ee76f

Browse files
authored
chore: Style api docs tools (#233)
1 parent 65e6ac1 commit 27ee76f

5 files changed

Lines changed: 179 additions & 70 deletions

File tree

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@
3333
"default": "./mjs/internal/focus-visible/index.js"
3434
},
3535
"./internal/style-api": "./internal/style-api/index.scss",
36+
"./internal/style-api/docs": {
37+
"require": "./internal/style-api/docs.js",
38+
"default": "./mjs/internal/style-api/docs.js"
39+
},
3640
"./internal/metrics": {
3741
"require": "./internal/metrics.js",
3842
"default": "./mjs/internal/metrics.js"
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { extractStyleApiDocs } from '../docs';
5+
6+
// Emulates the compiled output of `@include style-api.docs($name, $map)`.
7+
const marker = (name: string, tokens: string[]) =>
8+
`/* awsui:style-api-slot name=${name} tokens=${tokens.join(', ')} */`;
9+
10+
test('returns no slots when there are no markers', () => {
11+
const css = `
12+
.root { padding-inline: var(--awsui-style-padding-inline, 8px); }
13+
`;
14+
expect(extractStyleApiDocs(css)).toEqual({ slots: [] });
15+
});
16+
17+
test('reads a slot and its tokens from a marker', () => {
18+
const css = `
19+
${marker('label', ['color-text', 'color-background'])}
20+
.root { padding-inline: var(--awsui-style-padding-inline, 8px); }
21+
`;
22+
expect(extractStyleApiDocs(css).slots).toEqual([{ name: 'label', tokens: ['color-text', 'color-background'] }]);
23+
});
24+
25+
test('reads multiple slots having the same token name', () => {
26+
const css = `
27+
${marker('input', ['color-text', 'color-background'])}
28+
${marker('dropdown', ['color-text', 'color-background'])}
29+
`;
30+
const docs = extractStyleApiDocs(css);
31+
expect(docs.slots).toEqual([
32+
{ name: 'input', tokens: ['color-text', 'color-background'] },
33+
{ name: 'dropdown', tokens: ['color-text', 'color-background'] },
34+
]);
35+
});
36+
37+
test('throws on a duplicate slot name (each slot must be annotated exactly once)', () => {
38+
const css = `
39+
${marker('input', ['color-text', 'color-background'])}
40+
${marker('input', ['padding-inline', 'padding-block'])}
41+
`;
42+
expect(() => extractStyleApiDocs(css)).toThrow(/multiple .+ annotations with the same name: "input"/);
43+
});
44+
45+
test('tolerates empty slots', () => {
46+
const css = `
47+
${marker('empty', [])}
48+
`;
49+
expect(extractStyleApiDocs(css).slots).toEqual([{ name: 'empty', tokens: [] }]);
50+
});
51+
52+
test('tolerates whitespaces inside the marker', () => {
53+
const css = `/* \nawsui:style-api-slot name=header tokens=color-text, color-border */`;
54+
expect(extractStyleApiDocs(css).slots).toEqual([{ name: 'header', tokens: ['color-text', 'color-border'] }]);
55+
});

src/internal/style-api/docs.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Extracts the Style API documentation surface from a component's *compiled* CSS.
5+
//
6+
// Slots are declared explicitly by the author with the `style-api.docs($name, $tokens)` mixin, which
7+
// emits a machine-readable marker comment into the compiled CSS:
8+
//
9+
// /* awsui:style-api-slot name=<slot> tokens=<t1>, <t2> */
10+
//
11+
// This module parses those markers.
12+
13+
const MARKER = /awsui:style-api-slot\s+name=([\w-]+)\s+tokens=([^*]*)\*\//g;
14+
15+
export interface StyleApiDocs {
16+
/**
17+
* The component's themeable slots (defined by classNames), each with its own set of style tokens.
18+
*/
19+
slots: StyleApiSlotDocs[];
20+
}
21+
22+
export interface StyleApiSlotDocs {
23+
/**
24+
* The first argument of `style-api.docs(...)` - must match the corresponding classNames slot.
25+
*/
26+
name: string;
27+
/**
28+
* The public style tokens this slot supports (without "--awsui-style" prefix).
29+
*/
30+
tokens: string[];
31+
}
32+
33+
/**
34+
* Extracts the Style API slot documentation from a component's compiled CSS by reading the
35+
* explicit slot markers emitted by `style-api.docs(...)`.
36+
*/
37+
export function extractStyleApiDocs(css: string): StyleApiDocs {
38+
const slots = new Array<StyleApiSlotDocs>();
39+
const usedSlots = new Set<string>();
40+
41+
for (const match of css.matchAll(MARKER)) {
42+
const name = match[1];
43+
const tokens = match[2].split(/[\s,]+/).filter(Boolean);
44+
slots.push({ name, tokens });
45+
if (!usedSlots.has(name)) {
46+
usedSlots.add(name);
47+
} else {
48+
throw new Error(`Found multiple style-api.docs(...) annotations with the same name: "${name}"`);
49+
}
50+
}
51+
return { slots };
52+
}

src/internal/style-api/index.scss

Lines changed: 61 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -7,90 +7,100 @@
77
@use 'sass:list';
88
@use 'sass:string';
99

10-
// Style API v2 authoring utils for shared, component-agnostic style tokens (e.g. --awsui-style-color-text,
11-
// not --awsui-style-button-color-text). The owning component is implied by where the consumer applies the
12-
// class, so one abstract token name is reused across components and shared token groups can be composed.
10+
// Style API v2 authoring utils for shared, component-agnostic style tokens (e.g. color-text,
11+
// color-background). Each public token is exposed as a `--awsui-style-<token>` custom property,
12+
// registered as `@property { syntax:'*'; inherits:false }`. A slot can read a token in one of two layers:
13+
// - PUBLIC layer: read the public token directly, on the element the consumer themes (single-element slots).
14+
// - CARRIER layer: mirror the public token into an inherited internal carrier `--awsui-internal-style-<token>`
15+
// re-anchored on the component root (carriers() mixin), so descendants can read it per instance.
1316
//
14-
// Model: each public token `--awsui-style-<prop>` is registered `@property { syntax:'*'; inherits:false }`.
15-
// A component resolves its prop set into a <prop> -> custom-property map for one of two LAYERS, then reads via
16-
// `var(#{read($tokens, <prop>)}, <default>)` (uniform at every read site):
17+
// The reusable primitive is a per-slot token map produced by resolve() (`token -> custom-property`). Write
18+
// each slot's token names once in resolve(); then register(), docs() and read() all consume the same maps —
19+
// so the docs can never drift from the implementation, and a map can be shared by several slots.
1720
//
18-
// - PUBLIC layer: read the public token directly, on the element the consumer themes. It doesn't inherit, so
19-
// it can't pick up an ancestor component's value. Use for single-element components (e.g. a button).
20-
// - CARRIER layer: the public token is mirrored into an INHERITED internal carrier `--awsui-internal-style-<prop>`
21-
// re-anchored on the component root (carriers() mixin), so descendants can read it. Each root re-anchors from
22-
// its own (unset) public token, so values scope to the instance and reset at every nested boundary — no
23-
// leakage. Registrations are byte-identical across components, so order-independent.
24-
//
25-
// Example:
21+
// Flow (public layer):
2622
//
2723
// @use '@cloudscape-design/component-toolkit/internal/style-api' as style-api;
2824
//
29-
// $props: style-api.combine((color-background, color-text), (icon-color)); // 1. union token lists (deduped)
30-
// $tokens: style-api.resolve($props, carrier); // 2. map for the carrier layer -> pass to read()
31-
// @include style-api.register($props); // 3. declare the public @property (top level, once)
25+
// $root: style-api.resolve((color-background), public); // 1. token names written once, per slot
26+
// $info: style-api.resolve((color-text), public);
27+
// @include style-api.register($root, $text); // 2. declare @property for every token (once)
28+
//
29+
// @include style-api.docs('root', $root); // 3. declare the slots (docs only, top level)
30+
// @include style-api.docs('info', $info);
3231
//
33-
// .alert-root {
34-
// @include style-api.carriers($props); // 4. re-anchor public -> internal on the boundary
35-
// color: var(#{style-api.read($tokens, color-text)}, #16191f); // 5. apply token (default via awsui.$… in a real component)
36-
// }
32+
// .root { background: var(#{style-api.read($root, color-background)}, …); } // 4. apply the tokens
33+
// .info { color: var(#{style-api.read($info, color-text)}, …); }
3734
//
38-
// A consumer themes an instance by setting the public token on a class applied to the component:
39-
// .my-alert { --awsui-style-color-text: light-dark(black, white); }
35+
// Carrier layer: resolve($tokens, carrier) and `@include style-api.carriers($map)` on the boundary element.
4036

41-
// 1. Deduped union of token lists.
37+
// Deduped union of token lists — for composing shared token groups before resolve().
4238
@function combine($lists...) {
4339
$result: ();
4440
@each $list in $lists {
45-
@each $prop in $list {
46-
@if not list.index($result, $prop) {
47-
$result: list.append($result, $prop);
41+
@each $token in $list {
42+
@if not list.index($result, $token) {
43+
$result: list.append($result, $token);
4844
}
4945
}
5046
}
5147
@return $result;
5248
}
5349

54-
// 2. Resolves `$props` into a <prop> -> custom-property map for `$layer` (`public` or `carrier`), consumed by
55-
// read() at read sites. `$layer` is required so the layer choice is always explicit at the call site.
56-
@function resolve($props, $layer) {
50+
// Resolves `$tokens` (a token name or list of token names) into a `token -> custom-property` map for
51+
// `$layer` (`public` or `carrier`), consumed by docs(), read() and carriers(). `$layer` is required
52+
// so the layer choice is always explicit at the definition site.
53+
@function resolve($tokens, $layer) {
5754
@if $layer != public and $layer != carrier {
5855
@error 'Unknown layer "#{$layer}". Use `public` or `carrier`.';
5956
}
6057
$prefix: '--awsui-internal-style-';
6158
@if $layer == public {
6259
$prefix: '--awsui-style-';
6360
}
64-
$tokens: ();
65-
@each $prop in $props {
66-
$tokens: map.set($tokens, $prop, string.unquote('#{$prefix}#{$prop}'));
61+
$map: ();
62+
@each $token in $tokens {
63+
$map: map.set($map, $token, string.unquote('#{$prefix}#{$token}'));
6764
}
68-
@return $tokens;
65+
@return $map;
6966
}
7067

71-
// 3. Registers the public tokens as non-inheriting @property. Top level only.
72-
@mixin register($props) {
73-
@each $prop in $props {
74-
@property --awsui-style-#{$prop} {
75-
syntax: '*';
76-
inherits: false;
68+
// Registers the public tokens of the given slot maps as non-inheriting @property. Top level, once.
69+
// Accepts every slot's map so the registered set is exactly the union of the tokens the slots use.
70+
@mixin register($maps...) {
71+
$seen: ();
72+
@each $map in $maps {
73+
@each $token in map.keys($map) {
74+
@if not list.index($seen, $token) {
75+
$seen: list.append($seen, $token);
76+
@property --awsui-style-#{$token} {
77+
syntax: '*';
78+
inherits: false;
79+
}
80+
}
7781
}
7882
}
7983
}
8084

81-
// 4. Re-anchors each public token into its inherited internal carrier. Include on the component's root; pair
82-
// with a `resolve($props, carrier)` map so descendants read the re-anchored values.
83-
@mixin carriers($props) {
84-
@each $prop in $props {
85-
--awsui-internal-style-#{$prop}: var(--awsui-style-#{$prop});
85+
// Re-anchors each token of a carrier map into its inherited internal carrier. Include on the
86+
// component's boundary element; descendants read the re-anchored values via the same map.
87+
@mixin carriers($map) {
88+
@each $token in map.keys($map) {
89+
--awsui-internal-style-#{$token}: var(--awsui-style-#{$token});
8690
}
8791
}
8892

89-
// 5. The custom-property to read for `$prop`, looked up in the given map. Errors at compile time on an
90-
// undeclared prop (a typo, or a token the component never composed into its set).
91-
@function read($tokens, $prop) {
92-
@if not map.has-key($tokens, $prop) {
93-
@error 'Unknown style token "#{$prop}". Declared: #{map.keys($tokens)}.';
93+
// The custom-property to read for `$token`, looked up in a slot map. Errors at compile time on an
94+
// unknown token (a typo, or a token not in this slot's set).
95+
@function read($map, $token) {
96+
@if not map.has-key($map, $token) {
97+
@error 'Unknown style token "#{$token}". This slot declares: #{map.keys($map)}.';
9498
}
95-
@return map.get($tokens, $prop);
99+
@return map.get($map, $token);
100+
}
101+
102+
// Documents a themeable slot (emits docs only — no styling effect), from the slot map.
103+
// The `$name` must match the component's `classNames` property entry.
104+
@mixin docs($name, $map) {
105+
/* awsui:style-api-slot name=#{$name} tokens=#{map.keys($map)} */
96106
}

test-pages/src/pages/style-api.module.scss

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,16 @@
66
// Two demo components exercise the two layers:
77
// - .panel uses the CARRIER layer, so descendants (.title/.body) read the re-anchored tokens.
88
// - .pill uses the PUBLIC layer, reading the tokens directly on the element the consumer themes.
9-
$panel-props: (
10-
color-background,
11-
color-border,
12-
border-width,
13-
border-radius,
14-
color-text,
15-
color-text-secondary
16-
);
17-
$pill-props: (
18-
color-background,
19-
color-border,
20-
color-text,
21-
border-radius
22-
);
9+
// Token names are written once, in resolve(); register/slot/carriers/read all reuse the same maps.
10+
$panel: style-api.resolve((color-background, color-border, border-width, border-radius, color-text, color-text-secondary), carrier);
11+
$pill: style-api.resolve((color-background, color-border, color-text, border-radius), public);
2312

24-
@include style-api.register(style-api.combine($panel-props, $pill-props));
25-
26-
$panel: style-api.resolve($panel-props, carrier);
27-
$pill: style-api.resolve($pill-props, public);
13+
@include style-api.register($panel, $pill);
14+
@include style-api.docs('panel', $panel);
15+
@include style-api.docs('pill', $pill);
2816

2917
.panel {
30-
@include style-api.carriers($panel-props); // re-anchor on the boundary so descendants can read the tokens
18+
@include style-api.carriers($panel); // re-anchor on the boundary so descendants can read the tokens
3119
box-sizing: border-box;
3220
max-inline-size: 360px;
3321
background: var(#{style-api.read($panel, color-background)}, #445566);

0 commit comments

Comments
 (0)