/** * @license MIT * @author Ayo Ayco * * A Custom Elements Manifest analyzer plugin that teaches * `@custom-elements-manifest/analyzer` about wcb's `static props` convention. * * For easy scaffolding, run: `npm create wcb@latest` * * This module is **dev-time only** — it runs in Node during `cem analyze` and * never reaches the browser. It is not imported by `WebComponent`, so the core * stays zero-dependency and within its size budget. * @see https://webcomponent.io/cem-plugin/ */ import { getKebabCase } from './utils/index.js' /** wcb statics that are implementation detail, not public API. */ const WCB_INTERNAL = new Set([ 'props', 'shadowRootInit', 'styles', 'strictProps', 'observedAttributes', 'template', ]) /** * Maps a `static props` default literal to a CEM type string. * @param {any} ts the TypeScript module handed to the hook * @param {any} init the property initializer node * @returns {string} `boolean` | `number` | `object` | `string` */ function typeOfDefault(ts, init) { if ( init.kind === ts.SyntaxKind.TrueKeyword || init.kind === ts.SyntaxKind.FalseKeyword ) return 'boolean' if (ts.isNumericLiteral(init)) return 'number' if (ts.isObjectLiteralExpression(init) || ts.isArrayLiteralExpression(init)) return 'object' return 'string' } /** * Reads a node's modifiers across TypeScript versions. * @param {any} ts the TypeScript module handed to the hook * @param {any} node the node to read modifiers from * @returns {any[]} the modifiers, or an empty array */ function modifiersOf(ts, node) { return ( (ts.canHaveModifiers?.(node) ? ts.getModifiers(node) : node.modifiers) ?? [] ) } /** * Unwraps `x as const` / `x satisfies T` down to the underlying expression. * @param {any} ts the TypeScript module handed to the hook * @param {any} node the expression node * @returns {any} the unwrapped expression */ function unwrap(ts, node) { let current = node while ( current && (ts.isAsExpression?.(current) || ts.isSatisfiesExpression?.(current) || ts.isParenthesizedExpression(current)) ) current = current.expression return current } /** * Resolves an identifier to the object literal of a module-level `const` in * the same file, so a component that keeps its defaults in a shared `const` * still yields attributes instead of silently emitting none: * * ```js * const props = { variant: 'primary' } * class Foo extends WebComponent { static props = props } * ``` * @param {any} ts the TypeScript module handed to the hook * @param {any} node any node in the source file * @param {string} name the identifier to resolve * @returns {any} the object literal, or undefined */ function resolveObjectLiteral(ts, node, name) { for (const statement of node.getSourceFile()?.statements ?? []) { if (!ts.isVariableStatement(statement)) continue for (const declaration of statement.declarationList.declarations) { if (declaration.name?.getText() !== name || !declaration.initializer) continue const initializer = unwrap(ts, declaration.initializer) if (ts.isObjectLiteralExpression(initializer)) return initializer } } return undefined } /** * Finds the object literal behind a class's `static props`, whether it is * written inline or hoisted into a module-level const. * @param {any} ts the TypeScript module handed to the hook * @param {any} node the class declaration node * @returns {any} the object literal, or undefined */ function findStaticProps(ts, node) { const declaration = node.members.find( (member) => ts.isPropertyDeclaration(member) && modifiersOf(ts, member).some( (mod) => mod.kind === ts.SyntaxKind.StaticKeyword ) && member.name?.getText() === 'props' && member.initializer ) if (!declaration) return undefined const initializer = unwrap(ts, declaration.initializer) if (ts.isObjectLiteralExpression(initializer)) return initializer if (ts.isIdentifier(initializer)) return resolveObjectLiteral(ts, node, initializer.getText()) return undefined } /** * True when the class extends something named `WebComponent`. Used so wcb * components that declare no props still get their internals stripped. * @param {any} ts the TypeScript module handed to the hook * @param {any} node the class declaration node * @returns {boolean} whether the class extends `WebComponent` */ function extendsWebComponent(ts, node) { return (node.heritageClauses ?? []).some( (clause) => clause.token === ts.SyntaxKind.ExtendsKeyword && clause.types.some((t) => t.expression.getText().endsWith('WebComponent')) ) } /** * Teaches the CEM analyzer to read wcb's `static props`: every key becomes a * public field plus a reflected attribute, named with wcb's own * `getKebabCase` so manifest attribute names match `observedAttributes` * exactly. wcb internals are stripped from the public surface. * @example * // custom-elements-manifest.config.mjs * import { wcbStaticProps } from 'web-component-base/cem-plugin' * export default { globs: ['src/**\/*.js'], plugins: [wcbStaticProps()] } * @returns {{name: string, analyzePhase: (ctx: any) => void}} a CEM analyzer plugin */ export function wcbStaticProps() { return { name: 'wcb-static-props', analyzePhase({ ts, node, moduleDoc }) { if (!ts.isClassDeclaration(node) || !node.name) return const className = node.name.getText() const classDoc = (moduleDoc.declarations ?? []).find( (declaration) => declaration.kind === 'class' && declaration.name === className ) if (!classDoc) return const props = findStaticProps(ts, node) if (!props && !extendsWebComponent(ts, node)) return classDoc.members = (classDoc.members ?? []).filter( (member) => !WCB_INTERNAL.has(member.name) ) classDoc.attributes = classDoc.attributes ?? [] if (!props) return for (const prop of props.properties) { // shorthand (`{ variant }`) and spreads carry no inspectable default if (!ts.isPropertyAssignment(prop)) continue const fieldName = prop.name.getText().replace(/['"]/g, '') const attribute = getKebabCase(fieldName) const type = { text: typeOfDefault(ts, prop.initializer) } const defaultValue = prop.initializer.getText() classDoc.members.push({ kind: 'field', name: fieldName, privacy: 'public', type, default: defaultValue, attribute, description: `Reactive prop, reflected to the \`${attribute}\` attribute.`, }) classDoc.attributes.push({ name: attribute, fieldName, type, default: defaultValue, }) } }, } } export default wcbStaticProps