wcb/src/cem-plugin.js

207 lines
6.8 KiB
JavaScript

/**
* @license MIT <https://opensource.org/licenses/MIT>
* @author Ayo Ayco <https://ayo.ayco.io>
*
* 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