@nysds/codegen 0.0.0-stage → 1.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +95 -2
  2. package/cem-plugins/angular.mjs +551 -0
  3. package/cem-plugins/deps.mjs +154 -0
  4. package/cem-plugins/form-control.mjs +94 -0
  5. package/cem-plugins/index.mjs +12 -0
  6. package/cem-plugins/lib/core.d.mts +79 -0
  7. package/cem-plugins/lib/core.mjs +288 -0
  8. package/cem-plugins/react.mjs +143 -0
  9. package/cem-plugins/vue.mjs +228 -0
  10. package/dist/index.d.ts +17 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +14 -0
  13. package/dist/manifest/components.d.ts +6 -0
  14. package/dist/manifest/components.d.ts.map +1 -0
  15. package/dist/manifest/components.js +5 -0
  16. package/dist/manifest/filters.d.ts +6 -0
  17. package/dist/manifest/filters.d.ts.map +1 -0
  18. package/dist/manifest/filters.js +5 -0
  19. package/dist/manifest/form-control.d.ts +6 -0
  20. package/dist/manifest/form-control.d.ts.map +1 -0
  21. package/dist/manifest/form-control.js +5 -0
  22. package/dist/manifest/load.d.ts +11 -0
  23. package/dist/manifest/load.d.ts.map +1 -0
  24. package/dist/manifest/load.js +64 -0
  25. package/dist/manifest/naming.d.ts +7 -0
  26. package/dist/manifest/naming.d.ts.map +1 -0
  27. package/dist/manifest/naming.js +6 -0
  28. package/dist/manifest/packages.d.ts +6 -0
  29. package/dist/manifest/packages.d.ts.map +1 -0
  30. package/dist/manifest/packages.js +5 -0
  31. package/dist/manifest/types.d.ts +7 -0
  32. package/dist/manifest/types.d.ts.map +1 -0
  33. package/dist/manifest/types.js +1 -0
  34. package/dist/transform/index.d.ts +20 -0
  35. package/dist/transform/index.d.ts.map +1 -0
  36. package/dist/transform/index.js +11 -0
  37. package/dist/transform/parse.d.ts +19 -0
  38. package/dist/transform/parse.d.ts.map +1 -0
  39. package/dist/transform/parse.js +33 -0
  40. package/dist/transform/shared.d.ts +62 -0
  41. package/dist/transform/shared.d.ts.map +1 -0
  42. package/dist/transform/shared.js +96 -0
  43. package/dist/transform/to-angular.d.ts +7 -0
  44. package/dist/transform/to-angular.d.ts.map +1 -0
  45. package/dist/transform/to-angular.js +153 -0
  46. package/dist/transform/to-jsx.d.ts +7 -0
  47. package/dist/transform/to-jsx.d.ts.map +1 -0
  48. package/dist/transform/to-jsx.js +172 -0
  49. package/package.json +46 -4
@@ -0,0 +1,154 @@
1
+ /**
2
+ * CEM plugin: rewrites the generated blocks in the framework package.json
3
+ * files so they stay in lockstep with the monorepo (decisions L3, L4).
4
+ *
5
+ * For each target it rewrites:
6
+ * - `dependencies`: one exact-version entry per `@nysds/nys-*` component
7
+ * package that has a wrapper; other dependencies are kept as written.
8
+ * The exact versions are what guarantee wrapper/component alignment —
9
+ * no `@nysds/components` peer is written (nothing imports the root
10
+ * package, and the peer made npm pull the published bundle into
11
+ * consumer apps and into this monorepo, where its stale types shadowed
12
+ * workspace source).
13
+ * - `exports`: the barrel entry, one subpath per component, and
14
+ * `./package.json`. Hand-added subpaths outside that set are dropped, so
15
+ * put extra entry points behind the barrel instead.
16
+ *
17
+ * A missing target package.json logs a warning and is skipped — the react
18
+ * and angular packages are scaffolded in later workstreams.
19
+ */
20
+ import fs from "node:fs";
21
+ import { listComponents } from "./lib/core.mjs";
22
+
23
+ function reactSubpathEntry(component) {
24
+ return {
25
+ types: `./dist/generated/${component.className}.d.ts`,
26
+ import: `./dist/generated/${component.className}.js`,
27
+ };
28
+ }
29
+
30
+ function angularSubpathEntry(component) {
31
+ // One ng-packagr secondary entry point per component, generated into the
32
+ // staging tree by the angular wrapper plugin.
33
+ return angularEntry(component.subpath);
34
+ }
35
+
36
+ /** An `exports` entry for one ng-packagr entry point, by its subpath. */
37
+ function angularEntry(subpath) {
38
+ return {
39
+ types: `./dist/${subpath}/index.d.ts`,
40
+ default: `./dist/fesm2022/nysds-angular-${subpath}.mjs`,
41
+ };
42
+ }
43
+
44
+ function vueSubpathEntry(component) {
45
+ return {
46
+ types: `./dist/generated/${component.className}.d.ts`,
47
+ import: `./dist/generated/${component.className}.js`,
48
+ };
49
+ }
50
+
51
+ const SUBPATH_ENTRY_BY_FRAMEWORK = {
52
+ react: reactSubpathEntry,
53
+ vue: vueSubpathEntry,
54
+ angular: angularSubpathEntry,
55
+ };
56
+
57
+ function sortObject(obj) {
58
+ return Object.fromEntries(
59
+ Object.entries(obj).sort(([a], [b]) => a.localeCompare(b))
60
+ );
61
+ }
62
+
63
+ export function depsPlugin(options = {}) {
64
+ const {
65
+ rootPackageJson = "package.json",
66
+ targets = [
67
+ { path: "packages/react/package.json", framework: "react" },
68
+ { path: "packages/angular/package.json", framework: "angular" },
69
+ { path: "packages/vue/package.json", framework: "vue" },
70
+ ],
71
+ } = options;
72
+
73
+ return {
74
+ name: "nysds-framework-deps",
75
+
76
+ packageLinkPhase({ customElementsManifest }) {
77
+ const components = listComponents(customElementsManifest);
78
+ if (components.length === 0) return;
79
+
80
+ const rootPkg = JSON.parse(fs.readFileSync(rootPackageJson, "utf8"));
81
+ const version = rootPkg.version;
82
+
83
+ const componentPackages = [
84
+ ...new Set(components.map((component) => component.packageName)),
85
+ ].sort();
86
+
87
+ for (const target of targets) {
88
+ if (!fs.existsSync(target.path)) {
89
+ console.warn(
90
+ `[nysds-framework-deps] ${target.path} not found — skipped (scaffolded in a later workstream).`
91
+ );
92
+ continue;
93
+ }
94
+ const subpathEntry = SUBPATH_ENTRY_BY_FRAMEWORK[target.framework];
95
+ if (!subpathEntry) {
96
+ throw new Error(
97
+ `[nysds-framework-deps] Unknown framework "${target.framework}" for ${target.path}`
98
+ );
99
+ }
100
+
101
+ const pkg = JSON.parse(fs.readFileSync(target.path, "utf8"));
102
+
103
+ const dependencies = {};
104
+ for (const [name, range] of Object.entries(pkg.dependencies ?? {})) {
105
+ if (!componentPackages.includes(name) && !name.startsWith("@nysds/nys-")) {
106
+ dependencies[name] = range;
107
+ }
108
+ }
109
+ for (const packageName of componentPackages) {
110
+ dependencies[packageName] = version;
111
+ }
112
+ pkg.dependencies = sortObject(dependencies);
113
+
114
+ // Remove the peer this plugin used to write, so regeneration heals
115
+ // package.json files from before the peer was dropped.
116
+ if (pkg.peerDependencies) {
117
+ delete pkg.peerDependencies["@nysds/components"];
118
+ }
119
+
120
+ // Subpath exports for per-component bundling. These are generated for
121
+ // both frameworks: hand-maintaining the Angular half is what let it
122
+ // drift into advertising a subpath for every component while the build
123
+ // emitted a single bundle.
124
+ const exportsMap = {};
125
+ if (target.framework === "angular") {
126
+ exportsMap["."] = {
127
+ types: "./dist/index.d.ts",
128
+ default: "./dist/fesm2022/nysds-angular.mjs",
129
+ };
130
+ // The shared form-control accessors are their own entry point so no
131
+ // source file is compiled into more than one bundle.
132
+ exportsMap["./forms"] = angularEntry("forms");
133
+ } else {
134
+ exportsMap["."] = pkg.exports?.["."] ?? {
135
+ types: "./dist/index.d.ts",
136
+ import: "./dist/index.js",
137
+ };
138
+ }
139
+ for (const component of components) {
140
+ exportsMap[`./${component.subpath}`] = subpathEntry(component);
141
+ }
142
+ if (target.framework === "angular") {
143
+ // Several tools resolve `<pkg>/package.json` directly. Safe to add
144
+ // here: ng-packagr generates its own map for dist/ and never reads
145
+ // this one.
146
+ exportsMap["./package.json"] = "./package.json";
147
+ }
148
+ pkg.exports = exportsMap;
149
+
150
+ fs.writeFileSync(target.path, JSON.stringify(pkg, null, 2) + "\n");
151
+ }
152
+ },
153
+ };
154
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * CEM plugin: reads the class-level `@formControl` JSDoc tag and attaches
3
+ * `declaration.formControl = { kind, changeEvent, inputEvent? }` to the
4
+ * manifest.
5
+ *
6
+ * A malformed tag fails the analyze run with the file path. A form component
7
+ * (any class extending the form-control superclass) without the tag throws
8
+ * in strict mode and logs one warning otherwise. The allowlist names classes
9
+ * that extend the superclass but are not bindable form controls.
10
+ */
11
+ import { parseFormControlTag } from "./lib/core.mjs";
12
+
13
+ export function formControlPlugin(options = {}) {
14
+ const {
15
+ strict = true,
16
+ allowlist = ["NysButton", "NysRadiobutton", "NysOption"],
17
+ superclassName = "NysFormControlElement",
18
+ } = options;
19
+ const allowed = new Set(allowlist);
20
+
21
+ /** Map<modulePath, Map<className, FormControlMeta>> */
22
+ const collected = new Map();
23
+
24
+ return {
25
+ name: "nysds-form-control",
26
+
27
+ analyzePhase({ ts, node, moduleDoc }) {
28
+ if (!ts.isClassDeclaration(node) || !("jsDoc" in node)) return;
29
+ const className = node.name?.getText();
30
+ if (!className) return;
31
+
32
+ for (const jsdoc of node.jsDoc ?? []) {
33
+ if (!Array.isArray(jsdoc.tags)) continue;
34
+ for (const tag of jsdoc.tags) {
35
+ let tagName;
36
+ try {
37
+ tagName = tag.tagName.getText();
38
+ } catch {
39
+ continue;
40
+ }
41
+ if (tagName !== "formControl") continue;
42
+
43
+ let parsed;
44
+ try {
45
+ parsed = parseFormControlTag(
46
+ typeof tag.comment === "string" ? tag.comment : ""
47
+ );
48
+ } catch (error) {
49
+ throw new Error(
50
+ `[nysds-form-control] ${moduleDoc.path}: ${error.message}`
51
+ );
52
+ }
53
+
54
+ if (!collected.has(moduleDoc.path)) {
55
+ collected.set(moduleDoc.path, new Map());
56
+ }
57
+ collected.get(moduleDoc.path).set(className, parsed);
58
+ }
59
+ }
60
+ },
61
+
62
+ packageLinkPhase({ customElementsManifest }) {
63
+ // Attach collected tags.
64
+ for (const mod of customElementsManifest.modules ?? []) {
65
+ const byClass = collected.get(mod.path);
66
+ if (!byClass) continue;
67
+ for (const decl of mod.declarations ?? []) {
68
+ const formControl = byClass.get(decl.name);
69
+ if (formControl) decl.formControl = formControl;
70
+ }
71
+ }
72
+
73
+ // Verify every form component carries the tag.
74
+ const missing = [];
75
+ for (const mod of customElementsManifest.modules ?? []) {
76
+ for (const decl of mod.declarations ?? []) {
77
+ if (decl.kind !== "class") continue;
78
+ if (decl.superclass?.name !== superclassName) continue;
79
+ if (allowed.has(decl.name)) continue;
80
+ if (!decl.formControl) missing.push(`${decl.name} (${mod.path})`);
81
+ }
82
+ }
83
+
84
+ if (missing.length > 0) {
85
+ const message =
86
+ `[nysds-form-control] ${missing.length} form component(s) extend ` +
87
+ `${superclassName} but have no @formControl JSDoc tag:\n - ` +
88
+ missing.join("\n - ");
89
+ if (strict) throw new Error(message);
90
+ console.warn(`${message}\n (strict: false — continuing)`);
91
+ }
92
+ },
93
+ };
94
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * CEM plugin entry point. Imported by
3
+ * `src/scripts/custom-elements-manifest.config.mjs` as
4
+ * `@nysds/codegen/cem-plugins`. Register `formControlPlugin()` before
5
+ * `reactPlugin()`, `angularPlugin()` and `vuePlugin()` so the wrappers see the
6
+ * `formControl` metadata.
7
+ */
8
+ export { formControlPlugin } from "./form-control.mjs";
9
+ export { reactPlugin } from "./react.mjs";
10
+ export { angularPlugin } from "./angular.mjs";
11
+ export { vuePlugin } from "./vue.mjs";
12
+ export { depsPlugin } from "./deps.mjs";
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Types for `core.mjs`. Hand-written; keep in sync with the implementation.
3
+ * The TypeScript public API re-exports these, so this file is the single
4
+ * place the metadata types are declared.
5
+ */
6
+
7
+ export declare const NYS_PREFIX: string;
8
+ export declare const NYS_BLUR_EVENT: string;
9
+
10
+ export interface FormControlMeta {
11
+ kind: "value" | "checked" | "files";
12
+ changeEvent: string;
13
+ inputEvent?: string;
14
+ }
15
+
16
+ export interface PropMeta {
17
+ name: string;
18
+ attribute?: string;
19
+ type: string;
20
+ default?: string;
21
+ description?: string;
22
+ isBoolean: boolean;
23
+ isNumber: boolean;
24
+ isPrimitive: boolean;
25
+ }
26
+
27
+ export interface EventMeta {
28
+ name: string;
29
+ typeText: string;
30
+ reactProp: string;
31
+ angularOutput: string;
32
+ description?: string;
33
+ }
34
+
35
+ export interface SlotMeta {
36
+ name: string;
37
+ description?: string;
38
+ }
39
+
40
+ export interface ComponentMeta {
41
+ tag: string;
42
+ className: string;
43
+ packageName: string;
44
+ subpath: string;
45
+ modulePath: string;
46
+ description?: string;
47
+ props: PropMeta[];
48
+ events: EventMeta[];
49
+ slots: SlotMeta[];
50
+ formControl?: FormControlMeta;
51
+ }
52
+
53
+ export interface ManifestModule {
54
+ path: string;
55
+ declarations?: unknown[];
56
+ [key: string]: unknown;
57
+ }
58
+
59
+ export interface Manifest {
60
+ modules: ManifestModule[];
61
+ [key: string]: unknown;
62
+ }
63
+
64
+ export declare function pascalize(name: string): string;
65
+ export declare function tagToClass(tag: string, manifest?: Manifest): string;
66
+ export declare function classToAngularClass(className: string): string;
67
+ export declare function eventToReactProp(eventName: string): string;
68
+ export declare function eventToAngularOutput(eventName: string): string;
69
+ export declare function tagToSubpath(tag: string): string;
70
+ export declare function tagToPackage(modulePath: string): string;
71
+
72
+ export declare function isPublicMember(member: unknown): boolean;
73
+ export declare function isPublicProp(member: unknown): boolean;
74
+ export declare function isExposedEvent(event: unknown): boolean;
75
+
76
+ export declare function parseFormControlTag(comment: string): FormControlMeta;
77
+
78
+ export declare function normalizeTypeText(text: unknown): string | undefined;
79
+ export declare function listComponents(manifest: Manifest): ComponentMeta[];
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Shared rules for NYSDS code generation.
3
+ *
4
+ * This module is the single source of truth for naming rules, member and
5
+ * event filters, `@formControl` parsing, package derivation, and component
6
+ * metadata. It is plain JavaScript so the CEM plugins can run before
7
+ * `@nysds/codegen` is built. The TypeScript sources in `src/manifest/`
8
+ * re-export from this file and add no logic of their own; `core.d.mts`
9
+ * carries the types.
10
+ */
11
+
12
+ /** Prefix shared by every NYSDS custom element tag. */
13
+ export const NYS_PREFIX = "nys-";
14
+
15
+ /** Event that marks a form control as touched in framework wrappers. */
16
+ export const NYS_BLUR_EVENT = `${NYS_PREFIX}blur`;
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Naming rules (README.md section 4.1 is normative)
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Mechanical PascalCase of a tag. Inner capitals in a segment are kept.
24
+ * Use `tagToClass` with a manifest when the real class name matters:
25
+ * class names in the manifest can capitalize differently.
26
+ */
27
+ export function pascalize(name) {
28
+ return name
29
+ .split("-")
30
+ .map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part))
31
+ .join("");
32
+ }
33
+
34
+ /**
35
+ * Tag to class name. With a manifest, returns the manifest
36
+ * `declaration.name`, which is authoritative. Without one, falls back to
37
+ * mechanical PascalCase.
38
+ */
39
+ export function tagToClass(tag, manifest) {
40
+ if (manifest) {
41
+ for (const component of listComponents(manifest)) {
42
+ if (component.tag === tag) return component.className;
43
+ }
44
+ }
45
+ return pascalize(tag);
46
+ }
47
+
48
+ /** Class name to Angular component class name. */
49
+ export function classToAngularClass(className) {
50
+ return `${className}Component`;
51
+ }
52
+
53
+ /** Event name to React prop: `on` + PascalCase of the full event name. */
54
+ export function eventToReactProp(eventName) {
55
+ return `on${pascalize(eventName)}`;
56
+ }
57
+
58
+ /** Event name to Angular output: camelCase of the full event name. */
59
+ export function eventToAngularOutput(eventName) {
60
+ const parts = eventName.split("-");
61
+ return (
62
+ parts[0] +
63
+ parts
64
+ .slice(1)
65
+ .map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part))
66
+ .join("")
67
+ );
68
+ }
69
+
70
+ /** Tag to import subpath: the tag minus the prefix. */
71
+ export function tagToSubpath(tag) {
72
+ return tag.startsWith(NYS_PREFIX) ? tag.slice(NYS_PREFIX.length) : tag;
73
+ }
74
+
75
+ /**
76
+ * Manifest module path to package name. Derived, never a table:
77
+ * `packages/nys-accordion/src/nys-accordionitem.ts` -> `@nysds/nys-accordion`.
78
+ */
79
+ export function tagToPackage(modulePath) {
80
+ const match = /(?:^|\/)packages\/([^/]+)\//.exec(modulePath);
81
+ if (!match) {
82
+ throw new Error(
83
+ `Cannot derive a package name from module path "${modulePath}": expected a packages/<name>/ segment.`
84
+ );
85
+ }
86
+ return `@nysds/${match[1]}`;
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Filters
91
+ // ---------------------------------------------------------------------------
92
+
93
+ /** Lit and custom-element machinery that must never surface as a prop. */
94
+ const LIT_INTERNAL_MEMBERS = new Set([
95
+ "updateComplete",
96
+ "isUpdatePending",
97
+ "hasUpdated",
98
+ "renderRoot",
99
+ "renderOptions",
100
+ "shadowRootOptions",
101
+ "elementProperties",
102
+ "elementStyles",
103
+ "styles",
104
+ "properties",
105
+ "observedAttributes",
106
+ "formAssociated",
107
+ ]);
108
+
109
+ /**
110
+ * True when a manifest member is part of the public API: not static, not
111
+ * underscore- or hash-prefixed, not `privacy: private | protected`, not
112
+ * deprecated, and not Lit machinery.
113
+ */
114
+ export function isPublicMember(member) {
115
+ if (!member || typeof member.name !== "string" || member.name.length === 0) {
116
+ return false;
117
+ }
118
+ if (member.static) return false;
119
+ if (member.name.startsWith("_") || member.name.startsWith("#")) return false;
120
+ if (member.privacy === "private" || member.privacy === "protected") {
121
+ return false;
122
+ }
123
+ if (member.deprecated) return false;
124
+ if (LIT_INTERNAL_MEMBERS.has(member.name)) return false;
125
+ return true;
126
+ }
127
+
128
+ /** True for a public, non-deprecated field. */
129
+ export function isPublicProp(member) {
130
+ return member?.kind === "field" && isPublicMember(member);
131
+ }
132
+
133
+ /** True when an event should surface in wrappers and snippets. */
134
+ export function isExposedEvent(event) {
135
+ if (!event || typeof event.name !== "string" || event.name.length === 0) {
136
+ return false;
137
+ }
138
+ if (event.deprecated) return false;
139
+ return true;
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // @formControl parsing
144
+ // ---------------------------------------------------------------------------
145
+
146
+ const FORM_CONTROL_KINDS = new Set(["value", "checked", "files"]);
147
+ const EVENT_NAME_RE = new RegExp(`^${NYS_PREFIX}[a-z][a-zA-Z-]*$`);
148
+
149
+ /**
150
+ * Parses the `@formControl <kind> <changeEvent> [inputEvent]` JSDoc tag
151
+ * body. Throws on a malformed tag; the caller adds the file path.
152
+ */
153
+ export function parseFormControlTag(comment) {
154
+ const parts = String(comment ?? "")
155
+ .trim()
156
+ .split(/\s+/)
157
+ .filter(Boolean);
158
+ if (parts.length < 2 || parts.length > 3) {
159
+ throw new Error(
160
+ `@formControl expects "<kind> <changeEvent> [inputEvent]"; got "${String(comment ?? "").trim()}"`
161
+ );
162
+ }
163
+ const [kind, changeEvent, inputEvent] = parts;
164
+ if (!FORM_CONTROL_KINDS.has(kind)) {
165
+ throw new Error(
166
+ `@formControl kind must be "value", "checked", or "files"; got "${kind}"`
167
+ );
168
+ }
169
+ for (const eventName of [changeEvent, inputEvent]) {
170
+ if (eventName !== undefined && !EVENT_NAME_RE.test(eventName)) {
171
+ throw new Error(
172
+ `@formControl event "${eventName}" must be a ${NYS_PREFIX}* event name`
173
+ );
174
+ }
175
+ }
176
+ return inputEvent ? { kind, changeEvent, inputEvent } : { kind, changeEvent };
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Component metadata
181
+ // ---------------------------------------------------------------------------
182
+
183
+ /** Collapses whitespace in a manifest type text and trims a leading pipe. */
184
+ export function normalizeTypeText(text) {
185
+ if (typeof text !== "string") return undefined;
186
+ const collapsed = text.replace(/\s+/g, " ").replace(/^\|\s*/, "").trim();
187
+ return collapsed.length > 0 ? collapsed : undefined;
188
+ }
189
+
190
+ const PRIMITIVE_PARTS = new Set([
191
+ "string",
192
+ "number",
193
+ "boolean",
194
+ "null",
195
+ "undefined",
196
+ ]);
197
+
198
+ function isPrimitiveType(typeText) {
199
+ if (!typeText) return false;
200
+ return typeText
201
+ .split("|")
202
+ .map((part) => part.trim())
203
+ .filter(Boolean)
204
+ .every(
205
+ (part) =>
206
+ PRIMITIVE_PARTS.has(part) ||
207
+ /^".*"$/.test(part) ||
208
+ /^'.*'$/.test(part) ||
209
+ /^-?\d+(\.\d+)?$/.test(part)
210
+ );
211
+ }
212
+
213
+ function isNumberType(typeText) {
214
+ if (!typeText) return false;
215
+ const parts = typeText
216
+ .split("|")
217
+ .map((part) => part.trim())
218
+ .filter(Boolean);
219
+ return (
220
+ parts.includes("number") &&
221
+ parts.every((part) => part === "number" || part === "null" || part === "undefined")
222
+ );
223
+ }
224
+
225
+ /**
226
+ * Reads every NYSDS component out of a custom elements manifest and returns
227
+ * uniform metadata: naming, public props, exposed events, slots, and the
228
+ * `formControl` block when the manifest carries one. Sorted by tag.
229
+ */
230
+ export function listComponents(manifest) {
231
+ const components = [];
232
+ for (const mod of manifest?.modules ?? []) {
233
+ for (const decl of mod.declarations ?? []) {
234
+ if (decl.kind !== "class") continue;
235
+ const tag = decl.tagName;
236
+ if (typeof tag !== "string" || !tag.startsWith(NYS_PREFIX)) continue;
237
+
238
+ const attributeByField = new Map();
239
+ for (const attr of decl.attributes ?? []) {
240
+ if (attr.fieldName && attr.name) {
241
+ attributeByField.set(attr.fieldName, attr.name);
242
+ }
243
+ }
244
+
245
+ const props = (decl.members ?? []).filter(isPublicProp).map((member) => {
246
+ const type = normalizeTypeText(member.type?.text);
247
+ return {
248
+ name: member.name,
249
+ attribute: member.attribute ?? attributeByField.get(member.name),
250
+ type: type ?? "unknown",
251
+ default: member.default,
252
+ description: member.description,
253
+ isBoolean: type === "boolean",
254
+ isNumber: isNumberType(type),
255
+ isPrimitive: isPrimitiveType(type),
256
+ };
257
+ });
258
+
259
+ const events = (decl.events ?? []).filter(isExposedEvent).map((event) => ({
260
+ name: event.name,
261
+ typeText: normalizeTypeText(event.type?.text) ?? "CustomEvent",
262
+ reactProp: eventToReactProp(event.name),
263
+ angularOutput: eventToAngularOutput(event.name),
264
+ description: event.description,
265
+ }));
266
+
267
+ const slots = (decl.slots ?? [])
268
+ .filter((slot) => typeof slot?.name === "string")
269
+ .map((slot) => ({ name: slot.name, description: slot.description }));
270
+
271
+ const component = {
272
+ tag,
273
+ className: decl.name,
274
+ packageName: tagToPackage(mod.path),
275
+ subpath: tagToSubpath(tag),
276
+ modulePath: mod.path,
277
+ description: decl.description,
278
+ props,
279
+ events,
280
+ slots,
281
+ };
282
+ if (decl.formControl) component.formControl = decl.formControl;
283
+ components.push(component);
284
+ }
285
+ }
286
+ components.sort((a, b) => a.tag.localeCompare(b.tag));
287
+ return components;
288
+ }