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
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
105 changes: 93 additions & 12 deletions src/internal/style-api/__tests__/docs.test.ts
Original file line number Diff line number Diff line change
@@ -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<StyleApiTokenSlotDocs, 'name'> & Partial<StyleApiTokenSlotDocs>
): 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); }
Expand All @@ -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', () => {
Expand All @@ -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'] }),
]);
});

Expand All @@ -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', () => {
Expand All @@ -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', () => {
Expand All @@ -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' }),
]);
});
73 changes: 73 additions & 0 deletions src/internal/style-api/__tests__/mixins.test.ts
Original file line number Diff line number Diff line change
@@ -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 "*/"');
});
Loading
Loading