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; @@ -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(); 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([]); + }); + }); +});