diff --git a/packages/craftcms-cp/custom-elements-manifest.config.mjs b/packages/craftcms-cp/custom-elements-manifest.config.mjs index 69988926c76..e9788f785b3 100644 --- a/packages/craftcms-cp/custom-elements-manifest.config.mjs +++ b/packages/craftcms-cp/custom-elements-manifest.config.mjs @@ -4,6 +4,62 @@ export default { exclude: ['**/*.stories.ts', '**/*.styles.ts', '**/*.test.ts'], outdir: 'dist', plugins: [ + // Capture custom JSDoc tags used to drive PHP component generation. + // Unknown tags are dropped by the analyzer otherwise, so we read them off + // the AST here and attach them to the manifest: + // @phpComponent (class) opt this element into PHP generation + // @phpType {Type} (property) override the generated PHP type + { + name: 'php-codegen-tags', + analyzePhase({ts, node, moduleDoc}) { + if (!ts.isClassDeclaration(node) || !node.name) { + return; + } + + const decl = moduleDoc.declarations?.find( + (d) => d.name === node.name.getText() + ); + if (!decl) { + return; + } + + const findTag = (n, name) => + (ts.getJSDocTags(n) || []).find( + (t) => t.tagName?.getText() === name + ); + + if (findTag(node, 'phpComponent')) { + decl.phpComponent = true; + } + + for (const member of node.members || []) { + const tag = findTag(member, 'phpType'); + if (!tag) { + continue; + } + + const phpType = String(tag.comment ?? '') + .trim() + .replace(/^\{|\}$/g, '') + .trim(); + if (!phpType) { + continue; + } + + const fieldName = member.name?.getText(); + const field = decl.members?.find( + (m) => m.kind === 'field' && m.name === fieldName + ); + if (field) { + field.phpType = phpType; + } + const attr = decl.attributes?.find((a) => a.fieldName === fieldName); + if (attr) { + attr.phpType = phpType; + } + } + }, + }, // Add a plugin to prevent inheritance tree analysis errors { name: 'skip-external-inheritance', diff --git a/packages/craftcms-cp/package.json b/packages/craftcms-cp/package.json index 0b2e49d24aa..ef5e3d654ed 100644 --- a/packages/craftcms-cp/package.json +++ b/packages/craftcms-cp/package.json @@ -29,7 +29,8 @@ "build:storybook": "storybook build", "build:manifest": "custom-elements-manifest analyze --litelement --outdir dist", "generate:vue-wrappers": "node ./scripts/generate-vue-wrappers.js", - "generate:colors": "node ./scripts/generate-colors.js && node scripts/generate-color-palette.js" + "generate:colors": "node ./scripts/generate-colors.js && node scripts/generate-color-palette.js", + "generate:php": "npm run build:manifest && node ./scripts/generate-php-components.js" }, "exports": { "./package.json": "./package.json", diff --git a/packages/craftcms-cp/scripts/generate-php-components.js b/packages/craftcms-cp/scripts/generate-php-components.js new file mode 100644 index 00000000000..91bee1dae60 --- /dev/null +++ b/packages/craftcms-cp/scripts/generate-php-components.js @@ -0,0 +1,300 @@ +/** + * Proof of concept: generate PHP component base classes from the Custom + * Elements Manifest. + * + * For every custom element tagged `@phpComponent`, this emits an abstract PHP + * base class mirroring its attributes (fluent setters + `toHtml()`), into: + * src/Cp/Components/Generated/Component.php + * + * The generated base is meant to be extended by a hand-written concrete class + * (e.g. `Indicator extends IndicatorComponent`) that adds behavior. Regenerating + * never touches the hand-written subclass. + * + * Usage (from packages/craftcms-cp/): + * npm run build:manifest && node scripts/generate-php-components.js + */ + +import {mkdirSync, readFileSync, writeFileSync} from 'fs'; +import {dirname, resolve} from 'path'; +import {fileURLToPath} from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..'); +const MANIFEST = resolve(ROOT, 'dist/custom-elements.json'); +const OUT_DIR = resolve(ROOT, '../../src/Cp/Components/Generated'); +const PHP_NAMESPACE = 'CraftCms\\Cms\\Cp\\Components\\Generated'; + +// Short PHP type name -> fully-qualified class to `use`. Drives both imports +// and `->value` resolution for backed enums. A `@phpType {Color|string}` tag in +// the web component routes through here. +const ENUM_IMPORTS = { + Color: 'CraftCms\\Cms\\Shared\\Enums\\Color', +}; + +// TS type aliases that resolve to a plain PHP type. e.g. `VariantKey` is a +// string union, so it maps to `string`. A `@phpType` tag can always override. +const TYPE_ALIASES = { + VariantKey: 'string', +}; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function pascalFromTag(tagName) { + return tagName + .split('-') + .filter((part, i) => !(i === 0 && (part === 'craft' || part === 'c'))) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''); +} + +const isStringLiteralUnion = (text) => + /^'[^']*'(\s*\|\s*'[^']*')*$/.test(text.trim()); + +/** + * Maps a manifest attribute to PHP type info. + * Returns {type, paramDoc, enum} where `enum` is the backed-enum short name (or null). + */ +function mapType(attr) { + // An explicit @phpType override wins. + if (attr.phpType) { + const tokens = attr.phpType.split('|').map((t) => t.trim()); + const enumName = tokens.find((t) => ENUM_IMPORTS[t]) ?? null; + return {type: attr.phpType, paramDoc: null, enum: enumName}; + } + + const text = (attr.type?.text ?? 'string').trim(); + const nullable = /(^|\|)\s*null\s*($|\|)/.test(text); + const base = text + .split('|') + .map((t) => t.trim()) + .filter((t) => t !== 'null') + .join(' | '); + + let type; + let paramDoc = null; + + if (TYPE_ALIASES[base]) { + type = TYPE_ALIASES[base]; + } else if (isStringLiteralUnion(base)) { + type = 'string'; + paramDoc = base; // e.g. 'md' | 'lg' + } else if (base === 'string') { + type = 'string'; + } else if (base === 'number') { + type = 'int|float'; + } else if (base === 'boolean') { + type = 'bool'; + } else { + type = 'mixed'; + } + + if (nullable && type !== 'mixed') { + type = type.includes('|') ? `${type}|null` : `?${type}`; + } + + return {type, paramDoc, enum: null}; +} + +// Widens a PHP type to also accept null (used when an attribute has no default, +// so the generated property can be initialized to null rather than left unset). +function makeNullable(type) { + if (type === 'mixed' || type.startsWith('?') || /(^|\|)null(\||$)/.test(type)) { + return type; + } + + return type.includes('|') ? `${type}|null` : `?${type}`; +} + +const indent = (lines, pad = ' ') => + lines.map((l) => (l ? pad + l : l)).join('\n'); + +// ─── Code generation ──────────────────────────────────────────────────────── + +function generateSetter(attr, field, mapped) { + const doc = mapped.paramDoc + ? ` /**\n * @param ${mapped.paramDoc} $${field}\n */\n` + : ''; + + return ( + doc + + ` public function ${field}(${mapped.type} $${field}): static\n` + + ` {\n` + + ` $this->${field} = $${field};\n\n` + + ` return $this;\n` + + ` }` + ); +} + +function generateRenderLine(attr, field, mapped, effectiveDefault) { + const resolved = mapped.enum + ? `$this->${field} instanceof ${mapped.enum} ? $this->${field}->value : $this->${field}` + : `$this->${field}`; + + // Omit the attribute when it still holds its default, keeping the markup + // clean (the web component applies the same defaults itself). `Html::tag` + // renders bool `true` as a bare attribute and omits `false`/`null`. + if (effectiveDefault === 'null') { + return `'${attr.name}' => ${resolved},`; + } + + return `'${attr.name}' => $this->${field} === ${effectiveDefault} ? null : ${mapped.enum ? `(${resolved})` : resolved},`; +} + +function camelCase(name) { + return name + .split(/[-_]/) + .map((part, i) => + i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1) + ) + .join(''); +} + +// Method names already provided by the ViewComponent base. +const RESERVED_METHODS = [ + 'make', + 'attributes', + 'slot', + 'toHtml', + '__toString', + '__construct', +]; + +function generateSlotSetter(methodName, slotKey) { + const key = slotKey === '' ? "''" : `'${slotKey}'`; + + return ( + ` public function ${methodName}(Closure|Htmlable|Stringable|string $content): static\n` + + ` {\n` + + ` $this->slots[${key}] = $content;\n\n` + + ` return $this;\n` + + ` }` + ); +} + +function generateClass(decl) { + const tagName = decl.tagName; + const baseName = `${pascalFromTag(tagName)}Component`; + const attributes = decl.attributes ?? []; + + const enums = new Set(); + const fields = []; + const setters = []; + const renderLines = []; + const usedNames = new Set(RESERVED_METHODS); + + for (const attr of attributes) { + const field = attr.fieldName ?? attr.name; + const mapped = mapType(attr); + if (mapped.enum) { + enums.add(mapped.enum); + } + usedNames.add(field); + + // CEM only captures literal defaults; non-literal/absent ones come through + // as `undefined`. In that case make the property nullable and default null + // so it's always initialized (and omitted from the rendered markup). + const hasDefault = + attr.default !== undefined && attr.default !== 'undefined'; + const type = hasDefault ? mapped.type : makeNullable(mapped.type); + const effectiveDefault = hasDefault ? attr.default : 'null'; + + fields.push(` protected ${type} $${field} = ${effectiveDefault};`); + setters.push(generateSetter(attr, field, {...mapped, type})); + renderLines.push(generateRenderLine(attr, field, mapped, effectiveDefault)); + } + + // Each manifest slot becomes a fluent callback setter. The default (unnamed) + // slot maps to `content()`; names that would clash with an attribute setter + // (or a base method) get a `Slot` suffix. + const slotSetters = []; + for (const slot of decl.slots ?? []) { + const slotKey = slot.name ?? ''; + let methodName = slotKey === '' ? 'content' : camelCase(slotKey); + while (usedNames.has(methodName)) { + methodName += 'Slot'; + } + usedNames.add(methodName); + slotSetters.push(generateSlotSetter(methodName, slotKey)); + } + + const hasSlots = slotSetters.length > 0; + + const importClasses = ['CraftCms\\Cms\\Cp\\Components\\ViewComponent']; + for (const e of enums) { + importClasses.push(ENUM_IMPORTS[e]); + } + if (hasSlots) { + importClasses.push( + 'Closure', + 'Illuminate\\Contracts\\Support\\Htmlable', + 'Stringable' + ); + } + importClasses.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + const imports = importClasses.map((c) => `use ${c};`).join('\n'); + + const summary = (decl.summary ?? decl.description ?? '') + .split('\n') + .map((l) => ` * ${l}`.trimEnd()) + .join('\n'); + + const members = [ + fields.join('\n\n'), + setters.join('\n\n'), + slotSetters.join('\n\n'), + ` protected function tagName(): string\n {\n return '${tagName}';\n }`, + ` protected function hostAttributes(): array\n {\n return [\n${indent(renderLines, ' ')}\n ];\n }`, + ] + .filter(Boolean) + .join('\n\n'); + + return { + baseName, + code: ` mod.declarations ?? []) + .filter((decl) => decl.phpComponent === true); + + if (declarations.length === 0) { + console.log('No @phpComponent custom elements found in the manifest.'); + return; + } + + mkdirSync(OUT_DIR, {recursive: true}); + + for (const decl of declarations) { + const {baseName, code} = generateClass(decl); + const filePath = resolve(OUT_DIR, `${baseName}.php`); + writeFileSync(filePath, code); + console.log(` Generated: src/Cp/Components/Generated/${baseName}.php`); + } + + console.log(`\n ${declarations.length} PHP component base class(es) generated.`); +} + +main(); diff --git a/packages/craftcms-cp/src/components/action-item/action-item.ts b/packages/craftcms-cp/src/components/action-item/action-item.ts index f381c386510..5bdce361667 100644 --- a/packages/craftcms-cp/src/components/action-item/action-item.ts +++ b/packages/craftcms-cp/src/components/action-item/action-item.ts @@ -19,25 +19,67 @@ import { } from '@src/actions'; /** - * @summary Either a link or button typically used in a menu. + * @summary A menu entry that renders as a link or a button and can run an + * action, showing inline loading/success/error feedback. + * @since 1.0 + * + * @dependency craft-icon + * @dependency craft-spinner + * @dependency craft-shortcut + * + * @slot - The item's label. + * @slot icon - Icon displayed in the prefix (falls back to the `icon` attribute). + * @slot checkmark - Custom checkmark content, shown when `type="checkbox"`. + * @slot suffix - Content displayed after the label. + * + * @event action:change-state - Emitted when the item's async action changes state (idle, loading, success, or error). + * + * @phpComponent */ export default class CraftActionItem extends LitElement { static override styles = [variantsStyles, styles]; + + /** Icon name shown in the prefix when no `icon` slot is provided. */ @property() icon: string | null = null; + + /** When set, the item renders as a link (``) instead of a `