From 1881d2fc2a7bd54f122394fc5237bade053d61bb Mon Sep 17 00:00:00 2001 From: mainframev Date: Wed, 11 Nov 2026 13:33:32 +0100 Subject: [PATCH 1/3] feat(scripts-storybook): add styling metadata loader for docs pages Add registerStylingMetadataRule, a webpack pre loader that statically analyzes the components a story documents and injects parameters.__generatedStylingMetadata: v9 slot class names from the ClassNames export and headless data-* attributes with their value domains. Extraction issues are reported as warnings and never fail the build. --- scripts/storybook/README.md | 18 + scripts/storybook/src/index.d.ts | 2 + scripts/storybook/src/index.js | 2 + .../storybook/src/styling-metadata/README.md | 76 ++ .../__fixtures__/components/Card.ts | 25 + .../__fixtures__/components/Panel.ts | 3 + .../__fixtures__/components/Tile.ts | 5 + .../__fixtures__/components/index.ts | 3 + .../__fixtures__/tsconfig.json | 11 + .../src/styling-metadata/collect-evidence.js | 507 ++++++++++++ .../src/styling-metadata/extractor.js | 254 ++++++ .../storybook/src/styling-metadata/index.js | 57 ++ .../storybook/src/styling-metadata/loader.js | 313 ++++++++ .../src/styling-metadata/metadata.js | 452 +++++++++++ .../src/styling-metadata/source-analysis.js | 752 ++++++++++++++++++ .../styling-metadata/styling-metadata.spec.ts | 248 ++++++ 16 files changed, 2728 insertions(+) create mode 100644 scripts/storybook/src/styling-metadata/README.md create mode 100644 scripts/storybook/src/styling-metadata/__fixtures__/components/Card.ts create mode 100644 scripts/storybook/src/styling-metadata/__fixtures__/components/Panel.ts create mode 100644 scripts/storybook/src/styling-metadata/__fixtures__/components/Tile.ts create mode 100644 scripts/storybook/src/styling-metadata/__fixtures__/components/index.ts create mode 100644 scripts/storybook/src/styling-metadata/__fixtures__/tsconfig.json create mode 100644 scripts/storybook/src/styling-metadata/collect-evidence.js create mode 100644 scripts/storybook/src/styling-metadata/extractor.js create mode 100644 scripts/storybook/src/styling-metadata/index.js create mode 100644 scripts/storybook/src/styling-metadata/loader.js create mode 100644 scripts/storybook/src/styling-metadata/metadata.js create mode 100644 scripts/storybook/src/styling-metadata/source-analysis.js create mode 100644 scripts/storybook/src/styling-metadata/styling-metadata.spec.ts diff --git a/scripts/storybook/README.md b/scripts/storybook/README.md index 40a01991a9f330..0456bd033590f7 100644 --- a/scripts/storybook/README.md +++ b/scripts/storybook/README.md @@ -24,6 +24,24 @@ module.exports = { The helper, its CSS, and its unit tests are colocated in this project's `src` directory. +## `registerStylingMetadataRule` + +Registers a webpack `pre` loader that statically analyzes the components a story documents and injects +`parameters.__generatedStylingMetadata`, which `@fluentui/react-storybook-addon` renders as "Classes" (v9 `*ClassNames`) +and "Data attributes" (headless `data-*`) sections on docs pages. + +```js +const { registerStylingMetadataRule } = require('@fluentui/scripts-storybook'); + +module.exports = { + webpackFinal(config) { + return registerStylingMetadataRule({ config, tsConfigPath, classNames: true }); + }, +}; +``` + +See [`src/styling-metadata/README.md`](./src/styling-metadata/README.md) for sources, constraints and diagnostics. + ## `loadWorkspaceAddon` Function that registers custom workspace(monorepo) storybook addon written in TypeScript without need to "build first" before use. diff --git a/scripts/storybook/src/index.d.ts b/scripts/storybook/src/index.d.ts index 7a080b40cfdc57..4896044086cf7d 100644 --- a/scripts/storybook/src/index.d.ts +++ b/scripts/storybook/src/index.d.ts @@ -10,4 +10,6 @@ export { export * as rules from './rules'; +export { registerStylingMetadataRule } from './styling-metadata'; + export { configureReactIcons } from './react-icons-webpack'; diff --git a/scripts/storybook/src/index.js b/scripts/storybook/src/index.js index 37af064719e912..3071dc63c4161f 100644 --- a/scripts/storybook/src/index.js +++ b/scripts/storybook/src/index.js @@ -1,5 +1,6 @@ const { configureReactIcons } = require('./react-icons-webpack'); const rules = require('./rules'); +const { registerStylingMetadataRule } = require('./styling-metadata'); const { getPackageStoriesGlob, loadWorkspaceAddon, @@ -14,6 +15,7 @@ module.exports = { getPackageStoriesGlob, loadWorkspaceAddon, registerRules, + registerStylingMetadataRule, registerTsPaths, rules, getImportMappingsForExportToSandboxAddon, diff --git a/scripts/storybook/src/styling-metadata/README.md b/scripts/storybook/src/styling-metadata/README.md new file mode 100644 index 00000000000000..a9eefed4c77dde --- /dev/null +++ b/scripts/storybook/src/styling-metadata/README.md @@ -0,0 +1,76 @@ +# Styling metadata + +Generates the "Data attributes" and "Classes" sections that `@fluentui/react-storybook-addon` renders next to Props in +the docs page "API Reference". + +> **Temporary:** the tables are derived by static analysis of component source because components ship no +> machine-readable description of their styling contract yet. Once they do, replace the analysis in `extractor.js` +> with a reader for those files (same `{ dataAttributes, classNames }` shape) and keep `loader.js` only as the injector. + +## Usage + +```js +const { registerStylingMetadataRule } = require('@fluentui/scripts-storybook'); + +webpackFinal: config => { + registerStylingMetadataRule({ config, tsConfigPath, classNames: true }); + return config; +}; +``` + +| Storybook | Section | Source | +| --------------------------------------------------- | --------------- | -------------------------------------------- | +| root `.storybook`, `apps/public-docsite-v9` | Classes | `ClassNames` exports | +| headless stories, `apps/public-docsite-v9-headless` | Data attributes | `data-*` writes in component hooks/renderers | + +Calling `registerStylingMetadataRule` again replaces the earlier rule, so an extending config can switch sections. + +## How it works + +1. `loader.js` (webpack `pre` loader on `stories/src/**/*.stories.tsx`) parses the story meta, reads `component` + and `subcomponents`, and resolves each through its import. +2. `extractor.js` builds one TypeScript program per compilation from `tsConfigPath` (source via `paths`, no build + needed) and caches results per component. + - Classes: the `ClassNames` export of the module the story imports from, e.g. `buttonClassNames` + for `Button`. This is the convention enforced by the react-conformance `component-has-static-classnames-object` + test. + - Data attributes: `collect-evidence.js` walks the component (compose factory callbacks, hooks, renderers) for + `data-*` writes, data attribute maps, slot state types and components composed into slots; `metadata.js` merges + the evidence. `source-analysis.js` infers value domains from expressions and types. +3. The loader injects `parameters.__generatedStylingMetadata`. Authored + `parameters.reactStorybookAddon.docs.{dataAttributes,classNames}` win over generated values; + `parameters.reactStorybookAddon.docs: false` opts a story file out. + +## Authoring constraints + +- Class name maps must be object literals of string literals, constant references or spreads of such objects. + Factories (`createClassNames('Tile')`) cannot be evaluated and produce an `unresolved-map` warning. +- `data-*` writes are recognized as JSX attributes, `state.['data-x'] = …` assignments, object literal properties + on a slot, and `setAttribute`/`toggleAttribute` calls. Boolean serialization is understood through + `toDataAttributeValue`. +- A story's `component` must be an imported identifier; `subcomponents` must be an object literal. + +## Diagnostics + +Diagnostics are webpack warnings; extraction never fails a Storybook build. + +| Code | Meaning | Effect | +| ---------------------- | ----------------------------------------------- | -------------------- | +| `unresolved-component` | the import resolves but has no runtime export | no table | +| `unresolved-map` | a class or attribute map is not evaluable | no table for the map | +| `unresolved-slot` | a `data-*` write whose slot could not be found | row not rendered | +| `unresolved-domain` | attribute found but its values are unknown | row not rendered | +| `unassigned-contract` | a slot type declares a `data-*` key nobody sets | row not rendered | +| `open-domain` | value type is plain `string`/`number` | silent; `string` | + +## Known gaps + +- Writes on a local slot variable (`root['data-type'] = type` in `useNavDrawer`) are reported as `unresolved-slot`. +- Attributes written by infrastructure outside the component walk (`data-activedescendant*` from react-aria, + `data-fui-focus-within` from tabster) are not listed. +- Attributes on non-slot elements are kept in the metadata (`elements`) but not rendered. + +## Tests + +`yarn nx run scripts-storybook:test` covers the loader transform, fixtures in `__fixtures__/`, and the real headless +and v9 Button/Accordion sources. diff --git a/scripts/storybook/src/styling-metadata/__fixtures__/components/Card.ts b/scripts/storybook/src/styling-metadata/__fixtures__/components/Card.ts new file mode 100644 index 00000000000000..d233a2e1eec2d2 --- /dev/null +++ b/scripts/storybook/src/styling-metadata/__fixtures__/components/Card.ts @@ -0,0 +1,25 @@ +const PREFIX = 'fui-Card'; + +const baseClassNames = { + root: PREFIX, + header: 'fui-Card__header', +}; + +export const cardClassNames = { + ...baseClassNames, + footer: 'fui-Card__footer', +}; + +export type CardProps = { disabled?: boolean; size?: 'small' | 'large' }; + +export const useCard = (props: CardProps) => { + const state = { root: {} as Record }; + state.root['data-disabled'] = props.disabled ? '' : undefined; + state.root['data-size'] = props.size; + return state; +}; + +export const Card = (props: CardProps): null => { + useCard(props); + return null; +}; diff --git a/scripts/storybook/src/styling-metadata/__fixtures__/components/Panel.ts b/scripts/storybook/src/styling-metadata/__fixtures__/components/Panel.ts new file mode 100644 index 00000000000000..3ce31801308135 --- /dev/null +++ b/scripts/storybook/src/styling-metadata/__fixtures__/components/Panel.ts @@ -0,0 +1,3 @@ +export const panelClassNames = { root: 'fui-Panel' }; + +export const Panel = (): null => null; diff --git a/scripts/storybook/src/styling-metadata/__fixtures__/components/Tile.ts b/scripts/storybook/src/styling-metadata/__fixtures__/components/Tile.ts new file mode 100644 index 00000000000000..20645e04065913 --- /dev/null +++ b/scripts/storybook/src/styling-metadata/__fixtures__/components/Tile.ts @@ -0,0 +1,5 @@ +const createClassNames = (name: string) => ({ root: `fui-${name}` }); + +export const tileClassNames = createClassNames('Tile'); + +export const Tile = (): null => null; diff --git a/scripts/storybook/src/styling-metadata/__fixtures__/components/index.ts b/scripts/storybook/src/styling-metadata/__fixtures__/components/index.ts new file mode 100644 index 00000000000000..b037d68cd0e189 --- /dev/null +++ b/scripts/storybook/src/styling-metadata/__fixtures__/components/index.ts @@ -0,0 +1,3 @@ +export { Card, cardClassNames } from './Card'; +export { Tile, tileClassNames } from './Tile'; +export { Panel, panelClassNames } from './Panel'; diff --git a/scripts/storybook/src/styling-metadata/__fixtures__/tsconfig.json b/scripts/storybook/src/styling-metadata/__fixtures__/tsconfig.json new file mode 100644 index 00000000000000..1b6bcbafb92540 --- /dev/null +++ b/scripts/storybook/src/styling-metadata/__fixtures__/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2019", + "module": "ESNext", + "moduleResolution": "node", + "strict": true, + "paths": { + "fixture-components": ["./components/index.ts"] + } + } +} diff --git a/scripts/storybook/src/styling-metadata/collect-evidence.js b/scripts/storybook/src/styling-metadata/collect-evidence.js new file mode 100644 index 00000000000000..d79a1ddc221435 --- /dev/null +++ b/scripts/storybook/src/styling-metadata/collect-evidence.js @@ -0,0 +1,507 @@ +// @ts-check +/* eslint-disable no-bitwise -- TypeScript exposes node flags as bit sets */ + +/** + * @typedef {import('./source-analysis').SourceAnalysis} SourceAnalysis + * @typedef {import('./source-analysis').Domain} Domain + * @typedef {(code: string, message: string, node?: import('typescript').Node, context?: Record) => void} Report + * @typedef {{ + * name: string; + * slot: string | undefined; + * expression: any; + * node: import('typescript').Node; + * target?: string; + * imperative?: boolean; + * removal?: boolean; + * domain?: Domain; + * }} AttributeWrite + * @typedef {{ + * maps: Map; declaration: any }>; + * writes: AttributeWrite[]; + * states: Array<{ type: import('typescript').Type; node: import('typescript').Node }>; + * inputs: Map>; + * compositions: Map }>; + * }} Evidence + */ + +const COMPOSE_FACTORIES = ['composeComponent', 'composeComponentWithoutRef', 'composeTypography']; + +/** + * Walks a component (its factory callbacks, hooks and renderers) for `data-*` writes, data + * attribute maps, slot state types and components composed into its slots. + * + * @param {import('typescript').Symbol} component + * @param {SourceAnalysis} analysis + * @param {Report} report + * @param {(file: import('typescript').SourceFile) => boolean} isLocal - files of the documented package + * @returns {Evidence} + */ +function collectEvidence(component, analysis, report, isLocal) { + const { ts, checker, unwrap, symbolAt, isRuntime, flatMap, literal, propertyName } = analysis; + + /** @type {Set} */ + const visited = new Set(); + /** @type {Evidence['maps']} */ + const maps = new Map(); + /** @type {AttributeWrite[]} */ + const writes = []; + /** @type {Evidence['states']} */ + const states = []; + /** @type {Evidence['inputs']} */ + const inputs = new Map(); + /** @type {Evidence['compositions']} */ + const compositions = new Map(); + + /** @param {any} declaration */ + const isFunctionDeclaration = declaration => + ts.isFunctionDeclaration(declaration) || + (declaration.initializer && ts.isFunctionLike(unwrap(declaration.initializer))); + + /** + * @param {any} expression + * @param {string} slot + * @param {import('typescript').Node} node + * @param {Set} seen + */ + function inherit(expression, slot, node, seen = new Set()) { + expression = unwrap(expression); + if (!expression || seen.has(expression)) { + return; + } + + seen = new Set(seen).add(expression); + + if (ts.isConditionalExpression(expression)) { + inherit(expression.whenTrue, slot, node, seen); + inherit(expression.whenFalse, slot, node, seen); + return; + } + + const symbol = symbolAt(expression); + + if ( + !symbol || + symbol === component || + !symbol.declarations?.some(declaration => isRuntime(declaration.getSourceFile())) + ) { + return; + } + + for (const declaration of /** @type {any[]} */ (symbol.declarations ?? [])) { + const initializer = unwrap(declaration.initializer); + if (initializer && (ts.isConditionalExpression(initializer) || ts.isIdentifier(initializer))) { + inherit(initializer, slot, node, seen); + return; + } + } + + const priority = isLocal(node.getSourceFile()) ? 1 : 0; + const previous = compositions.get(slot); + + if (!previous || priority > previous.priority) { + compositions.set(slot, { priority, symbols: new Set([symbol]) }); + } else if (priority === previous.priority) { + previous.symbols.add(symbol); + } + } + + /** + * @param {import('typescript').Node} node + * @returns {string | undefined} + */ + function attributeOwner(node) { + let ancestor = /** @type {any} */ (node.parent); + + while (ancestor && !ts.isFunctionLike(ancestor)) { + if (ts.isCallExpression(ancestor)) { + const callee = symbolAt(ancestor.expression)?.name; + if ( + (callee && ['jsx', 'jsxs', 'createElement'].includes(callee)) || + /^(?:_jsx|_jsxs)$/.test(ancestor.expression.getText()) + ) { + const element = ancestor.arguments[0]; + if (element && ts.isPropertyAccessExpression(element)) { + return element.name.text; + } + if (element && ts.isStringLiteralLike(element)) { + return `element:${element.text}`; + } + } + } + + if (ts.isPropertyAssignment(ancestor)) { + const candidate = propertyName(ancestor.name); + if (typeof candidate === 'string' && !candidate.startsWith('data-') && candidate !== 'children') { + return candidate; + } + } + + if (ts.isVariableDeclaration(ancestor)) { + const init = unwrap(ancestor.initializer); + if ( + init && + ts.isCallExpression(init) && + ts.isPropertyAccessExpression(init.expression) && + init.expression.expression.getText() === 'slot' + ) { + const name = propertyName(ancestor.name); + return typeof name === 'string' ? name : undefined; + } + break; + } + + ancestor = ancestor.parent; + } + + return undefined; + } + + /** + * @param {any} expression + * @param {Set} seen + * @returns {string | undefined} + */ + function targetExpression(expression, seen = new Set()) { + expression = unwrap(expression); + if (!expression || seen.has(expression)) { + return undefined; + } + + seen = new Set(seen).add(expression); + + if (ts.isPropertyAccessExpression(expression)) { + const owner = targetExpression(expression.expression, seen); + return owner && `${owner}.${expression.name.text}`; + } + + if (!ts.isIdentifier(expression)) { + return undefined; + } + + const declaration = /** @type {any} */ (symbolAt(expression)?.valueDeclaration); + if (!declaration) { + return undefined; + } + if (ts.isParameter(declaration) || ts.isBindingElement(declaration)) { + return expression.text; + } + + if (!ts.isVariableDeclaration(declaration) || !(declaration.parent.flags & ts.NodeFlags.Const)) { + return undefined; + } + + const initializer = unwrap(declaration.initializer); + const factory = + initializer && ts.isCallExpression(initializer) ? symbolAt(initializer.expression)?.name : undefined; + + if (factory === 'useRef' || factory === 'createRef') { + return expression.text; + } + + return targetExpression(initializer, seen); + } + + /** + * @param {any} expression + * @param {import('typescript').Node} node + */ + function targetLabel(expression, node) { + const target = targetExpression(expression); + + if (!target) { + return undefined; + } + + for (let ancestor = /** @type {any} */ (node.parent); ancestor; ancestor = ancestor.parent) { + if (!ts.isFunctionLike(ancestor)) { + continue; + } + if (ancestor.name && ts.isIdentifier(ancestor.name)) { + return `${ancestor.name.text}(${target})`; + } + + let owner = ancestor.parent; + + if (ts.isCallExpression(owner)) { + owner = owner.parent; + } + if (ts.isVariableDeclaration(owner) && ts.isIdentifier(owner.name)) { + return `${owner.name.text}(${target})`; + } + } + + return undefined; + } + + /** @param {import('typescript').Symbol | undefined} symbol */ + function visitSymbol(symbol) { + if (!symbol || visited.has(symbol)) { + return; + } + + visited.add(symbol); + + for (const declaration of /** @type {any[]} */ (symbol.declarations ?? [])) { + if (!isRuntime(declaration.getSourceFile())) { + continue; + } + + if (ts.isVariableDeclaration(declaration)) { + const map = flatMap(declaration); + + if (map && Object.values(map).every(value => value.startsWith('data-'))) { + maps.set(symbol, { map, declaration }); + continue; + } + + const initializer = unwrap(declaration.initializer); + + if ( + !map && + initializer && + ts.isObjectLiteralExpression(initializer) && + (symbol.name.endsWith('DataAttributes') || + initializer.properties.some( + property => + ts.isPropertyAssignment(property) && String(literal(property.initializer)).startsWith('data-'), + )) + ) { + report( + 'unresolved-map', + `Cannot statically evaluate ${symbol.name}; expected flat maps of literals or constant references`, + declaration, + ); + } + } + + walk(declaration); + } + } + + /** @param {import('typescript').CallExpression} node */ + function collectCall(node) { + const symbol = symbolAt(node.expression); + + if (symbol && COMPOSE_FACTORIES.includes(symbol.name)) { + for (const argument of node.arguments.slice(1)) { + visitSymbol(symbolAt(unwrap(argument))); + } + return; + } + + for (const argument of node.arguments) { + const callback = symbolAt(unwrap(argument)); + if (callback?.declarations?.some(isFunctionDeclaration)) { + visitSymbol(callback); + } + } + + for (const declaration of /** @type {any[]} */ (symbol?.declarations ?? [])) { + const fn = unwrap(declaration.initializer) ?? declaration; + if (!ts.isFunctionLike(fn)) { + continue; + } + + node.arguments.forEach((input, index) => { + const argument = unwrap(input); + const parameter = fn.parameters[index]; + if (!parameter) { + return; + } + + /** @type {Map} */ + const values = new Map(); + + for (const property of ts.isObjectLiteralExpression(argument) ? argument.properties : []) { + if (ts.isSpreadAssignment(property)) { + values.clear(); + } + if (ts.isPropertyAssignment(property) && literal(property.initializer) !== undefined) { + values.set(propertyName(property.name), property.initializer); + } + } + + const key = symbolAt(parameter.name); + const previous = inputs.get(key); + + if (!previous) { + inputs.set(key, values); + } else { + for (const [name, value] of previous) { + if (!values.has(name) || literal(values.get(name)) !== literal(value)) { + previous.delete(name); + } + } + } + }); + } + + visitSymbol(symbol); + } + + /** @param {import('typescript').PropertyAssignment} node */ + function collectProperty(node) { + const name = propertyName(node.name); + + // Controller callbacks can own writes without a direct call in the hook. + const callback = symbolAt(node.initializer); + if (callback?.declarations?.some(isFunctionDeclaration)) { + visitSymbol(callback); + } + + if (name === 'components' && ts.isObjectLiteralExpression(node.initializer)) { + for (const member of node.initializer.properties) { + const slot = propertyName(member.name); + if (ts.isPropertyAssignment(member) && typeof slot === 'string') { + inherit(member.initializer, slot, member); + } + } + } + + if (name === 'elementType') { + const owner = attributeOwner(node); + if (owner) { + inherit(node.initializer, owner, node); + } + } + + if (typeof name === 'string' && name.startsWith('data-')) { + writes.push({ name, slot: attributeOwner(node), expression: node.initializer, node }); + } + } + + /** @param {import('typescript').CallExpression} node */ + function collectImperativeWrite(node) { + const method = ts.isPropertyAccessExpression(node.expression) ? node.expression.name.text : ''; + const name = ['setAttribute', 'toggleAttribute', 'removeAttribute'].includes(method) + ? literal(node.arguments[0]) + : undefined; + if (typeof name !== 'string' || !name.startsWith('data-')) { + return; + } + + writes.push({ + name, + slot: undefined, + target: targetLabel( + /** @type {import('typescript').PropertyAccessExpression} */ (node.expression).expression, + node, + ), + imperative: true, + removal: method === 'removeAttribute', + expression: node.arguments[1], + node, + domain: method === 'toggleAttribute' ? { kind: 'presence' } : undefined, + }); + } + + /** @param {import('typescript').BinaryExpression} node */ + function collectAssignment(node) { + const lhs = unwrap(node.left); + + // TODO: only `state.["data-x"]` is attributed. Writes on a local slot + // variable (`root["data-type"] = type` in useNavDrawer) surface as + // unresolved-slot; resolve the identifier through its slot.*() declaration. + if (ts.isElementAccessExpression(lhs)) { + const name = literal(lhs.argumentExpression); + if (typeof name === 'string' && name.startsWith('data-')) { + const owner = unwrap(lhs.expression); + + writes.push({ + name, + slot: ts.isPropertyAccessExpression(owner) ? owner.name.text : undefined, + expression: node.right, + node, + }); + } + } + } + + /** @param {import('typescript').JsxOpeningElement | import('typescript').JsxSelfClosingElement} node */ + function isReturnedJsx(node) { + let rendered = /** @type {import('typescript').Node} */ (ts.isJsxOpeningElement(node) ? node.parent : node); + while ( + ts.isParenthesizedExpression(rendered.parent) || + ts.isAsExpression(rendered.parent) || + ts.isSatisfiesExpression(rendered.parent) + ) { + rendered = rendered.parent; + } + return ( + ts.isReturnStatement(rendered.parent) || + (ts.isArrowFunction(rendered.parent) && rendered.parent.body === rendered) + ); + } + + /** @param {import('typescript').JsxOpeningElement | import('typescript').JsxSelfClosingElement} node */ + function collectJsx(node) { + const returned = isReturnedJsx(node); + if (returned) { + inherit(node.tagName, 'root', node); + } + + const slot = returned ? 'root' : ts.isPropertyAccessExpression(node.tagName) ? node.tagName.name.text : undefined; + + for (const attribute of node.attributes.properties) { + if (!ts.isJsxAttribute(attribute)) { + continue; + } + const name = propertyName(attribute.name); + if (typeof name !== 'string' || !name.startsWith('data-')) { + continue; + } + const initializer = attribute.initializer; + writes.push({ + name, + slot, + node: attribute, + expression: initializer && ts.isJsxExpression(initializer) ? initializer.expression : initializer, + // JSX shorthand passes boolean true, not presence serialization. + domain: initializer ? undefined : { kind: 'literal', values: [true] }, + }); + } + } + + /** @param {import('typescript').Node} node */ + function walk(node) { + if (ts.isCallExpression(node)) { + collectCall(node); + collectImperativeWrite(node); + } + + if (ts.isIdentifier(node)) { + const symbol = symbolAt(node); + if (symbol?.declarations?.some(declaration => ts.isVariableDeclaration(declaration) && flatMap(declaration))) { + visitSymbol(symbol); + } + } + + if (ts.isVariableDeclaration(node)) { + const type = checker.getTypeAtLocation(node.name); + if (type.getProperty('components')) { + states.push({ type, node: node.name }); + } + } + + if (ts.isPropertyAccessExpression(node)) { + visitSymbol(symbolAt(node.expression)); + } + if (ts.isPropertyAssignment(node)) { + collectProperty(node); + } + if (ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)) { + collectJsx(node); + } + if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken) { + collectAssignment(node); + } + + ts.forEachChild(node, walk); + } + + visitSymbol(component); + + return { maps, writes, states, inputs, compositions }; +} + +module.exports = { collectEvidence }; diff --git a/scripts/storybook/src/styling-metadata/extractor.js b/scripts/storybook/src/styling-metadata/extractor.js new file mode 100644 index 00000000000000..c45b8e332b780e --- /dev/null +++ b/scripts/storybook/src/styling-metadata/extractor.js @@ -0,0 +1,254 @@ +// @ts-check +const path = require('path'); + +const { collectEvidence } = require('./collect-evidence'); +const { assembleMetadata } = require('./metadata'); +const { createSourceAnalysis } = require('./source-analysis'); + +/** + * @typedef {import('./metadata').ComponentDataAttributes} ComponentDataAttributes + * @typedef {{ dataAttributes?: import('./metadata').DataAttributesDefinition; classNames?: { classNames: Record } }} ComponentStylingMetadata + * @typedef {{ + * component: string; + * code: string; + * message: string; + * file?: string; + * start?: number; + * resolved?: boolean; + * slot?: string; + * attribute?: string; + * [key: string]: unknown; + * }} Diagnostic + * @typedef {ReturnType} StylingMetadataExtractor + */ + +// Registered with webpack alongside analyzed source files so watch/cache invalidation includes tooling changes. +const toolingDependencies = [ + __filename, + ...['source-analysis', 'collect-evidence', 'metadata'].map(name => require.resolve(`./${name}`)), +]; + +/** + * Results and diagnostics are plain JSON; copies keep callers from mutating cached values. + * + * @template T + * @param {T} value + * @returns {T} + */ +const clone = value => (value === undefined ? value : JSON.parse(JSON.stringify(value))); + +/** + * Build-time only: analyzes component source without executing components. + * + * - `classNames`: the `ClassNames` export next to the component (v9 convention, + * enforced by react-conformance `component-has-static-classnames-object`). + * - `dataAttributes`: `data-*` attributes the component writes on its slots (headless convention). + * + * @param {{ tsConfigPath: string; classNames?: boolean; dataAttributes?: boolean }} options + */ +function createStylingMetadataExtractor({ tsConfigPath, classNames = false, dataAttributes = false }) { + if (!tsConfigPath) { + throw new Error('tsConfigPath is required'); + } + + const analysis = createSourceAnalysis({ tsConfigPath: path.resolve(tsConfigPath) }); + /** @type {Map} unique diagnostics by content */ + const diagnostics = new Map(); + /** @type {Map>} diagnostics per component key */ + const scoped = new Map(); + /** @type {Array>} scopes of the components being extracted */ + const scopes = []; + /** @type {Map} */ + const cache = new Map(); + /** @type {Map} */ + const dataAttributeCache = new Map(); + /** @type {Set} */ + const active = new Set(); + + /** @param {Iterable} items */ + const addToScopes = items => { + for (const item of items) { + scopes.forEach(scope => scope.add(item)); + } + }; + + /** + * @param {string} component + * @returns {import('./collect-evidence').Report} + */ + const reporter = + component => + (code, message, node, context = {}) => { + /** @type {Diagnostic} */ + const diagnostic = { + component, + code, + message, + ...context, + ...(node ? { file: node.getSourceFile().fileName, start: node.getStart() } : {}), + }; + const key = JSON.stringify(diagnostic); + if (!diagnostics.has(key)) { + diagnostics.set(key, diagnostic); + } + addToScopes([/** @type {Diagnostic} */ (diagnostics.get(key))]); + }; + + /** + * @template T + * @param {string} key + * @param {() => T} fn + * @returns {T} + */ + function inScope(key, fn) { + const scope = scoped.get(key) ?? new Set(); + scopes.push(scope); + try { + return fn(); + } finally { + scoped.set(key, scope); + scopes.pop(); + } + } + + /** + * @param {string} specifier - module the story imports the component from + * @param {string} importer - story file + * @param {string} exportName - imported name + * @returns {ComponentStylingMetadata | undefined} + */ + function extractComponent(specifier, importer, exportName) { + const { moduleExport, isRuntime, resolveModule, flatMap } = analysis; + const file = resolveModule(specifier, importer); + const key = `${specifier}\0${exportName}`; + + if (!cache.has(key)) { + cache.set( + key, + inScope(key, () => { + // Modules outside this tsconfig's `paths` (other Storybooks share the rule) and story-local helpers + // (e.g. a wrapper defined in a sibling `*.stories.tsx`) have no styling contract to document. + if (!file || !isRuntime(file)) { + return undefined; + } + + const report = reporter(exportName); + const component = moduleExport(file, exportName); + if (!component?.declarations?.some(node => isRuntime(node.getSourceFile()))) { + report('unresolved-component', `Cannot resolve runtime export ${exportName} from ${specifier}`); + return undefined; + } + + /** @type {ComponentStylingMetadata} */ + const definition = {}; + + if (classNames) { + const name = `${exportName.charAt(0).toLowerCase()}${exportName.slice(1)}ClassNames`; + const declaration = moduleExport(file, name)?.valueDeclaration; + const map = declaration && flatMap(declaration); + if (map) { + definition.classNames = { classNames: map }; + } else if (declaration) { + report( + 'unresolved-map', + `Cannot statically evaluate ${name}; expected a flat object of string literals, constants or spreads of such objects`, + declaration, + ); + } + } + + const extracted = dataAttributes ? extractDataAttributes(component) : undefined; + if (extracted) { + definition.dataAttributes = extracted.dataAttributes; + } + + return Object.keys(definition).length ? definition : undefined; + }), + ); + } + + return clone(cache.get(key)); + } + + /** + * @param {import('typescript').Symbol} component + * @returns {ComponentDataAttributes | undefined} + */ + function extractDataAttributes(component) { + const declaration = /** @type {import('typescript').Declaration} */ (component.declarations?.[0]); + const fileName = declaration.getSourceFile().fileName; + const key = `${path.resolve(fileName)}\0${component.name}`; + const report = reporter(component.name); + + if (dataAttributeCache.has(key)) { + addToScopes(scoped.get(key) ?? []); + return dataAttributeCache.get(key); + } + + if (active.has(key)) { + report('recursive-composition', `Recursive component ${component.name}`, declaration); + return undefined; + } + + active.add(key); + + try { + return inScope(key, () => { + const packageRoot = analysis.packageRootOf(fileName); + /** @param {import('typescript').SourceFile} file */ + const isLocal = file => + packageRoot !== undefined && path.resolve(file.fileName).startsWith(packageRoot + path.sep); + + const evidence = collectEvidence(component, analysis, report, isLocal); + const definition = assembleMetadata(evidence, analysis, report, extractDataAttributes, isLocal); + + markResolved(component.name, definition); + dataAttributeCache.set(key, definition); + return definition; + }); + } finally { + active.delete(key); + } + } + + /** + * Warnings about an attribute that another source documented after all are not actionable. + * + * @param {string} componentName + * @param {ComponentDataAttributes | undefined} definition + */ + function markResolved(componentName, definition) { + for (const diagnostic of diagnostics.values()) { + if ( + diagnostic.component === componentName && + diagnostic.attribute && + ['unresolved-map-entry', 'unassigned-contract'].includes(diagnostic.code) + ) { + diagnostic.resolved = Object.entries(definition?.dataAttributes.attributes ?? {}).some( + ([slot, entries]) => + (!diagnostic.slot || slot === diagnostic.slot) && + Object.entries(entries).some( + ([key, name]) => + name === diagnostic.attribute && + (diagnostic.code !== 'unassigned-contract' || + definition?.dataAttributes.values?.[slot]?.[key] !== null), + ), + ); + } + } + } + + return { + extractComponent, + getDependencies: () => [...new Set([...analysis.getDependencies(), ...toolingDependencies])].sort(), + getDiagnostics: () => clone([...diagnostics.values()]), + /** + * @param {string} specifier + * @param {string} exportName + * @returns {Diagnostic[]} + */ + getDiagnosticsFor: (specifier, exportName) => clone([...(scoped.get(`${specifier}\0${exportName}`) ?? [])]), + }; +} + +module.exports = { createStylingMetadataExtractor }; diff --git a/scripts/storybook/src/styling-metadata/index.js b/scripts/storybook/src/styling-metadata/index.js new file mode 100644 index 00000000000000..58a6dca0b3a9d2 --- /dev/null +++ b/scripts/storybook/src/styling-metadata/index.js @@ -0,0 +1,57 @@ +// @ts-check + +const LOADER_PATH = require.resolve('./loader'); + +/** + * @typedef {{ + * config: import('webpack').Configuration; + * tsConfigPath: string; + * classNames?: boolean; + * dataAttributes?: boolean; + * include?: import('webpack').RuleSetCondition; + * }} RegisterStylingMetadataRuleOptions + */ + +/** + * Registers the webpack `pre` loader that injects generated styling metadata + * (`parameters.__generatedStylingMetadata`) into story metas. `@fluentui/react-storybook-addon` renders it + * as "Data attributes" and "Classes" sections in the docs page. + * + * Calling it again replaces the previously registered rule, so a Storybook that extends another config can + * switch sections (for example headless documents data attributes instead of class names). + * + * @param {RegisterStylingMetadataRuleOptions} options + */ +function registerStylingMetadataRule(options) { + const { config, tsConfigPath, classNames = false, dataAttributes = false, include } = options; + + config.module = config.module ?? {}; + config.module.rules = (config.module.rules ?? []).filter(rule => !isStylingMetadataRule(rule)); + + if (!classNames && !dataAttributes) { + return config; + } + + config.module.rules.push({ + test: /\.stories\.tsx$/, + // Analyze the original TypeScript before any transpilation. + enforce: 'pre', + include: include ?? /[\\/]stories[\\/]src[\\/]/, + exclude: /[\\/]node_modules[\\/]/, + use: { + loader: LOADER_PATH, + /** @type {import('./loader').StylingMetadataLoaderOptions} */ + options: { tsConfigPath, classNames, dataAttributes }, + }, + }); + + return config; +} + +/** @param {unknown} rule */ +function isStylingMetadataRule(rule) { + const use = rule && typeof rule === 'object' ? /** @type {import('webpack').RuleSetRule} */ (rule).use : undefined; + return Boolean(use && typeof use === 'object' && !Array.isArray(use) && use.loader === LOADER_PATH); +} + +module.exports = { registerStylingMetadataRule }; diff --git a/scripts/storybook/src/styling-metadata/loader.js b/scripts/storybook/src/styling-metadata/loader.js new file mode 100644 index 00000000000000..c255818a48d030 --- /dev/null +++ b/scripts/storybook/src/styling-metadata/loader.js @@ -0,0 +1,313 @@ +// @ts-check +const path = require('path'); + +const ts = require('typescript'); + +/** + * @typedef {import('./extractor').StylingMetadataExtractor} StylingMetadataExtractor + * @typedef {import('./extractor').Diagnostic} Diagnostic + * @typedef {import('./extractor').ComponentStylingMetadata} ComponentStylingMetadata + * @typedef {{ tsConfigPath: string; classNames?: boolean; dataAttributes?: boolean }} StylingMetadataLoaderOptions + */ + +// TODO: styling tables are derived by static analysis because components ship no metadata files yet. +// Once they do, read those files in the extractor and keep this loader only as the injector. See README.md. + +/** @type {WeakMap }>} */ +const compilationExtractors = new WeakMap(); + +/** + * @param {any} node + * @returns {any} + */ +function unwrap(node) { + while (ts.isAsExpression(node) || ts.isSatisfiesExpression(node) || ts.isParenthesizedExpression(node)) { + node = node.expression; + } + return node; +} + +/** + * @param {import('typescript').ObjectLiteralExpression} object + * @param {string} name + * @returns {import('typescript').PropertyAssignment | undefined} + */ +function property(object, name) { + return /** @type {import('typescript').PropertyAssignment | undefined} */ ( + object.properties.find( + node => + ts.isPropertyAssignment(node) && + (ts.isIdentifier(node.name) || ts.isStringLiteral(node.name)) && + node.name.text === name, + ) + ); +} + +/** + * Injects `parameters.__generatedStylingMetadata` into a story file's default export (meta). + * + * Reads `component` and `subcomponents`, resolves each through its import, and asks the extractor for + * its styling metadata. Authored `parameters` are preserved and take precedence at render time. + * + * @param {{ + * source: string; + * filename: string; + * extractor: Pick & Partial>; + * onDiagnostics?: (diagnostics: Diagnostic[]) => void; + * }} options + * @returns {string} + */ +function transformStory({ source, filename, extractor, onDiagnostics = () => undefined }) { + const file = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + + const exported = file.statements.find(ts.isExportAssignment); + if (!exported || exported.isExportEquals) { + return source; + } + let meta = unwrap(exported.expression); + + /** @returns {import('typescript').VariableDeclaration[]} */ + const localDeclarations = () => + file.statements.filter(ts.isVariableStatement).flatMap(statement => [...statement.declarationList.declarations]); + + if (ts.isIdentifier(meta)) { + const name = meta.text; + const declaration = localDeclarations().find(node => ts.isIdentifier(node.name) && node.name.text === name); + if (!declaration?.initializer) { + return source; + } + meta = unwrap(declaration.initializer); + } + + if (!ts.isObjectLiteralExpression(meta)) { + return source; + } + const metaObject = /** @type {import('typescript').ObjectLiteralExpression} */ (meta); + const component = property(metaObject, 'component'); + + if (!component || !ts.isIdentifier(component.initializer)) { + return source; + } + + /** @type {Map} */ + const imports = new Map(); + + for (const statement of file.statements) { + if ( + !ts.isImportDeclaration(statement) || + statement.importClause?.isTypeOnly || + !statement.importClause?.namedBindings || + !ts.isNamedImports(statement.importClause.namedBindings) + ) { + continue; + } + const specifier = /** @type {import('typescript').StringLiteral} */ (statement.moduleSpecifier).text; + + for (const binding of statement.importClause.namedBindings.elements) { + imports.set(binding.name.text, { specifier, name: (binding.propertyName ?? binding.name).text }); + } + } + + const primary = imports.get(component.initializer.text); + + if (!primary) { + return source; + } + + const parameters = property(metaObject, 'parameters'); + + /** + * Resolve local constants and object spreads only for the static opt-out. + * Authored expressions themselves are preserved and evaluated by Storybook. + * + * @param {any} node + * @param {Set} seen + * @returns {any} + */ + function resolveLocal(node, seen = new Set()) { + if (!node) { + return undefined; + } + node = unwrap(node); + if (!ts.isIdentifier(node) || seen.has(node.text)) { + return node; + } + seen = new Set(seen).add(node.text); + const declaration = localDeclarations().find(entry => ts.isIdentifier(entry.name) && entry.name.text === node.text); + return declaration?.initializer ? resolveLocal(declaration.initializer, seen) : node; + } + + /** + * @param {any} node + * @param {string} name + * @param {Set} seen + * @returns {any} + */ + function effectiveProperty(node, name, seen = new Set()) { + node = resolveLocal(node); + if (!node || !ts.isObjectLiteralExpression(node) || seen.has(node)) { + return undefined; + } + seen = new Set(seen).add(node); + for (const entry of [...node.properties].reverse()) { + if (ts.isSpreadAssignment(entry)) { + const spread = resolveLocal(entry.expression); + // An opaque spread may overwrite an earlier value; do not guess false. + if (!spread || !ts.isObjectLiteralExpression(spread)) { + return undefined; + } + const value = effectiveProperty(spread, name, seen); + if (value) { + return value; + } + } else if ( + (ts.isPropertyAssignment(entry) || ts.isShorthandPropertyAssignment(entry)) && + entry.name.getText(file).replace(/^["']|["']$/g, '') === name + ) { + return ts.isPropertyAssignment(entry) ? entry.initializer : entry.name; + } + } + return undefined; + } + + let docs = parameters?.initializer; + + for (const name of ['reactStorybookAddon', 'docs']) { + docs = effectiveProperty(docs, name); + } + + if (resolveLocal(docs)?.kind === ts.SyntaxKind.FalseKeyword) { + return source; + } + + /** @type {Map} */ + const members = new Map([[primary.name, primary]]); + const subcomponents = property(metaObject, 'subcomponents'); + const subcomponentsValue = subcomponents && unwrap(subcomponents.initializer); + + if (subcomponentsValue && ts.isObjectLiteralExpression(subcomponentsValue)) { + for (const node of /** @type {import('typescript').ObjectLiteralExpression} */ (subcomponentsValue).properties) { + const local = ts.isShorthandPropertyAssignment(node) + ? node.name + : ts.isPropertyAssignment(node) + ? node.initializer + : undefined; + const imported = local && ts.isIdentifier(local) ? imports.get(local.text) : undefined; + if (imported) { + members.set(imported.name, imported); + } + } + } + + /** @type {Map} */ + const definitions = new Map(); + + for (const [name, { specifier }] of members) { + definitions.set(name, extractor.extractComponent(specifier, filename, name)); + onDiagnostics(extractor.getDiagnosticsFor?.(specifier, name) ?? []); + } + + /** @type {Record} */ + const generated = {}; + + for (const section of /** @type {const} */ (['dataAttributes', 'classNames'])) { + const entries = [...members.keys()].flatMap(name => { + const result = definitions.get(name)?.[section]; + return result ? [[name, result]] : []; + }); + + if (!entries.length) { + continue; + } + + generated[section] = members.size > 1 ? { components: Object.fromEntries(entries) } : entries[0][1]; + } + + if (!Object.keys(generated).length) { + return source; + } + + // Keep generated defaults outside authored docs: Storybook deep-merges + // parameters, which would otherwise combine incompatible family/direct shapes. + const authored = parameters?.initializer.getText(file) ?? '{}'; + const merged = `{ ...(${authored}), __generatedStylingMetadata: ${JSON.stringify(generated)} }`; + + if (parameters) { + return source.slice(0, parameters.initializer.getStart(file)) + merged + source.slice(parameters.initializer.end); + } + + return ( + source.slice(0, metaObject.getStart(file) + 1) + + ` parameters: ${merged}, ` + + source.slice(metaObject.getStart(file) + 1) + ); +} + +/** + * Webpack loader. Register it with {@link import('./index').registerStylingMetadataRule}. + * + * @this {import('webpack').LoaderContext} + * @param {string} source + * @param {any} sourceMap + */ +function stylingMetadataLoader(source, sourceMap) { + this.cacheable(true); + const options = this.getOptions(); + + // TODO: `_compilation` is deprecated loader API; find another per-build key. + const compilation = /** @type {object} */ (this._compilation); + + let entry = compilationExtractors.get(compilation); + + if (!entry) { + const { createStylingMetadataExtractor } = require('./extractor'); + entry = { extractor: createStylingMetadataExtractor(options), reported: new Set() }; + compilationExtractors.set(compilation, entry); + } + + const { extractor, reported } = entry; + + /** @type {string} */ + let code = source; + /** @type {Diagnostic[]} */ + const storyDiagnostics = []; + + try { + code = transformStory({ + source, + filename: this.resourcePath, + extractor, + onDiagnostics: diagnostics => storyDiagnostics.push(...diagnostics), + }); + } catch (error) { + // Docs metadata must never break a Storybook build: render without the tables instead. + this.emitWarning(new Error(`[styling metadata] ${this.resourcePath}: ${/** @type {Error} */ (error).message}`)); + } finally { + // Register workspace sources only: files under node_modules do not change during a build. + for (const dependency of extractor.getDependencies()) { + if (!/[\\/]node_modules[\\/]/.test(dependency)) { + this.addDependency(dependency); + } + } + } + + for (const diagnostic of storyDiagnostics) { + if (diagnostic.resolved || diagnostic.code === 'open-domain') { + continue; + } + + const key = JSON.stringify(diagnostic); + if (reported.has(key)) { + continue; + } + reported.add(key); + + const location = diagnostic.file ? ` (${path.relative(process.cwd(), diagnostic.file)})` : ''; + this.emitWarning(new Error(`[styling metadata: ${diagnostic.component}] ${diagnostic.message}${location}`)); + } + + this.callback(null, code, code === source ? sourceMap : undefined); +} + +module.exports = stylingMetadataLoader; +module.exports.transformStory = transformStory; diff --git a/scripts/storybook/src/styling-metadata/metadata.js b/scripts/storybook/src/styling-metadata/metadata.js new file mode 100644 index 00000000000000..5dc693b7fba5c8 --- /dev/null +++ b/scripts/storybook/src/styling-metadata/metadata.js @@ -0,0 +1,452 @@ +// @ts-check + +/** + * @typedef {import('./source-analysis').SourceAnalysis} SourceAnalysis + * @typedef {import('./source-analysis').Domain} Domain + * @typedef {import('./collect-evidence').Evidence} Evidence + * @typedef {import('./collect-evidence').Report} Report + * @typedef {Array} Values + * @typedef {{ target: string; attribute: string; values?: Values | null }} ElementDataAttribute + * @typedef {{ + * attributes: Record>; + * elements?: ElementDataAttribute[]; + * values?: Record>; + * }} DataAttributesDefinition + * @typedef {{ dataAttributes: DataAttributesDefinition }} ComponentDataAttributes + */ + +const PRIORITY = { + inherited: -1, + type: 0, + literalType: 2, + expression: 1, + literal: 3, + presence: 4, + open: 1, + unresolved: 1, + local: 10, +}; + +/** @param {string} name */ +const camelKey = name => name.slice(5).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); + +/** + * @param {...Values} values + * @returns {Domain} + */ +const literalUnion = (...values) => ({ kind: 'literal', values: [...new Set(values.flat())] }); + +/** + * @param {Values | null | undefined} values + * @returns {Domain} + */ +function domainFromValues(values) { + if (values === null) { + return { kind: 'unresolved' }; + } + if (values === undefined) { + return { kind: 'presence' }; + } + + return values.length ? { kind: 'literal', values } : { kind: 'open', values: [] }; +} + +/** + * @param {Domain} domain + * @returns {Values | null | undefined} + */ +function domainValues(domain) { + if (domain.kind === 'presence') { + return undefined; + } + + return domain.kind === 'unresolved' ? null : domain.values; +} + +/** + * Merges the collected evidence into the `{ dataAttributes }` definition rendered by the docs page. + * + * Precedence per slot attribute: writes in the documented package, then literal writes elsewhere, + * then declared slot types, then attributes inherited from components composed into a slot. + * + * @param {Evidence} evidence + * @param {SourceAnalysis} analysis + * @param {Report} report + * @param {(symbol: import('typescript').Symbol, slot: string) => ComponentDataAttributes | undefined} inherit + * @param {(file: import('typescript').SourceFile) => boolean} isLocal + * @returns {ComponentDataAttributes | undefined} + */ +function assembleMetadata(evidence, analysis, report, inherit, isLocal) { + const { ts, checker, typeDomain, expressionDomain } = analysis; + const { maps, writes, states, inputs, compositions } = evidence; + + /** @type {Record>} */ + const attributes = {}; + /** @type {Map} */ + const domains = new Map(); + /** @type {Map} */ + const elements = new Map(); + + /** + * @param {string} target + * @param {string} attribute + * @param {Domain} domain + */ + function addElement(target, attribute, domain) { + const key = `${target}\0${attribute}`; + const previous = elements.get(key); + + if (previous?.domain.kind === 'literal' && domain.kind === 'literal') { + domain = literalUnion(previous.domain.values, domain.values); + } + + if (!previous || domain.kind !== 'unresolved') { + elements.set(key, { target, attribute, domain }); + } + } + + /** + * @param {string | undefined} slot + * @param {string} key + * @param {string} name + * @param {Domain} domain + * @param {import('typescript').Node | undefined} node + * @param {number} priority + */ + function add(slot, key, name, domain, node, priority) { + if (!slot) { + report('unresolved-slot', `Cannot locate slot for ${name}`, node, { attribute: name }); + return; + } + + if (slot.startsWith('element:')) { + addElement(`Rendered <${slot.slice('element:'.length)}>`, name, domain); + return; + } + + const entries = (attributes[slot] ??= {}); + + // Retain map keys even if an upstream write supplied a DOM-derived key first. + const previousKey = Object.keys(entries).find(item => entries[item] === name); + if (previousKey && previousKey !== key) { + delete entries[previousKey]; + } + entries[key] = name; + + const id = `${slot}\0${name}`; + const previous = domains.get(id); + + if ( + priority === PRIORITY.inherited && + previous?.priority === PRIORITY.inherited && + domain.kind === 'literal' && + previous.domain.kind === 'literal' + ) { + domain = literalUnion(previous.domain.values, domain.values); + } + + if ( + !previous || + (domain.kind !== 'unresolved' && (previous.domain.kind === 'unresolved' || priority >= previous.priority)) + ) { + domains.set(id, { domain, node, priority }); + } + } + + function applyTypes() { + const runtimeAttributes = new Set( + writes.filter(write => !write.removal && write.slot).map(write => `${write.slot}\0${write.name}`), + ); + + for (const { type, node } of states) { + const components = /** @type {import('typescript').Symbol} */ (type.getProperty('components')); + const slots = checker.getPropertiesOfType(checker.getTypeOfSymbolAtLocation(components, node)); + + for (const slot of slots) { + const property = type.getProperty(slot.name); + if (!property) { + continue; + } + + const slotType = checker.getNonNullableType(checker.getTypeOfSymbolAtLocation(property, node)); + + for (const attribute of checker.getPropertiesOfType(slotType)) { + if (!attribute.name.startsWith('data-')) { + continue; + } + + if (!runtimeAttributes.has(`${slot.name}\0${attribute.name}`)) { + report( + 'unassigned-contract', + `${slot.name}.${attribute.name} has a type contract but no reachable map or runtime assignment`, + node, + { slot: slot.name, attribute: attribute.name }, + ); + continue; + } + + const domain = typeDomain(checker.getTypeOfSymbolAtLocation(attribute, node)); + + add( + slot.name, + camelKey(attribute.name), + attribute.name, + domain, + node, + domain.kind === 'literal' ? PRIORITY.literalType : PRIORITY.type, + ); + } + } + } + } + + function applyWrites() { + /** @type {Map; domain: Domain }> }>} */ + const alternatives = new Map(); + /** @type {Map>} */ + const writeBaseline = new Map(); + + /** @param {import('typescript').Node} node */ + function executionPath(node) { + /** @type {Map} */ + const branches = new Map(); + let child = node; + for (let parent = child.parent; parent; child = parent, parent = parent.parent) { + if (ts.isFunctionLike(parent)) { + return { scope: /** @type {import('typescript').Node} */ (parent), branches }; + } + if (ts.isIfStatement(parent) && child !== parent.expression) { + branches.set(parent, child === parent.thenStatement ? 'then' : 'else'); + } + if (ts.isConditionalExpression(parent) && child !== parent.condition) { + branches.set(parent, child === parent.whenTrue ? 'then' : 'else'); + } + if (ts.isIterationStatement(parent, false)) { + branches.set(parent, 'loop'); + } + if (ts.isCaseBlock(parent)) { + branches.set(parent, child); + } + if ( + ts.isBinaryExpression(parent) && + child === parent.right && + [ + ts.SyntaxKind.AmpersandAmpersandToken, + ts.SyntaxKind.BarBarToken, + ts.SyntaxKind.QuestionQuestionToken, + ].includes(parent.operatorToken.kind) + ) { + branches.set(parent, 'right'); + } + } + return { scope: /** @type {import('typescript').Node} */ (node.getSourceFile()), branches }; + } + + for (const write of writes) { + // Cleanup alone does not establish an emitted attribute or a value domain. + if (write.removal) { + continue; + } + + let domain = write.domain ?? expressionDomain(write.expression, new Set(), inputs); + + if (write.imperative && write.target) { + addElement(write.target, write.name, domain); + continue; + } + + const local = isLocal(write.node.getSourceFile()); + + const pathInfo = executionPath(write.node); + const id = `${write.slot}\0${write.name}`; + if (!writeBaseline.has(id)) { + writeBaseline.set(id, domains.get(id)); + } + const previous = alternatives.get(id); + let paths = previous?.scope === pathInfo.scope ? previous.paths : []; + // A later write replaces earlier writes on paths it always covers. Writes + // on alternative branches remain possible and contribute a value union. + paths = paths.filter( + entry => ![...pathInfo.branches].every(([branch, arm]) => entry.branches.get(branch) === arm), + ); + paths.push({ branches: pathInfo.branches, domain }); + alternatives.set(id, { scope: pathInfo.scope, paths }); + if (paths.length > 1) { + if (paths.every(entry => entry.domain.kind === 'literal')) { + domain = literalUnion(...paths.map(entry => (entry.domain.kind === 'literal' ? entry.domain.values : []))); + } else if (paths.every(entry => entry.domain.kind === 'presence')) { + domain = { kind: 'presence' }; + } else { + domain = { kind: 'unresolved' }; + } + } + + // Replacing an aggregate must not retain the narrower value previously + // accumulated from just one branch. Preserve upstream/type precedence. + if (previous?.scope === pathInfo.scope) { + const baseline = writeBaseline.get(id); + if (baseline) { + domains.set(id, baseline); + } else { + domains.delete(id); + } + } else if (previous) { + writeBaseline.set(id, domains.get(id)); + } + const priority = (local ? PRIORITY.local : 0) + PRIORITY[domain.kind]; + + add( + write.slot === 'children' ? undefined : write.slot, + camelKey(write.name), + write.name, + domain, + write.node, + priority, + ); + } + } + + function applyMaps() { + for (const { map, declaration } of maps.values()) { + /** @type {Set} */ + const observedSlots = new Set(); + /** @param {string} name */ + const slotsFor = name => Object.keys(attributes).filter(slot => Object.values(attributes[slot]).includes(name)); + + for (const name of Object.values(map)) { + for (const slot of slotsFor(name)) { + observedSlots.add(slot); + } + } + + for (const [key, name] of Object.entries(map)) { + const slots = slotsFor(name); + if (!slots.length && observedSlots.size === 1) { + slots.push([...observedSlots][0]); + } + + if (!slots.length) { + report( + 'unresolved-map-entry', + `No runtime slot for ${declaration.name.getText()}.${key} (${name})`, + declaration, + { + attribute: name, + }, + ); + } + + for (const slot of slots) { + const existing = domains.get(`${slot}\0${name}`); + add( + slot, + key, + name, + existing?.domain ?? { kind: 'unresolved' }, + declaration, + existing?.priority ?? PRIORITY.type, + ); + } + } + } + } + + function applyComposition() { + for (const [slot, { symbols }] of compositions) { + for (const symbol of symbols) { + const inherited = inherit(symbol, slot)?.dataAttributes; + const declaration = symbol.declarations?.[0]; + + for (const [sourceSlot, entries] of Object.entries(inherited?.attributes ?? {})) { + const target = sourceSlot === 'root' ? slot : `${slot}.${sourceSlot}`; + + for (const [key, name] of Object.entries(entries)) { + add( + target, + key, + name, + domainFromValues(inherited?.values?.[sourceSlot]?.[key]), + declaration, + PRIORITY.inherited, + ); + } + } + + for (const entry of inherited?.elements ?? []) { + addElement(`${slot} → ${entry.target}`, entry.attribute, domainFromValues(entry.values)); + } + } + } + } + + /** @returns {ComponentDataAttributes | undefined} */ + function finish() { + /** @type {Record>} */ + const values = {}; + + for (const [slot, entries] of Object.entries(attributes)) { + for (const [key, name] of Object.entries(entries)) { + const { domain, node } = /** @type {NonNullable>} */ ( + domains.get(`${slot}\0${name}`) + ); + if (domain.kind === 'presence') { + continue; + } + + if (domain.kind === 'unresolved') { + report('unresolved-domain', `Cannot infer values for ${slot}.${key} (${name}); documented as unknown`, node, { + slot, + attribute: name, + }); + } + + (values[slot] ??= {})[key] = /** @type {Values | null} */ (domainValues(domain)); + + if (domain.kind === 'open') { + report('open-domain', `${slot}.${key} (${name}) has an unconstrained string/number domain`, node); + } + } + if (!Object.keys(entries).length) { + delete attributes[slot]; + } + } + + /** @type {ElementDataAttribute[]} */ + const elementRows = [...elements.values()].map(({ target, attribute, domain }) => { + if (domain.kind === 'unresolved') { + report( + 'unresolved-domain', + `Cannot infer values for ${target} ${attribute}; documented as unknown`, + undefined, + { + attribute, + target, + }, + ); + } + + return { target, attribute, ...(domain.kind === 'presence' ? {} : { values: domainValues(domain) }) }; + }); + + if (!Object.keys(attributes).length && !elementRows.length) { + return undefined; + } + + return { + dataAttributes: { + attributes, + ...(elementRows.length ? { elements: elementRows } : {}), + ...(Object.keys(values).length ? { values } : {}), + }, + }; + } + + applyTypes(); + applyWrites(); + applyMaps(); + applyComposition(); + + return finish(); +} + +module.exports = { assembleMetadata }; diff --git a/scripts/storybook/src/styling-metadata/source-analysis.js b/scripts/storybook/src/styling-metadata/source-analysis.js new file mode 100644 index 00000000000000..0eed9b8c40e129 --- /dev/null +++ b/scripts/storybook/src/styling-metadata/source-analysis.js @@ -0,0 +1,752 @@ +// @ts-check +/* eslint-disable no-bitwise -- TypeScript exposes symbol, type and node flags as bit sets */ +const fs = require('fs'); +const path = require('path'); + +const ts = require('typescript'); + +/** + * @typedef {{ kind: 'literal'; values: Array } | { kind: 'open'; values: [] } | { kind: 'presence' } | { kind: 'unresolved' }} Domain + * @typedef {ReturnType} SourceAnalysis + */ + +// TODO: attributes written by Fluent infrastructure outside the component walk +// (react-aria data-activedescendant*, tabster data-fui-focus-within) are not +// discovered. Resolve their exported constants or allowlist them as presence. +const DATA_ATTRIBUTE_HELPERS = new Set(['stringifyDataAttribute', 'toDataAttributeValue']); + +const NON_RUNTIME_FILE = /\.(stories|test|spec)\.[cm]?[jt]sx?$/; + +/** + * TypeScript program over workspace sources, resolved through the `paths` of `tsConfigPath`. + * + * The program is rooted lazily at the modules stories import from, so a Storybook build only parses + * the packages its stories document. + * + * @param {{ tsConfigPath: string }} options + */ +function createSourceAnalysis({ tsConfigPath }) { + /** @type {Set} */ + const dependencies = new Set(); + + /** @param {string} filename */ + const readFile = filename => { + const text = ts.sys.readFile(filename); + if (text !== undefined) { + dependencies.add(path.resolve(filename)); + } + return text; + }; + + /** @param {readonly import('typescript').Diagnostic[]} errors */ + function checkConfig(errors) { + // TS18002/18003: no inputs. Roots are added lazily, so an empty file list is expected. + const relevant = errors.filter(error => error.code !== 18002 && error.code !== 18003); + if (relevant.length) { + throw new Error(relevant.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n')); + } + } + + const config = ts.readConfigFile(tsConfigPath, readFile); + checkConfig(config.error ? [config.error] : []); + + const parsed = ts.parseJsonConfigFileContent( + { ...config.config, files: [], include: [] }, + { ...ts.sys, readFile }, + path.dirname(tsConfigPath), + undefined, + tsConfigPath, + ); + checkConfig(parsed.errors); + + /** @type {import('typescript').CompilerOptions} */ + const options = { + ...parsed.options, + // Base configs leave `jsx` to package configs; without it `.tsx` modules do not resolve. + jsx: parsed.options.jsx ?? ts.JsxEmit.ReactJSX, + noEmit: true, + skipLibCheck: true, + }; + const host = ts.createCompilerHost(options, true); + host.readFile = readFile; + + /** @type {Set} */ + const roots = new Set(); + let program = ts.createProgram({ rootNames: [], options, host }); + let checker = program.getTypeChecker(); + + /** + * Resolves a module specifier as imported from `containingFile` and adds it to the program. + * + * @param {string} specifier + * @param {string} containingFile + * @returns {import('typescript').SourceFile | undefined} + */ + function resolveModule(specifier, containingFile) { + const resolved = ts.resolveModuleName(specifier, containingFile, options, host).resolvedModule; + if (!resolved) { + return undefined; + } + + const filename = path.resolve(resolved.resolvedFileName); + if (!program.getSourceFile(filename)) { + // Each rebuild discards the checker's caches, so root all entries of a wildcard mapping at once. + for (const entry of [filename, ...wildcardEntries(specifier)]) { + roots.add(entry); + } + program = ts.createProgram({ rootNames: [...roots], options, host, oldProgram: program }); + checker = program.getTypeChecker(); + } + + return program.getSourceFile(filename); + } + + const pathsBase = /** @type {string} */ (options.pathsBasePath ?? options.baseUrl ?? path.dirname(tsConfigPath)); + + /** + * Files covered by the `paths` wildcard mapping a specifier matches, e.g. every + * `library/src/*.ts` for `@fluentui/react-headless-components-preview/*`. + * + * @param {string} specifier + * @returns {string[]} + */ + function wildcardEntries(specifier) { + for (const [pattern, targets] of Object.entries(options.paths ?? {})) { + const [prefix, suffix, ...rest] = pattern.split('*'); + if (suffix === undefined || rest.length || !specifier.startsWith(prefix) || !specifier.endsWith(suffix)) { + continue; + } + + return targets.flatMap(target => { + const absolute = path.resolve(pathsBase, target); + const [filePrefix, fileSuffix, ...more] = path.basename(absolute).split('*'); + if (fileSuffix === undefined || more.length || path.dirname(absolute).includes('*')) { + return []; + } + const directory = path.dirname(absolute); + return fs.existsSync(directory) + ? fs + .readdirSync(directory) + .filter(name => name.startsWith(filePrefix) && name.endsWith(fileSuffix)) + .map(name => path.join(directory, name)) + : []; + }); + } + + return []; + } + + /** @type {Map} */ + const packageRoots = new Map(); + + /** + * Nearest directory containing a package.json. + * + * @param {string} filename + * @returns {string | undefined} + */ + function packageRootOf(filename) { + const directory = path.dirname(path.resolve(filename)); + if (packageRoots.has(directory)) { + return packageRoots.get(directory); + } + + const root = fs.existsSync(path.join(directory, 'package.json')) + ? directory + : path.dirname(directory) === directory + ? undefined + : packageRootOf(directory); + packageRoots.set(directory, root); + return root; + } + + /** @param {import('typescript').SourceFile} file */ + const isRuntime = file => + !file.isDeclarationFile && !NON_RUNTIME_FILE.test(file.fileName) && !/[\\/]node_modules[\\/]/.test(file.fileName); + + /** + * @param {any} node + * @returns {any} + */ + function unwrap(node) { + while ( + node && + (ts.isAsExpression(node) || + ts.isSatisfiesExpression(node) || + ts.isParenthesizedExpression(node) || + ts.isNonNullExpression(node)) + ) { + node = node.expression; + } + + return node; + } + + /** + * @param {import('typescript').Symbol | undefined} symbol + * @returns {import('typescript').Symbol | undefined} + */ + function unalias(symbol) { + const seen = new Set(); + + while (symbol && symbol.flags & ts.SymbolFlags.Alias && !seen.has(symbol)) { + seen.add(symbol); + symbol = checker.getAliasedSymbol(symbol); + } + + return symbol; + } + + /** + * @param {import('typescript').Node | undefined} node + * @returns {import('typescript').Symbol | undefined} + */ + const symbolAt = node => (node ? unalias(checker.getSymbolAtLocation(node)) : undefined); + + /** + * @param {import('typescript').SourceFile | undefined} file + * @param {string} name + */ + function moduleExport(file, name) { + const symbol = file && checker.getSymbolAtLocation(file); + + return symbol && unalias(checker.getExportsOfModule(symbol).find(item => item.name === name)); + } + + /** @param {import('typescript').Symbol | undefined} symbol */ + const symbolType = symbol => + symbol && + checker.getTypeOfSymbolAtLocation(symbol, symbol.valueDeclaration ?? /** @type {any} */ (symbol.declarations)[0]); + + /** @param {import('typescript').Symbol | undefined} symbol */ + const signatureOf = symbol => symbolType(symbol)?.getCallSignatures()[0]; + + /** + * @param {import('typescript').Type | undefined} type + * @param {string} name + * @param {import('typescript').Node} node + */ + function propertyType(type, name, node) { + const property = type && checker.getNonNullableType(type).getProperty(name); + + return property && checker.getTypeOfSymbolAtLocation(property, node); + } + + /** + * @param {any} node + * @param {Set} seen + * @returns {import('typescript').Type | undefined} + */ + function declaredType(node, seen = new Set()) { + node = unwrap(node); + if (!node || seen.has(node)) { + return undefined; + } + + seen = new Set(seen).add(node); + + if (ts.isPropertyAccessExpression(node)) { + const type = propertyType(declaredType(node.expression, seen), node.name.text, node); + if (type) { + return type; + } + } + + if (ts.isCallExpression(node)) { + const signature = signatureOf(symbolAt(node.expression)); + + if (signature) { + // Selector hooks return the selected property rather than the context. + const selector = node.arguments[0]; + if (selector && ts.isArrowFunction(selector) && ts.isPropertyAccessExpression(selector.body)) { + const callback = signatureOf(signature.parameters[0]); + const contextType = symbolType(callback?.parameters[0]); + const selected = contextType?.getProperty(selector.body.name.text); + if (selected) { + return checker.getTypeOfSymbolAtLocation(selected, selector.body); + } + } + + return checker.getReturnTypeOfSignature(signature); + } + } + + for (const declaration of /** @type {any[]} */ (symbolAt(node)?.declarations ?? [])) { + if (ts.isBindingElement(declaration) && ts.isObjectBindingPattern(declaration.parent)) { + const owner = /** @type {any} */ (declaration.parent.parent); + const type = owner.initializer ? declaredType(owner.initializer, seen) : checker.getTypeAtLocation(owner); + const name = propertyName(declaration.propertyName ?? declaration.name); + const result = name === undefined ? undefined : propertyType(type, String(name), declaration); + if (result) { + return result; + } + } + + if (ts.isParameter(declaration)) { + return checker.getTypeAtLocation(declaration); + } + + if (declaration.initializer && !ts.isBindingElement(declaration)) { + const result = declaredType(declaration.initializer, seen); + if (result) { + return result; + } + } + } + + return checker.getTypeAtLocation(node); + } + + /** + * @param {any} node + * @returns {string | number | boolean | undefined} + */ + function propertyName(node) { + if (!node) { + return undefined; + } + + if (ts.isIdentifier(node) || ts.isStringLiteralLike(node) || ts.isNumericLiteral(node)) { + return node.text; + } + if (ts.isComputedPropertyName(node)) { + return literal(node.expression); + } + + return undefined; + } + + /** + * Statically evaluates a literal-valued expression. + * + * @param {any} node + * @param {Set} seen + * @returns {string | number | boolean | undefined} + */ + function literal(node, seen = new Set()) { + node = unwrap(node); + if (!node) { + return undefined; + } + + if (ts.isStringLiteralLike(node)) { + return node.text; + } + if (ts.isNumericLiteral(node)) { + return Number(node.text); + } + if (node.kind === ts.SyntaxKind.TrueKeyword) { + return true; + } + if (node.kind === ts.SyntaxKind.FalseKeyword) { + return false; + } + + if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) { + const owner = symbolAt(/** @type {any} */ (node).expression); + const key = ts.isPropertyAccessExpression(node) ? node.name.text : literal(node.argumentExpression, seen); + if (owner && !seen.has(owner)) { + const nextSeen = new Set(seen).add(owner); + for (const declaration of /** @type {any[]} */ (owner.declarations ?? [])) { + const object = unwrap(declaration.initializer); + if (!object || !ts.isObjectLiteralExpression(object)) { + continue; + } + const property = object.properties.find( + item => ts.isPropertyAssignment(item) && propertyName(item.name) === key, + ); + if (property) { + return literal(/** @type {any} */ (property).initializer, nextSeen); + } + } + } + } + + if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.MinusToken) { + const value = literal(node.operand, seen); + return typeof value === 'number' ? -value : undefined; + } + + const symbol = symbolAt(node); + + if (symbol && !seen.has(symbol)) { + seen = new Set(seen).add(symbol); + for (const declaration of /** @type {any[]} */ (symbol.declarations ?? [])) { + if (declaration.initializer) { + const value = literal(declaration.initializer, seen); + if (value !== undefined) { + return value; + } + } + } + } + + const type = checker.getTypeAtLocation(node); + if (type.isStringLiteral() || type.isNumberLiteral()) { + return type.value; + } + + return undefined; + } + + /** + * Evaluates `const map = { key: 'literal', ...otherMap }` to a string record. + * + * @param {any} declaration + * @param {Set} seen + * @returns {Record | undefined} + */ + function flatMap(declaration, seen = new Set()) { + const object = unwrap(declaration?.initializer); + if (!object || !ts.isObjectLiteralExpression(object) || seen.has(declaration)) { + return undefined; + } + + seen = new Set(seen).add(declaration); + + /** @type {Record} */ + const entries = {}; + + for (const property of object.properties) { + if (ts.isSpreadAssignment(property)) { + const spread = symbolAt(unwrap(property.expression))?.valueDeclaration; + const inherited = spread && flatMap(spread, seen); + if (!inherited) { + return undefined; + } + Object.assign(entries, inherited); + continue; + } + + if (!ts.isPropertyAssignment(property)) { + return undefined; + } + + const key = propertyName(property.name); + const value = literal(property.initializer); + if (typeof key !== 'string' || typeof value !== 'string') { + return undefined; + } + + entries[key] = value; + } + + return Object.keys(entries).length ? entries : undefined; + } + + /** + * @param {import('typescript').Type} type + * @returns {Domain} + */ + function typeDomain(type) { + /** @type {Array} */ + const values = []; + let open = false; + + for (const member of type.isUnion() ? type.types : [type]) { + if (member.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Null | ts.TypeFlags.Never)) { + continue; + } + + if (member.isStringLiteral() || member.isNumberLiteral()) { + values.push(member.value); + } else if (member.flags & ts.TypeFlags.BooleanLiteral) { + values.push(/** @type {any} */ (member).intrinsicName === 'true'); + } else if (member.flags & (ts.TypeFlags.String | ts.TypeFlags.Number)) { + open = true; + } else { + return { kind: 'unresolved' }; + } + } + + if (open) { + return { kind: 'open', values: [] }; + } + + return values.length ? { kind: 'literal', values: [...new Set(values)] } : { kind: 'unresolved' }; + } + + /** + * Expression that supplies `name` on an object-valued expression. + * + * @param {any} expression + * @param {string} name + * @param {Set} seen + * @returns {import('typescript').Expression | undefined} + */ + function propertyExpression(expression, name, seen = new Set()) { + expression = unwrap(expression); + if (!expression || seen.has(expression)) { + return undefined; + } + + seen = new Set(seen).add(expression); + + if (ts.isObjectLiteralExpression(expression)) { + for (const property of [...expression.properties].reverse()) { + if (propertyName(/** @type {any} */ (property).name) === name) { + if (ts.isPropertyAssignment(property)) { + return property.initializer; + } + if (ts.isShorthandPropertyAssignment(property)) { + return /** @type {any} */ (checker.getShorthandAssignmentValueSymbol(property)?.valueDeclaration)?.name; + } + } + + if (ts.isSpreadAssignment(property)) { + const inherited = propertyExpression(property.expression, name, seen); + if (inherited) { + return inherited; + } + } + } + } + + if (ts.isCallExpression(expression)) { + const symbol = symbolAt(/** @type {any} */ (expression).expression); + for (const declaration of /** @type {any[]} */ (symbol?.declarations ?? [])) { + const fn = unwrap(declaration.initializer) ?? declaration; + if (!ts.isFunctionLike(fn) || !(/** @type {any} */ (fn).body)) { + continue; + } + const body = /** @type {any} */ (fn).body; + if (!ts.isBlock(body)) { + return propertyExpression(body, name, seen); + } + const returned = body.statements.filter(ts.isReturnStatement); + if (returned.length === 1) { + return propertyExpression(returned[0].expression, name, seen); + } + } + } + + for (const declaration of /** @type {any[]} */ (symbolAt(expression)?.declarations ?? [])) { + if (declaration.initializer && !ts.isBindingElement(declaration)) { + const result = propertyExpression(declaration.initializer, name, seen); + if (result) { + return result; + } + } + } + + return undefined; + } + + /** + * Infers the values an expression can serialize to as a data attribute. + * + * @param {any} expression + * @param {Set} seen + * @param {Map>} inputs + * @returns {Domain} + */ + function expressionDomain(expression, seen = new Set(), inputs = new Map()) { + expression = unwrap(expression); + if (!expression || seen.has(expression)) { + return { kind: 'unresolved' }; + } + + seen = new Set(seen).add(expression); + /** @param {any} node */ + const infer = node => expressionDomain(node, seen, inputs); + + if (ts.isCallExpression(expression)) { + const call = /** @type {import('typescript').CallExpression} */ (expression); + // Headless serializes booleans to attribute presence (`toDataAttributeValue`). + const helperName = symbolAt(call.expression)?.name; + const helper = helperName !== undefined && DATA_ATTRIBUTE_HELPERS.has(helperName); + + if (helper && call.arguments.length === 1) { + const input = infer(call.arguments[0]); + + if (input.kind === 'literal') { + const values = input.values.filter(value => !['true', 'false'].includes(String(value))).map(String); + + if (values.length && input.values.some(value => value === true || value === 'true')) { + values.unshift(''); + } + + return values.length ? { kind: 'literal', values: [...new Set(values)] } : { kind: 'presence' }; + } + + // A generic helper's string return does not prove an open input domain. + return { kind: 'unresolved' }; + } + + // `String(size)` serializes a numeric union; keep the literal domain + // instead of falling through to the widened `string` return type. + if ( + ts.isIdentifier(call.expression) && + call.expression.text === 'String' && + call.arguments.length === 1 && + (symbolAt(call.expression)?.declarations ?? []).every( + declaration => declaration.getSourceFile().isDeclarationFile, + ) + ) { + const input = infer(call.arguments[0]); + return input.kind === 'literal' ? { kind: 'literal', values: [...new Set(input.values.map(String))] } : input; + } + } + + if (ts.isPropertyAccessExpression(expression)) { + const access = /** @type {import('typescript').PropertyAccessExpression} */ (expression); + const supplied = inputs.get(symbolAt(access.expression))?.get(access.name.text); + if (supplied) { + return infer(supplied); + } + + const value = propertyExpression(access.expression, access.name.text); + if (value && value !== expression) { + const inferred = infer(value); + if (inferred.kind !== 'unresolved') { + return inferred; + } + } + } + + for (const declaration of /** @type {any[]} */ (symbolAt(expression)?.declarations ?? [])) { + if ( + ts.isBindingElement(declaration) && + ts.isArrayBindingPattern(declaration.parent) && + declaration.parent.elements.indexOf(declaration) === 0 + ) { + const call = unwrap(/** @type {any} */ (declaration.parent.parent).initializer); + if (call && ts.isCallExpression(call) && symbolAt(call.expression)?.name === 'useControllableState') { + const candidates = ['state', 'defaultState', 'initialState'] + .map(name => propertyExpression(call.arguments[0], name)) + .filter(Boolean); + const domains = candidates.map(infer); + if ( + domains.length && + domains.every(entry => entry.kind === 'literal' && entry.values.every(value => typeof value === 'boolean')) + ) { + return { kind: 'literal', values: [false, true] }; + } + } + } + + if (ts.isBindingElement(declaration) && ts.isObjectBindingPattern(declaration.parent)) { + const owner = /** @type {any} */ (declaration.parent.parent); + const name = propertyName(declaration.propertyName ?? declaration.name); + const supplied = inputs.get(symbolAt(owner.name))?.get(name); + if (supplied) { + return infer(supplied); + } + + const value = owner.initializer && typeof name === 'string' && propertyExpression(owner.initializer, name); + if (value) { + return infer(value); + } + } + } + + if (ts.isConditionalExpression(expression)) { + const conditional = /** @type {import('typescript').ConditionalExpression} */ (expression); + const condition = infer(conditional.condition); + if (condition.kind === 'literal' && condition.values.length === 1 && typeof condition.values[0] === 'boolean') { + return infer(condition.values[0] ? conditional.whenTrue : conditional.whenFalse); + } + + let comparison = unwrap(conditional.condition); + const conditionDeclaration = symbolAt(comparison)?.valueDeclaration; + if (conditionDeclaration && ts.isVariableDeclaration(conditionDeclaration)) { + comparison = unwrap(conditionDeclaration.initializer); + } + + if ( + comparison && + ts.isBinaryExpression(comparison) && + comparison.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken + ) { + const compared = literal(comparison.right); + const whenTrue = infer(conditional.whenTrue); + const whenFalse = infer(conditional.whenFalse); + if ( + compared !== undefined && + symbolAt(conditional.whenFalse) === symbolAt(comparison.left) && + whenTrue.kind === 'literal' && + whenFalse.kind === 'literal' + ) { + return { + kind: 'literal', + values: [...new Set([...whenTrue.values, ...whenFalse.values.filter(value => value !== compared)])], + }; + } + } + } + + // Evaluate conditional initializers before widened type fallbacks. + const valueDeclaration = symbolAt(expression)?.valueDeclaration; + if (valueDeclaration && ts.isVariableDeclaration(valueDeclaration) && valueDeclaration.initializer) { + const initializer = unwrap(valueDeclaration.initializer); + if (ts.isConditionalExpression(initializer)) { + const inferred = infer(initializer); + if (inferred.kind !== 'unresolved') { + return inferred; + } + } + } + + let domain = typeDomain(declaredType(expression) ?? checker.getTypeAtLocation(expression)); + + if (domain.kind === 'open' || domain.kind === 'unresolved') { + for (const item of /** @type {any[]} */ (symbolAt(expression)?.declarations ?? [])) { + if (item.initializer && !ts.isBindingElement(item)) { + const inferred = infer(item.initializer); + if (inferred.kind !== 'unresolved') { + domain = inferred; + } + } + } + + if (ts.isConditionalExpression(expression)) { + const conditional = /** @type {import('typescript').ConditionalExpression} */ (expression); + const branches = [conditional.whenTrue, conditional.whenFalse].map(infer); + if (branches.every(branch => branch.kind === 'literal')) { + domain = { + kind: 'literal', + values: [...new Set(branches.flatMap(branch => (branch.kind === 'literal' ? branch.values : [])))], + }; + } + } + } + + const type = checker.getTypeAtLocation(expression); + const canBeAbsent = (type.isUnion() ? type.types : [type]).some( + member => member.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Null), + ); + + if ( + domain.kind === 'literal' && + domain.values.length === 1 && + domain.values[0] === '' && + canBeAbsent && + ts.isConditionalExpression(expression) + ) { + return { kind: 'presence' }; + } + + return domain; + } + + return { + ts, + get checker() { + return checker; + }, + isRuntime, + packageRootOf, + resolveModule, + unwrap, + symbolAt, + moduleExport, + flatMap, + literal, + propertyName, + typeDomain, + expressionDomain, + getDependencies: () => [...dependencies], + }; +} + +module.exports = { createSourceAnalysis }; diff --git a/scripts/storybook/src/styling-metadata/styling-metadata.spec.ts b/scripts/storybook/src/styling-metadata/styling-metadata.spec.ts new file mode 100644 index 00000000000000..9487907831cb6c --- /dev/null +++ b/scripts/storybook/src/styling-metadata/styling-metadata.spec.ts @@ -0,0 +1,248 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { workspaceRoot } from '@nx/devkit'; + +import { createStylingMetadataExtractor } from './extractor'; +import { transformStory } from './loader'; + +import { registerStylingMetadataRule } from './index'; + +const fixtureTsConfig = path.join(__dirname, '__fixtures__/tsconfig.json'); +const fixtureStory = path.join(__dirname, '__fixtures__/Card.stories.tsx'); +const headlessStories = path.join( + workspaceRoot, + 'packages/react-components/react-headless-components-preview/stories/src', +); +const v9Stories = path.join(workspaceRoot, 'packages/react-components'); + +type Extractor = ReturnType; + +const MARKER = '__generatedStylingMetadata: '; + +/** Parses the injected JSON, which is followed by the rest of the story source. */ +function generated(output: string) { + const start = output.indexOf(MARKER); + if (start === -1) { + return undefined; + } + const json = output.slice(start + MARKER.length); + for (let end = json.indexOf('}'); end !== -1; end = json.indexOf('}', end + 1)) { + try { + return JSON.parse(json.slice(0, end + 1)); + } catch { + // keep extending until the object is balanced + } + } + throw new Error('Unterminated generated styling metadata'); +} + +function transformFile(filename: string, extractor: Extractor) { + return transformStory({ source: fs.readFileSync(filename, 'utf-8'), filename, extractor }); +} + +describe('styling metadata', () => { + describe('transformStory', () => { + const extractor = { + extractComponent: jest.fn((_specifier: string, _importer: string, name: string) => ({ + classNames: { classNames: { root: `fui-${name}` } }, + })), + }; + + const transform = (source: string) => transformStory({ source, filename: fixtureStory, extractor }); + + beforeEach(() => extractor.extractComponent.mockClear()); + + it('injects metadata for the story component next to authored parameters', () => { + const output = transform( + [ + `import { Card } from 'fixture-components';`, + `export default { title: 'Card', component: Card, parameters: { layout: 'centered' } } as Meta;`, + ].join('\n'), + ); + + expect(output).toContain(`parameters: { ...({ layout: 'centered' }), __generatedStylingMetadata:`); + expect(generated(output)).toEqual({ classNames: { classNames: { root: 'fui-Card' } } }); + expect(extractor.extractComponent).toHaveBeenCalledWith('fixture-components', fixtureStory, 'Card'); + }); + + it('resolves a meta declared as a local constant and aliased imports', () => { + const output = transform( + [ + `import { Card as FixtureCard } from 'fixture-components';`, + `const meta = { title: 'Card', component: FixtureCard };`, + `export default meta;`, + ].join('\n'), + ); + + expect(output).toContain(`const meta = { parameters: {`); + expect(generated(output)).toEqual({ classNames: { classNames: { root: 'fui-Card' } } }); + }); + + it('groups subcomponents into a component family', () => { + const output = transform( + [ + `import { Card, Panel } from 'fixture-components';`, + `import { Local } from './Local.stories';`, + `export default { component: Card, subcomponents: { Panel, Local } };`, + ].join('\n'), + ); + + expect(generated(output)).toEqual({ + classNames: { + components: { + Card: { classNames: { root: 'fui-Card' } }, + Panel: { classNames: { root: 'fui-Panel' } }, + Local: { classNames: { root: 'fui-Local' } }, + }, + }, + }); + }); + + it('honors the reactStorybookAddon.docs opt-out', () => { + const source = [ + `import { Card } from 'fixture-components';`, + `const docs = false;`, + `export default { component: Card, parameters: { reactStorybookAddon: { docs } } };`, + ].join('\n'); + + expect(transform(source)).toBe(source); + expect(extractor.extractComponent).not.toHaveBeenCalled(); + }); + + it('leaves stories without an imported component unchanged', () => { + const source = `const Card = () => null;\nexport default { component: Card };`; + + expect(transform(source)).toBe(source); + }); + }); + + describe('extractor', () => { + it('evaluates class name maps with constants and spreads', () => { + const extractor = createStylingMetadataExtractor({ tsConfigPath: fixtureTsConfig, classNames: true }); + + expect(extractor.extractComponent('fixture-components', fixtureStory, 'Card')).toEqual({ + classNames: { + classNames: { root: 'fui-Card', header: 'fui-Card__header', footer: 'fui-Card__footer' }, + }, + }); + expect(extractor.getDiagnostics()).toEqual([]); + }); + + it('warns about class name maps it cannot evaluate', () => { + const extractor = createStylingMetadataExtractor({ tsConfigPath: fixtureTsConfig, classNames: true }); + + expect(extractor.extractComponent('fixture-components', fixtureStory, 'Tile')).toBeUndefined(); + expect(extractor.getDiagnosticsFor('fixture-components', 'Tile')).toEqual([ + expect.objectContaining({ code: 'unresolved-map', component: 'Tile' }), + ]); + }); + + it('extracts data attributes and their values', () => { + const extractor = createStylingMetadataExtractor({ tsConfigPath: fixtureTsConfig, dataAttributes: true }); + + expect(extractor.extractComponent('fixture-components', fixtureStory, 'Card')).toEqual({ + dataAttributes: { + attributes: { root: { disabled: 'data-disabled', size: 'data-size' } }, + values: { root: { size: ['small', 'large'] } }, + }, + }); + }); + + it('skips modules that do not resolve', () => { + const extractor = createStylingMetadataExtractor({ tsConfigPath: fixtureTsConfig, classNames: true }); + + expect(extractor.extractComponent('missing-module', fixtureStory, 'Card')).toBeUndefined(); + expect(extractor.getDiagnostics()).toEqual([]); + }); + }); + + // Guards the conventions of real sources the docsites document. + describe('workspace sources', () => { + jest.setTimeout(120_000); + + it('documents headless data attributes', () => { + const extractor = createStylingMetadataExtractor({ + tsConfigPath: path.join(workspaceRoot, 'tsconfig.base.json'), + dataAttributes: true, + }); + + expect(generated(transformFile(path.join(headlessStories, 'Button/index.stories.tsx'), extractor))).toEqual({ + dataAttributes: { + attributes: { + root: { + disabled: 'data-disabled', + disabledFocusable: 'data-disabled-focusable', + iconOnly: 'data-icon-only', + iconPosition: 'data-icon-position', + }, + }, + values: { root: { iconPosition: ['before', 'after'] } }, + }, + }); + + const accordion = generated(transformFile(path.join(headlessStories, 'Accordion/index.stories.tsx'), extractor)); + expect(Object.keys(accordion.dataAttributes.components)).toEqual([ + 'Accordion', + 'AccordionHeader', + 'AccordionItem', + 'AccordionPanel', + ]); + expect(accordion.dataAttributes.components.AccordionHeader).toEqual({ + attributes: { + root: { open: 'data-open', disabled: 'data-disabled', expandIconPosition: 'data-expand-icon-position' }, + }, + values: { root: { expandIconPosition: ['start', 'end'] } }, + }); + expect(accordion.classNames).toBeUndefined(); + }); + + it('documents v9 class names', () => { + const extractor = createStylingMetadataExtractor({ + tsConfigPath: path.join(workspaceRoot, 'tsconfig.base.json'), + classNames: true, + }); + + expect( + generated(transformFile(path.join(v9Stories, 'react-button/stories/src/Button/index.stories.tsx'), extractor)), + ).toEqual({ classNames: { classNames: { root: 'fui-Button', icon: 'fui-Button__icon' } } }); + + const accordion = generated( + transformFile(path.join(v9Stories, 'react-accordion/stories/src/Accordion/index.stories.tsx'), extractor), + ); + expect(accordion.classNames.components.AccordionHeader).toEqual({ + classNames: { + root: 'fui-AccordionHeader', + button: 'fui-AccordionHeader__button', + expandIcon: 'fui-AccordionHeader__expandIcon', + icon: 'fui-AccordionHeader__icon', + }, + }); + expect(accordion.dataAttributes).toBeUndefined(); + }); + }); + + describe('registerStylingMetadataRule', () => { + it('replaces a previously registered rule', () => { + const config = { module: { rules: [{ test: /\.css$/ }] } }; + + registerStylingMetadataRule({ config, tsConfigPath: fixtureTsConfig, classNames: true }); + registerStylingMetadataRule({ config, tsConfigPath: fixtureTsConfig, dataAttributes: true }); + + expect(config.module.rules).toHaveLength(2); + expect(config.module.rules[1]).toMatchObject({ + enforce: 'pre', + use: { options: { tsConfigPath: fixtureTsConfig, classNames: false, dataAttributes: true } }, + }); + }); + + it('removes the rule when no section is enabled', () => { + const config = { module: { rules: [] } }; + + registerStylingMetadataRule({ config, tsConfigPath: fixtureTsConfig, classNames: true }); + registerStylingMetadataRule({ config, tsConfigPath: fixtureTsConfig }); + + expect(config.module.rules).toEqual([]); + }); + }); +}); From fa74b717e815379eb52f9188812c730561c632f0 Mon Sep 17 00:00:00 2001 From: mainframev Date: Wed, 11 Nov 2026 13:33:38 +0100 Subject: [PATCH 2/3] feat(react-storybook-addon): render API reference with data attributes and classes Show Props, Data attributes and Classes as collapsible API Reference sections on the docs page, matching the Fluent Modern Storybook. Styling sections render generated metadata, overridable through parameters.reactStorybookAddon.docs. --- ...-a1988b99-5929-4498-8b55-856d4c1f0e31.json | 7 + .../react-storybook-addon/README.md | 3 + .../src/docs/FluentDocsPage.tsx | 25 +- .../src/docs/StylingTables.tsx | 346 ++++++++++++++++++ .../src/docs/stylingMetadata.test.ts | 89 +++++ .../src/docs/stylingMetadata.ts | 147 ++++++++ .../react-storybook-addon/src/hooks.ts | 5 +- 7 files changed, 613 insertions(+), 9 deletions(-) create mode 100644 change/@fluentui-react-storybook-addon-a1988b99-5929-4498-8b55-856d4c1f0e31.json create mode 100644 packages/react-components/react-storybook-addon/src/docs/StylingTables.tsx create mode 100644 packages/react-components/react-storybook-addon/src/docs/stylingMetadata.test.ts create mode 100644 packages/react-components/react-storybook-addon/src/docs/stylingMetadata.ts diff --git a/change/@fluentui-react-storybook-addon-a1988b99-5929-4498-8b55-856d4c1f0e31.json b/change/@fluentui-react-storybook-addon-a1988b99-5929-4498-8b55-856d4c1f0e31.json new file mode 100644 index 00000000000000..a0728d9137c8d0 --- /dev/null +++ b/change/@fluentui-react-storybook-addon-a1988b99-5929-4498-8b55-856d4c1f0e31.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: show Props, data attributes and classes as collapsible API Reference sections on the docs page", + "packageName": "@fluentui/react-storybook-addon", + "email": "vgenaev@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-storybook-addon/README.md b/packages/react-components/react-storybook-addon/README.md index 1ce937b7db8120..446255f1009444 100644 --- a/packages/react-components/react-storybook-addon/README.md +++ b/packages/react-components/react-storybook-addon/README.md @@ -57,6 +57,9 @@ This presets uses [custom docs container and page](https://storybook.js.org/docs - custom ArgsTable - `Slot` api rendering - native props support +- collapsible "API Reference" with Props, "Data attributes" and "Classes" sections, the latter two rendered from + `parameters.__generatedStylingMetadata` (generated by `registerStylingMetadataRule` from `@fluentui/scripts-storybook`) + or authored `parameters.reactStorybookAddon.docs.{dataAttributes,classNames}`, which take precedence ## Getting Started diff --git a/packages/react-components/react-storybook-addon/src/docs/FluentDocsPage.tsx b/packages/react-components/react-storybook-addon/src/docs/FluentDocsPage.tsx index b406a1725663ef..c1a33c954ce2f1 100644 --- a/packages/react-components/react-storybook-addon/src/docs/FluentDocsPage.tsx +++ b/packages/react-components/react-storybook-addon/src/docs/FluentDocsPage.tsx @@ -27,6 +27,8 @@ import { DirSwitch } from './DirSwitch'; import { ThemePicker } from './ThemePicker'; import { Toc, nameToHash } from './Toc'; import { CopyAsMarkdownButton } from './CopyAsMarkdownButton'; +import { ApiReference } from './StylingTables'; +import { resolveStylingMetadata } from './stylingMetadata'; type PrimaryStory = PreparedStory; @@ -377,6 +379,7 @@ export const FluentDocsPage = ({ const hideArgsTable = Boolean(primaryStoryContext.parameters?.docs?.hideArgsTable); const skipPrimaryStory = Boolean(primaryStoryContext.parameters?.docs?.skipPrimaryStory); + const stylingMetadata = resolveStylingMetadata(primaryStoryContext.parameters); const videos = primaryStoryContext.parameters?.videos ?? null; const styles = useStyles(); @@ -389,7 +392,11 @@ export const FluentDocsPage = ({ {renderPrimaryStory({ primaryStory: primaryStory, skipPrimaryStory })} - {renderArgsTable({ story: primaryStory, hideArgsTable })} + {!hideArgsTable && ( + + {renderArgsTable({ story: primaryStory, hideArgsTable })} + + )} {renderStories({ stories: stories.slice(1), skipPrimaryStory })} ); @@ -434,12 +441,16 @@ export const FluentDocsPage = ({ {videos && } {renderPrimaryStory({ primaryStory, skipPrimaryStory })} - {renderArgsTable({ - story: primaryStory, - hideArgsTable, - showSlotsApi: argTable.slotsApi, - showNativePropsApi: argTable.nativePropsApi, - })} + {!hideArgsTable && ( + + {renderArgsTable({ + story: primaryStory, + hideArgsTable, + showSlotsApi: argTable.slotsApi, + showNativePropsApi: argTable.nativePropsApi, + })} + + )} {renderStories({ stories: stories.slice(1), skipPrimaryStory })} {showTableOfContents && ( diff --git a/packages/react-components/react-storybook-addon/src/docs/StylingTables.tsx b/packages/react-components/react-storybook-addon/src/docs/StylingTables.tsx new file mode 100644 index 00000000000000..6f5eaf7cb8ce8e --- /dev/null +++ b/packages/react-components/react-storybook-addon/src/docs/StylingTables.tsx @@ -0,0 +1,346 @@ +import * as React from 'react'; +import { TabsState } from 'storybook/internal/components'; +import { makeStyles, mergeClasses } from '@griffel/react'; +import { ChevronRightRegular } from '@fluentui/react-icons'; + +import { + getClassNameRows, + getDataAttributeRows, + type ClassNameRow, + type DataAttributeRow, + type DataAttributeValue, + type StylingMetadata, +} from './stylingMetadata'; + +// Colors and sizes mirror Storybook's args table so Props, Data attributes and Classes read as one family. +const border = '1px solid rgba(38, 74, 115, 0.15)'; + +const useStyles = makeStyles({ + reference: { + marginBlock: '24px 40px', + }, + section: { + '& + &': { + borderTop: border, + }, + '&[open] > summary > svg': { + transform: 'rotate(90deg)', + }, + }, + summary: { + display: 'flex', + alignItems: 'center', + gap: '8px', + paddingBlock: '12px', + cursor: 'pointer', + listStyle: 'none', + color: 'rgb(92, 101, 112)', + fontSize: '14px', + fontWeight: 700, + lineHeight: '20px', + textTransform: 'uppercase', + '::-webkit-details-marker': { + display: 'none', + }, + ':hover': { + color: 'rgb(46, 52, 56)', + }, + '& > svg': { + flexShrink: 0, + fontSize: '20px', + transitionProperty: 'transform', + transitionDuration: '0.1s', + }, + }, + panel: { + paddingBlockEnd: '16px', + // Storybook's args table adds its own block margins; the panel owns spacing. + '& .docblock-argstable': { + marginBlock: 0, + }, + }, + scroll: { + overflowX: 'auto', + // Room for the tbody shadow inside the scroll container. + padding: '4px', + margin: '-4px', + ':focus-visible': { + outline: '2px solid rgb(2, 156, 253)', + outlineOffset: '2px', + }, + }, + table: { + width: '100%', + margin: 0, + border: 0, + borderCollapse: 'collapse', + borderSpacing: 0, + background: 'transparent', + color: 'rgb(46, 52, 56)', + fontSize: '16px', + lineHeight: '24px', + textAlign: 'start', + '& tr': { + border: 0, + background: 'transparent', + }, + '& th, & td': { + border: 0, + padding: '10px 15px 10px 20px', + textAlign: 'start', + verticalAlign: 'top', + }, + '& th:last-child, & td:last-child': { + paddingInlineEnd: '20px', + }, + '& th': { + background: 'transparent', + color: 'rgb(92, 101, 112)', + fontWeight: 700, + }, + '& tbody': { + filter: 'drop-shadow(rgba(0, 0, 0, 0.1) 0 1px 3px)', + }, + '& td': { + background: 'rgb(255, 255, 255)', + }, + '& tbody tr:not(:first-child) td': { + borderTop: border, + }, + '& tbody tr:first-child td:first-child': { + borderStartStartRadius: '4px', + }, + '& tbody tr:first-child td:last-child': { + borderStartEndRadius: '4px', + }, + '& tbody tr:last-child td:last-child': { + borderEndEndRadius: '4px', + }, + '& code': { + display: 'inline-block', + margin: 0, + padding: '2px 5px', + border: '1px solid rgba(0, 0, 0, 0.05)', + borderRadius: '3px', + background: 'rgb(247, 250, 252)', + color: 'rgba(46, 52, 56, 0.9)', + fontFamily: + 'ui-monospace, Menlo, Monaco, "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro", "Droid Sans Mono", "Courier New", monospace', + fontSize: '12px', + lineHeight: 1, + whiteSpace: 'nowrap', + }, + '@media (forced-colors: active)': { + '& td, & code': { + border: '1px solid CanvasText', + }, + }, + }, + /** The slot cell reaching the last row owns the table's bottom-start rounded corner. */ + bottomStart: { + borderEndStartRadius: '4px', + }, + values: { + display: 'inline-flex', + flexWrap: 'wrap', + gap: '4px', + }, +}); + +const AttributeValues = ({ values }: { values: readonly DataAttributeValue[] | undefined }) => { + const styles = useStyles(); + + if (values === undefined) { + return boolean; + } + + if (values.length === 0) { + return string; + } + + return ( + + {values.map((value, index) => ( + {typeof value === 'string' ? JSON.stringify(value) : String(value)} + ))} + + ); +}; + +const Table = ({ + label, + headers, + children, +}: { + label: string; + headers: readonly string[]; + children: React.ReactNode; +}): React.ReactElement => { + const styles = useStyles(); + + return ( +
+ {/* `docblock-argstable` opts out of docsite global table rules, as for Storybook's props table. */} + + + + {headers.map(header => ( + + ))} + + + {children} +
+ {header} +
+
+ ); +}; + +/** Rows of one slot (consecutive, as slots are unique per component) share a single slot cell. */ +const DataAttributeRows = ({ rows }: { rows: readonly DataAttributeRow[] }) => { + const styles = useStyles(); + + return ( + <> + {rows.map((row, index) => { + const first = index === 0 || rows[index - 1].slot !== row.slot; + const rowSpan = rows.filter(next => next.slot === row.slot).length; + + return ( + + {first && ( + 1 ? rowSpan : undefined} + className={index + rowSpan === rows.length ? styles.bottomStart : undefined} + > + {row.slot} + + )} + + {row.attribute} + + + + + + ); + })} + + ); +}; + +const ClassNameRows = ({ rows }: { rows: readonly ClassNameRow[] }) => { + const styles = useStyles(); + + return ( + <> + {rows.map((row, index) => ( + + + {row.slot} + + + {row.className} + + + ))} + + ); +}; + +/** One tab per family component, matching Storybook's subcomponent tabs in the props table. */ +const ComponentTabs = ({ + section, + config, + rows, + children, +}: { + section: string; + config: { components?: Readonly> } | undefined; + rows: readonly { component?: string }[]; + children: (component: string | undefined) => React.ReactNode; +}): React.ReactElement => { + // Family components in config order; a single component renders without tabs. + const present = new Set(rows.map(row => row.component)); + const components = config?.components ? Object.keys(config.components).filter(name => present.has(name)) : []; + + if (components.length <= 1) { + return <>{children(components[0])}; + } + + // `TabsState` renders each child's `title` as a tab and calls its function child with `{ active }`. + const tabs = components.map(component => ( +
+ {(({ active }: { active: boolean }) => (active ? children(component) : null)) as unknown as React.ReactNode} +
+ )); + + return {tabs as unknown as React.ComponentProps['children']}; +}; + +const ApiSection = ({ title, children }: { title: string; children: React.ReactNode }) => { + const styles = useStyles(); + + return ( +
+ + + {title} + +
{children}
+
+ ); +}; + +/** + * "API Reference" with collapsible Props, Data attributes and Classes sections. Styling sections come from + * `parameters.__generatedStylingMetadata` (or authored `parameters.reactStorybookAddon.docs`). + * + * Rows whose values could not be inferred (`null`) are omitted instead of rendered as unknown. Attributes on + * non-slot elements (`elements`) stay in the metadata but are not rendered until slot types cover those elements. + */ +export const ApiReference = ({ + metadata, + children, +}: { + metadata: StylingMetadata; + /** The props table. */ + children: React.ReactNode; +}): React.ReactElement => { + const styles = useStyles(); + const dataRows = getDataAttributeRows(metadata.dataAttributes).filter(row => row.values !== null); + const classRows = getClassNameRows(metadata.classNames); + + return ( +
+

API Reference

+ {children} + {dataRows.length > 0 && ( + + + {component => ( + + row.component === component)} /> +
+ )} +
+
+ )} + {classRows.length > 0 && ( + + + {component => ( + + row.component === component)} /> +
+ )} +
+
+ )} +
+ ); +}; diff --git a/packages/react-components/react-storybook-addon/src/docs/stylingMetadata.test.ts b/packages/react-components/react-storybook-addon/src/docs/stylingMetadata.test.ts new file mode 100644 index 00000000000000..f9f63ac06d9e40 --- /dev/null +++ b/packages/react-components/react-storybook-addon/src/docs/stylingMetadata.test.ts @@ -0,0 +1,89 @@ +import { getClassNameRows, getDataAttributeRows, resolveStylingMetadata } from './stylingMetadata'; + +describe('stylingMetadata', () => { + describe('resolveStylingMetadata', () => { + const generated = { + dataAttributes: { attributes: { root: { open: 'data-open' as const } } }, + classNames: { classNames: { root: 'fui-Generated' } }, + }; + + it('uses generated metadata by default', () => { + expect(resolveStylingMetadata({ __generatedStylingMetadata: generated })).toEqual(generated); + }); + + it('prefers authored sections per section', () => { + const classNames = { classNames: { root: 'fui-Authored' } }; + + expect( + resolveStylingMetadata({ + __generatedStylingMetadata: generated, + reactStorybookAddon: { docs: { classNames } }, + }), + ).toEqual({ dataAttributes: generated.dataAttributes, classNames }); + }); + + it('returns nothing when docs are disabled', () => { + expect( + resolveStylingMetadata({ __generatedStylingMetadata: generated, reactStorybookAddon: { docs: false } }), + ).toEqual({}); + }); + + it('handles missing parameters', () => { + expect(resolveStylingMetadata(undefined)).toEqual({ dataAttributes: undefined, classNames: undefined }); + }); + }); + + describe('getDataAttributeRows', () => { + it('maps slot attributes to rows with slot values, DOM-name values or presence', () => { + expect( + getDataAttributeRows({ + attributes: { + root: { disabled: 'data-disabled', size: 'data-size', position: 'data-position' }, + icon: { label: 'data-label' }, + }, + values: { + root: { size: ['small', 'large'], position: null }, + 'data-label': [], + }, + }), + ).toEqual([ + { component: undefined, slot: 'root', attribute: 'data-disabled', values: undefined }, + { component: undefined, slot: 'root', attribute: 'data-size', values: ['small', 'large'] }, + { component: undefined, slot: 'root', attribute: 'data-position', values: null }, + { component: undefined, slot: 'icon', attribute: 'data-label', values: [] }, + ]); + }); + + it('keeps family order', () => { + expect( + getDataAttributeRows({ + components: { + Accordion: { attributes: { root: { multiple: 'data-multiple' } } }, + AccordionItem: { attributes: { root: { open: 'data-open' } } }, + }, + }).map(row => `${row.component}.${row.attribute}`), + ).toEqual(['Accordion.data-multiple', 'AccordionItem.data-open']); + }); + }); + + describe('getClassNameRows', () => { + it('supports definitions and bare slot maps in families', () => { + expect( + getClassNameRows({ + components: { + Button: { classNames: { root: 'fui-Button', icon: 'fui-Button__icon' } }, + Link: { root: 'fui-Link' }, + }, + }), + ).toEqual([ + { component: 'Button', slot: 'root', className: 'fui-Button' }, + { component: 'Button', slot: 'icon', className: 'fui-Button__icon' }, + { component: 'Link', slot: 'root', className: 'fui-Link' }, + ]); + }); + + it('returns no rows without config', () => { + expect(getClassNameRows(undefined)).toEqual([]); + }); + }); +}); diff --git a/packages/react-components/react-storybook-addon/src/docs/stylingMetadata.ts b/packages/react-components/react-storybook-addon/src/docs/stylingMetadata.ts new file mode 100644 index 00000000000000..22129b7704baff --- /dev/null +++ b/packages/react-components/react-storybook-addon/src/docs/stylingMetadata.ts @@ -0,0 +1,147 @@ +export type DataAttributeName = `data-${string}`; + +export type DataAttributeValue = string | number | boolean; + +/** Slot name → attribute key → DOM attribute name. */ +export type SlotDataAttributes = Readonly>>>; + +type SlotAttributeValues = Readonly>; + +/** + * Values keyed by slot then attribute key, or by DOM attribute name. Slot/key entries win. + * An omitted attribute is a presence (boolean) attribute, `[]` means any string, and `null` means the values + * could not be inferred (the row is not rendered). + */ +export type DataAttributeValues = Readonly>; + +export type ElementDataAttribute = { + /** DOM target that is not a slot, e.g. `Rendered
`. */ + target: string; + attribute: DataAttributeName; + values?: readonly DataAttributeValue[] | null; +}; + +export type DataAttributesDefinition = { + attributes: SlotDataAttributes; + /** Attributes written on non-slot elements. Kept in the metadata, not rendered. */ + elements?: readonly ElementDataAttribute[]; + values?: DataAttributeValues; +}; + +/** Slot name → class name. */ +export type SlotClassNames = Readonly>; + +export type ClassNamesDefinition = { + classNames: SlotClassNames; +}; + +type ComponentFamily = { + components: Readonly>; +}; + +export type DataAttributesConfig = + | (DataAttributesDefinition & { components?: never }) + | (ComponentFamily & { attributes?: never; values?: never }); + +export type ClassNamesConfig = + | (ClassNamesDefinition & { components?: never }) + | (ComponentFamily & { classNames?: never }); + +export type StylingMetadata = { + dataAttributes?: DataAttributesConfig; + classNames?: ClassNamesConfig; +}; + +export type StylingMetadataParameters = { + /** Injected at build time by `registerStylingMetadataRule` from `@fluentui/scripts-storybook`. */ + __generatedStylingMetadata?: StylingMetadata; + reactStorybookAddon?: { docs?: unknown }; +}; + +/** + * Authored `parameters.reactStorybookAddon.docs.{dataAttributes,classNames}` win over generated metadata. + * `reactStorybookAddon.docs: false` disables both sections. + */ +export function resolveStylingMetadata(parameters: StylingMetadataParameters | undefined): StylingMetadata { + const docs = parameters?.reactStorybookAddon?.docs; + if (docs === false) { + return {}; + } + const authored: StylingMetadata = typeof docs === 'object' && docs !== null ? (docs as StylingMetadata) : {}; + const generated = parameters?.__generatedStylingMetadata; + + return { + dataAttributes: authored.dataAttributes ?? generated?.dataAttributes, + classNames: authored.classNames ?? generated?.classNames, + }; +} + +export type DataAttributeRow = { + component?: string; + slot: string; + attribute: DataAttributeName; + /** `undefined`: presence attribute; `[]`: any string; `null`: unknown. */ + values: readonly DataAttributeValue[] | null | undefined; +}; + +export type ClassNameRow = { + component?: string; + slot: string; + className: string; +}; + +function normalizeComponents( + config: (Definition & { components?: never }) | ComponentFamily, +): Array<{ component?: string; definition: Definition }> { + if (config.components !== undefined) { + return Object.entries(config.components).map(([component, definition]) => ({ component, definition })); + } + + return [{ definition: config as Definition }]; +} + +function getValues( + values: DataAttributeValues | undefined, + slot: string, + key: string, + attribute: DataAttributeName, +): readonly DataAttributeValue[] | null | undefined { + const slotValues = values?.[slot]; + if (slotValues && !Array.isArray(slotValues) && (slotValues as SlotAttributeValues)[key] !== undefined) { + return (slotValues as SlotAttributeValues)[key]; + } + const attributeValues = values?.[attribute]; + return Array.isArray(attributeValues) ? attributeValues : undefined; +} + +export function getDataAttributeRows(config?: DataAttributesConfig): DataAttributeRow[] { + if (!config) { + return []; + } + + return normalizeComponents(config).flatMap( + ({ component, definition: { attributes, values } }) => + Object.entries(attributes).flatMap(([slot, slotAttributes]) => + Object.entries(slotAttributes).map(([key, attribute]) => ({ + component, + slot, + attribute, + values: getValues(values, slot, key, attribute), + })), + ), + ); +} + +export function getClassNameRows(config?: ClassNamesConfig): ClassNameRow[] { + if (!config) { + return []; + } + + return normalizeComponents(config).flatMap(({ component, definition }) => { + const classNames = + typeof definition.classNames === 'object' + ? (definition as ClassNamesDefinition).classNames + : (definition as SlotClassNames); + return Object.entries(classNames).map(([slot, className]) => ({ component, slot, className })); + }); +} diff --git a/packages/react-components/react-storybook-addon/src/hooks.ts b/packages/react-components/react-storybook-addon/src/hooks.ts index 8c13791cb25641..7bb9c58c526a34 100644 --- a/packages/react-components/react-storybook-addon/src/hooks.ts +++ b/packages/react-components/react-storybook-addon/src/hooks.ts @@ -3,6 +3,7 @@ import type { Args as StorybookArgs, StoryContext as StorybookContext, Parameter import type { DIR_ID, STRICT_MODE_ID, THEME_ID, THEMES } from './constants'; import type { Theme, ThemeIds } from './theme'; +import type { StylingMetadata } from './docs/stylingMetadata'; export interface FluentStoryContext extends StorybookContext { globals: FluentGlobals; @@ -38,7 +39,7 @@ export interface FluentParameters extends Parameters { */ type FluentDocsConfig = | boolean - | { + | ({ tableOfContents?: boolean; dirSwitcher?: boolean; themePicker?: boolean; @@ -49,7 +50,7 @@ type FluentDocsConfig = slotsApi?: boolean; nativePropsApi?: boolean; }; - }; + } & StylingMetadata); export function useGlobals(): [FluentGlobals, (newGlobals: FluentGlobals) => void, FluentGlobals, FluentGlobals] { return useStorybookGlobals(); From 701f0193f9f604b7396ab04403cb4403f9d15340 Mon Sep 17 00:00:00 2001 From: mainframev Date: Wed, 11 Nov 2026 13:33:38 +0100 Subject: [PATCH 3/3] chore(storybook): enable styling metadata for v9 and headless docs v9 Storybooks document slot class names; the headless Storybook and docsite document data attributes. --- .storybook/main.js | 3 +++ apps/public-docsite-v9/.storybook/main.js | 2 ++ .../stories/.storybook/main.js | 3 +++ 3 files changed, 8 insertions(+) diff --git a/.storybook/main.js b/.storybook/main.js index b1fd67c4353024..232548bd46411e 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -7,6 +7,7 @@ const { loadWorkspaceAddon, registerTsPaths, registerRules, + registerStylingMetadataRule, rules, processBabelLoaderOptions, getImportMappingsForExportToSandboxAddon, @@ -53,6 +54,8 @@ module.exports = /** @type {import('./types').StorybookConfig} */ ({ webpackFinal: config => { registerRules({ config, rules: [rules.swcRule] }); registerTsPaths({ config, configFile: tsConfigPath }); + // v9 docs pages list each component's `*ClassNames` in a "Classes" section. + registerStylingMetadataRule({ config, tsConfigPath, classNames: true }); if ((process.env.CI || process.env.TF_BUILD) && config.plugins) { // Disable ProgressPlugin in PR/CI builds to reduce log verbosity (warnings and errors are still logged) diff --git a/apps/public-docsite-v9/.storybook/main.js b/apps/public-docsite-v9/.storybook/main.js index 2354a1b4e6ac9f..f315cce6398613 100644 --- a/apps/public-docsite-v9/.storybook/main.js +++ b/apps/public-docsite-v9/.storybook/main.js @@ -5,6 +5,7 @@ const { registerTsPaths, rules, registerRules, + registerStylingMetadataRule, } = require('@fluentui/scripts-storybook'); const rootMain = require('../../../.storybook/main'); @@ -84,6 +85,7 @@ module.exports = /** @type {Omit