diff --git a/package.json b/package.json index 1f798cc..ff1f191 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "require": "./analytics-metadata/utils.js", "default": "./mjs/analytics-metadata/utils.js" }, + "./styling": "./styling/index.scss", "./internal": { "require": "./internal/index.js", "default": "./mjs/internal/index.js" @@ -78,10 +79,11 @@ "scripts": { "prebuild": "rm -rf lib", "build": "tsc -p ./tsconfig.json && tsc -p ./tsconfig.cjs.json", - "postbuild": "npm run postbuild:root && npm run postbuild:focus-visible && npm run postbuild:style-api && node ./scripts/generate-deep-package.js", + "postbuild": "npm run postbuild:root && npm run postbuild:focus-visible && npm run postbuild:style-api && npm run postbuild:styling && node ./scripts/generate-deep-package.js", "postbuild:root": "cp package.json README.md LICENSE NOTICE lib", "postbuild:focus-visible": "cp ./src/internal/focus-visible/index.scss lib/internal/focus-visible/index.scss", "postbuild:style-api": "mkdir -p lib/internal/style-api && cp ./src/internal/style-api/index.scss lib/internal/style-api/index.scss", + "postbuild:styling": "mkdir -p lib/styling && cp ./src/styling/index.scss lib/styling/index.scss", "test-pages": "vite --config ./test-pages/vite.config.mts", "test:unit": "jest -c jest.unit.config.cjs", "test:integ": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest -c jest.integ.config.cjs", diff --git a/src/internal/style-api/__tests__/docs.test.ts b/src/internal/style-api/__tests__/docs.test.ts index f371535..173f65e 100644 --- a/src/internal/style-api/__tests__/docs.test.ts +++ b/src/internal/style-api/__tests__/docs.test.ts @@ -1,16 +1,27 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { extractStyleApiDocs } from '../docs'; +import { extractStyleApiDocs, StyleApiTokenSlotDocs } from '../docs'; -// Emulates the compiled output of `@include style-api.docs($name, $map)`. -const marker = (name: string, tokens: string[]) => - `/* awsui:style-api-slot name=${name} tokens=${tokens.join(', ')} */`; +// Emulates the compiled output of `@include style-api.docs($name, $tokens, $properties)`. +const marker = (name: string, tokens: string[], properties: string[] = []) => + `/* awsui:style-api-slot name=${name} tokens=${tokens.join(', ')} properties=${properties.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} */`; +// The expected docs of a token slot, with the always-present fields defaulted to empty. +const tokenSlot = ( + slot: Pick & Partial +): StyleApiTokenSlotDocs => ({ + tokens: [], + tokenDescriptions: {}, + properties: [], + propertyDescriptions: {}, + ...slot, +}); + test('returns no slots when there are no markers', () => { const css = ` .root { padding-inline: var(--awsui-style-padding-inline, 8px); } @@ -23,7 +34,9 @@ test('reads a slot and its tokens from a marker', () => { ${marker('label', ['color-text', 'color-background'])} .root { padding-inline: var(--awsui-style-padding-inline, 8px); } `; - expect(extractStyleApiDocs(css).slots).toEqual([{ name: 'label', tokens: ['color-text', 'color-background'] }]); + expect(extractStyleApiDocs(css).slots).toEqual([ + tokenSlot({ name: 'label', tokens: ['color-text', 'color-background'] }), + ]); }); test('reads multiple slots having the same token name', () => { @@ -33,8 +46,8 @@ test('reads multiple slots having the same token name', () => { `; const docs = extractStyleApiDocs(css); expect(docs.slots).toEqual([ - { name: 'input', tokens: ['color-text', 'color-background'] }, - { name: 'dropdown', tokens: ['color-text', 'color-background'] }, + tokenSlot({ name: 'input', tokens: ['color-text', 'color-background'] }), + tokenSlot({ name: 'dropdown', tokens: ['color-text', 'color-background'] }), ]); }); @@ -50,12 +63,14 @@ test('tolerates empty slots', () => { const css = ` ${marker('empty', [])} `; - expect(extractStyleApiDocs(css).slots).toEqual([{ name: 'empty', tokens: [] }]); + expect(extractStyleApiDocs(css).slots).toEqual([tokenSlot({ name: 'empty' })]); }); 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'] }]); + const css = `/* \nawsui:style-api-slot name=header tokens=color-text, color-border properties= */`; + expect(extractStyleApiDocs(css).slots).toEqual([ + tokenSlot({ name: 'header', tokens: ['color-text', 'color-border'] }), + ]); }); test('reads a forward slot that points to another component slot', () => { @@ -74,14 +89,17 @@ test('reads token slots and forward slots together, preserving order', () => { ${forwardMarker('dismissButton', 'button', 'button')} `; expect(extractStyleApiDocs(css).slots).toEqual([ - { name: 'root', tokens: ['color-text', 'color-background'] }, + tokenSlot({ 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 */`; + const css = `/* awsui:style-api-slot name=column layout tokens=color-text properties= */`; expect(() => extractStyleApiDocs(css)).toThrow(/malformed style-api docs annotation/); + expect(() => extractStyleApiDocs(`/* awsui:style-api-slot name=root tokens=color-text */`)).toThrow( + /malformed style-api docs annotation/ + ); }); test('throws on a duplicate slot name across token and forward markers', () => { @@ -91,3 +109,66 @@ test('throws on a duplicate slot name across token and forward markers', () => { `; expect(() => extractStyleApiDocs(css)).toThrow(/multiple .+ annotations with the same name: "dismissButton"/); }); + +// Emulates the description markers emitted by `docs()`. +const description = (slot: string, kind: 'slot' | 'token' | 'property', name: string, text: string) => + `/* awsui:style-api-description slot=${slot} kind=${kind} name=${name} text=${text} */`; + +test('attaches token descriptions to their slot', () => { + const css = ` + ${marker('root', ['color', 'background-color'])} + ${description('root', 'token', 'color', 'Text color, also used for the icon')} + `; + expect(extractStyleApiDocs(css).slots).toEqual([ + tokenSlot({ + name: 'root', + tokens: ['color', 'background-color'], + tokenDescriptions: { color: 'Text color, also used for the icon' }, + }), + ]); +}); + +test('reads allowlisted properties and their descriptions', () => { + const css = ` + ${marker('root', ['color'], ['padding-inline', 'font-weight'])} + ${description('root', 'property', 'padding-inline', 'Horizontal padding')} + `; + expect(extractStyleApiDocs(css).slots).toEqual([ + tokenSlot({ + name: 'root', + tokens: ['color'], + properties: ['padding-inline', 'font-weight'], + propertyDescriptions: { 'padding-inline': 'Horizontal padding' }, + }), + ]); +}); + +test('reads allowlisted properties on a slot without tokens', () => { + expect(extractStyleApiDocs(marker('root', [], ['padding-block'])).slots).toEqual([ + tokenSlot({ name: 'root', properties: ['padding-block'] }), + ]); +}); + +test('throws when a description refers to an undeclared or forwarding slot', () => { + expect(() => extractStyleApiDocs(description('root', 'token', 'color', 'Text'))).toThrow('undeclared'); + const css = `${forwardMarker('dismissButton', 'button', 'root')} ${description('dismissButton', 'slot', 'dismissButton', 'Text')}`; + expect(() => extractStyleApiDocs(css)).toThrow('forwarding'); +}); + +test('throws when a description refers to an undeclared token or property', () => { + const css = `${marker('root', ['color'])} ${description('root', 'token', 'border-color', 'Border')}`; + expect(() => extractStyleApiDocs(css)).toThrow('undeclared token "border-color"'); + const css2 = `${marker('root', ['color'])} ${description('root', 'property', 'padding-block', 'Padding')}`; + expect(() => extractStyleApiDocs(css2)).toThrow('undeclared property "padding-block"'); +}); + +test('throws on a malformed description marker', () => { + expect(() => extractStyleApiDocs('/* awsui:style-api-description slot=root kind=token */')).toThrow('malformed'); +}); + +test('attaches a slot description to its slot', () => { + const css = `${marker('root', [])} ${description('root', 'slot', 'root', 'Class hook for state selectors')}`; + expect(extractStyleApiDocs(css).slots).toEqual([ + tokenSlot({ name: 'root', description: 'Class hook for state selectors' }), + ]); +}); diff --git a/src/internal/style-api/__tests__/mixins.test.ts b/src/internal/style-api/__tests__/mixins.test.ts new file mode 100644 index 0000000..eefc0ea --- /dev/null +++ b/src/internal/style-api/__tests__/mixins.test.ts @@ -0,0 +1,73 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * @jest-environment node + */ +import fs from 'fs'; +import path from 'path'; +import { compileString } from 'sass'; + +import { extractStyleApiDocs } from '../docs'; + +const styleApiSource = fs.readFileSync(path.resolve(__dirname, '../index.scss'), 'utf8'); + +// Serves the style-api module through an importer: Sass's own file resolution doesn't work in jest's sandbox. +const importer = { + canonicalize: (url: string) => (url === 'style-api' ? new URL('style-api:index') : null), + load: () => ({ contents: styleApiSource, syntax: 'scss' as const }), +}; + +function compile(scss: string) { + return compileString(`@use 'style-api';\n${scss}`, { importers: [importer] }).css; +} + +test('docs produces markers that the extractor reads back', () => { + const css = compile(` + $root: style-api.resolve((color, background-color), public); + @include style-api.docs( + 'root', + $root, + $properties: (padding-inline, font-weight), + $descriptions: ( + tokens: (color: 'Text color, also used for the icon'), + properties: (padding-inline: 'Horizontal padding'), + ) + ); + @include style-api.docs('hook', $properties: padding-block, $descriptions: (slot: 'Class hook for state selectors')); + @include style-api.docs('plain', $root); + `); + expect(extractStyleApiDocs(css).slots).toEqual([ + { + name: 'root', + tokens: ['color', 'background-color'], + tokenDescriptions: { color: 'Text color, also used for the icon' }, + properties: ['padding-inline', 'font-weight'], + propertyDescriptions: { 'padding-inline': 'Horizontal padding' }, + }, + { + name: 'hook', + tokens: [], + tokenDescriptions: {}, + properties: ['padding-block'], + propertyDescriptions: {}, + description: 'Class hook for state selectors', + }, + { + name: 'plain', + tokens: ['color', 'background-color'], + tokenDescriptions: {}, + properties: [], + propertyDescriptions: {}, + }, + ]); +}); + +test('docs fails the build when a description contains a comment terminator', () => { + expect(() => compile(`@include style-api.docs('root', $descriptions: (slot: 'a */ b'));`)).toThrow( + 'must not contain "*/"' + ); + expect(() => + compile(`@include style-api.docs('root', $properties: (color), $descriptions: (properties: (color: 'a */ b')));`) + ).toThrow('must not contain "*/"'); +}); diff --git a/src/internal/style-api/docs.ts b/src/internal/style-api/docs.ts index 34c2205..c15b3f0 100644 --- a/src/internal/style-api/docs.ts +++ b/src/internal/style-api/docs.ts @@ -6,21 +6,28 @@ // 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: // -// token slot — `@include style-api.docs($name, $tokens)`: -// /* awsui:style-api-slot name= tokens=, */ +// token slot — `@include style-api.docs($name, $tokens, $properties)`: +// /* awsui:style-api-slot name= tokens=, properties=, */ // // 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. +// the docs consumer resolves it to that component's slot, so it never goes stale. +// +// docs() also emits one description marker per described slot, token or property: +// /* awsui:style-api-description slot= kind= name= text= */ + +const MARKER = + /awsui:style-api-slot\s+name=([\w-]+)\s+(?:tokens=([^*]*?)\s+properties=([^*]*)|component=([\w-]+)\s+slot=([\w-]+)\s*)\*\//; -const MARKER = /awsui:style-api-slot\s+name=([\w-]+)\s+(?:tokens=([^*]*)|component=([\w-]+)\s+slot=([\w-]+)\s*)\*\//; +const DESCRIPTION_MARKER = + /awsui:style-api-description\s+slot=([\w-]+)\s+kind=(slot|token|property)\s+name=([\w-]+)\s+text=([^*]*?)\s*\*\//; -// 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. +// Match any marker of a kind loosely (just the sentinel up to the comment close). We use these to detect +// markers that the strict patterns fail to parse — e.g. a name or token containing a space. const MARKER_LOOSE = /awsui:style-api-slot[\s\S]*?\*\//g; +const DESCRIPTION_MARKER_LOOSE = /awsui:style-api-description[\s\S]*?\*\//g; export interface StyleApiDocs { /** @@ -40,10 +47,26 @@ interface StyleApiSlotDocsBase { } export interface StyleApiTokenSlotDocs extends StyleApiSlotDocsBase { + /** + * Description of the slot. Present when provided. + */ + description?: string; /** * The public style tokens this slot supports (without "--awsui-style" prefix). */ tokens: string[]; + /** + * Descriptions of the tokens, by token name. + */ + tokenDescriptions: Record; + /** + * The CSS properties consumers may set directly on the slot element. + */ + properties: string[]; + /** + * Descriptions of the allowlisted properties, by property name. + */ + propertyDescriptions: Record; } export interface StyleApiForwardSlotDocs extends StyleApiSlotDocsBase { @@ -55,29 +78,72 @@ export interface StyleApiForwardSlotDocs extends StyleApiSlotDocsBase { /** * 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. + * markers emitted by the style-api docs mixins. */ export function extractStyleApiDocs(css: string): StyleApiDocs { const slots = new Array(); - const usedSlots = new Set(); + const slotsByName = new Map(); - 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)) { + for (const raw of matchAll(css, MARKER_LOOSE)) { + const match = parse(raw, MARKER); + const [, name, tokens, properties, component, slot] = match; + if (slotsByName.has(name)) { throw new Error(`Found multiple style-api docs annotations with the same name: "${name}"`); } - usedSlots.add(name); - + let slotDocs: StyleApiSlotDocs; if (tokens !== undefined) { - slots.push({ name, tokens: tokens.split(/[\s,]+/).filter(Boolean) }); + slotDocs = { + name, + tokens: splitList(tokens), + tokenDescriptions: {}, + properties: splitList(properties), + propertyDescriptions: {}, + }; } else { - slots.push({ name, forwardsTo: { component, slot } }); + slotDocs = { name, forwardsTo: { component, slot } }; } + slots.push(slotDocs); + slotsByName.set(name, slotDocs); } + + for (const raw of matchAll(css, DESCRIPTION_MARKER_LOOSE)) { + const [, slotName, kind, name, text] = parse(raw, DESCRIPTION_MARKER); + const slot = getTokenSlot(slotsByName, slotName, raw); + if (kind === 'slot') { + slot.description = text; + continue; + } + const described = kind === 'token' ? slot.tokens : slot.properties; + if (!described.includes(name)) { + throw new Error(`Found a style-api description for an undeclared ${kind} "${name}" in slot "${slotName}"`); + } + const descriptions = kind === 'token' ? slot.tokenDescriptions : slot.propertyDescriptions; + descriptions[name] = text; + } + return { slots }; } + +function matchAll(css: string, pattern: RegExp): string[] { + return Array.from(css.matchAll(pattern), match => match[0]); +} + +function parse(raw: string, pattern: RegExp): RegExpExecArray { + const match = pattern.exec(raw); + if (!match) { + throw new Error(`Found a malformed style-api docs annotation: "${raw}"`); + } + return match; +} + +function splitList(list: string): string[] { + return list.split(/[\s,]+/).filter(Boolean); +} + +function getTokenSlot(slotsByName: Map, name: string, raw: string): StyleApiTokenSlotDocs { + const slot = slotsByName.get(name); + if (!slot || !('tokens' in slot)) { + throw new Error(`Found a style-api docs annotation for an undeclared or forwarding slot "${name}": "${raw}"`); + } + return slot; +} diff --git a/src/internal/style-api/index.scss b/src/internal/style-api/index.scss index 497cfb6..fdb2a8c 100644 --- a/src/internal/style-api/index.scss +++ b/src/internal/style-api/index.scss @@ -97,10 +97,42 @@ @return map.get($map, $token); } -// Documents a themeable slot (emits docs only — no styling effect), from the slot map. -// 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)} */ +// Formats a list for interpolation into a marker: an empty list would otherwise render as "()". +@function _marker-list($list) { + @if list.length($list) == 0 { + @return ''; + } + @return $list; +} + +// Emits a docs-only marker for one token or property description. +@mixin _description($slot, $kind, $name, $text) { + @if $text { + @if string.index('#{$text}', '*/') { + @error 'The description of "#{$name}" in slot "#{$slot}" must not contain "*/".'; + } + /* awsui:style-api-description slot=#{$slot} kind=#{$kind} name=#{$name} text=#{$text} */ + } +} + +// Documents a themeable slot (emits docs only — no styling effect). `$name` must match the component's +// `classNames` property entry. `$tokens` is the slot map from resolve(); omit it for a slot without tokens. +// `$properties` lists the CSS properties consumers may set directly on the slot element. `$descriptions` +// is a map with optional keys: `slot` (the slot's description), `tokens` and `properties` (maps of +// token or property name to its description). +@mixin docs($name, $tokens: null, $properties: null, $descriptions: null) { + $token-names: map.keys($tokens or ()); + $descriptions: $descriptions or (); + + /* awsui:style-api-slot name=#{$name} tokens=#{_marker-list($token-names)} properties=#{_marker-list($properties or ())} */ + + @include _description($name, slot, $name, map.get($descriptions, slot)); + @each $token, $text in map.get($descriptions, tokens) or () { + @include _description($name, token, $token, $text); + } + @each $property, $text in map.get($descriptions, properties) or () { + @include _description($name, property, $property, $text); + } } // Documents a slot that forwards to another component's slot (e.g. a nested Button) instead of diff --git a/src/styling/__tests__/styling.test.ts b/src/styling/__tests__/styling.test.ts new file mode 100644 index 0000000..e14f8b6 --- /dev/null +++ b/src/styling/__tests__/styling.test.ts @@ -0,0 +1,30 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * @jest-environment node + */ +import fs from 'fs'; +import path from 'path'; +import { compileString } from 'sass'; + +const stylingSource = fs.readFileSync(path.resolve(__dirname, '../index.scss'), 'utf8'); + +// Serves the module through an importer: Sass's own file resolution doesn't work in jest's sandbox. +const importer = { + canonicalize: (url: string) => (url === 'styling' ? new URL('styling:index') : null), + load: () => ({ contents: stylingSource, syntax: 'scss' as const }), +}; + +function compile(scss: string) { + return compileString(`@use 'styling' as cloudscape;\n${scss}`, { importers: [importer] }).css; +} + +test('styling.override adds two id-level parts to the consumer selector', () => { + const css = compile(` + .my-badge { @include cloudscape.override { font-weight: 700; } } + .my-badge:hover { @include cloudscape.override { color: red; } } + `); + expect(css).toContain('.my-badge:not(#\\9 ):not(#\\9 ) {\n font-weight: 700;\n}'); + expect(css).toContain('.my-badge:hover:not(#\\9 ):not(#\\9 ) {\n color: red;\n}'); +}); diff --git a/src/styling/index.scss b/src/styling/index.scss new file mode 100644 index 0000000..bfea8b3 --- /dev/null +++ b/src/styling/index.scss @@ -0,0 +1,19 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Makes the wrapped declarations take precedence over Cloudscape component styles. Component selectors +// carry exactly one id-level part, `:not(#\9)`; this adds two, so the consumer rule wins regardless of +// how many classes the component selector has. Use it inside a rule that targets a component slot class: +// +// @use '@cloudscape-design/component-toolkit/styling' as styling; +// +// .my-badge { +// @include styling.override { +// font-weight: 700; +// } +// } +@mixin override { + &:not(#\9):not(#\9) { + @content; + } +}