@astryxdesign/native 0.0.0-bootstrap.1 → 0.7.4-canary.720bb10

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 (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +119 -1
  3. package/apple/Package.swift +18 -0
  4. package/apple/Sources/AstryxNative/Generated/AstryxBase.swift +12 -0
  5. package/apple/Sources/AstryxNative/Generated/Selector.swift +159 -0
  6. package/apple/Tests/AstryxNativeTests/SelectorPropsTests.swift +17 -0
  7. package/dist/components/Selector.d.ts +10 -0
  8. package/dist/components/Selector.d.ts.map +1 -0
  9. package/dist/components/Selector.js +38 -0
  10. package/dist/components/index.d.ts +10 -0
  11. package/dist/components/index.d.ts.map +1 -0
  12. package/dist/components/index.js +3 -0
  13. package/dist/contract/fromComponentDoc.d.ts +32 -0
  14. package/dist/contract/fromComponentDoc.d.ts.map +1 -0
  15. package/dist/contract/fromComponentDoc.js +81 -0
  16. package/dist/contract/generateSwift.d.ts +4 -0
  17. package/dist/contract/generateSwift.d.ts.map +1 -0
  18. package/dist/contract/generateSwift.js +133 -0
  19. package/dist/contract/generateTypeScript.d.ts +4 -0
  20. package/dist/contract/generateTypeScript.d.ts.map +1 -0
  21. package/dist/contract/generateTypeScript.js +87 -0
  22. package/dist/contract/index.d.ts +20 -0
  23. package/dist/contract/index.d.ts.map +1 -0
  24. package/dist/contract/index.js +33 -0
  25. package/dist/contract/names.d.ts +20 -0
  26. package/dist/contract/names.d.ts.map +1 -0
  27. package/dist/contract/names.js +90 -0
  28. package/dist/contract/parseDocType.d.ts +22 -0
  29. package/dist/contract/parseDocType.d.ts.map +1 -0
  30. package/dist/contract/parseDocType.js +183 -0
  31. package/dist/contract/rules.d.ts +10 -0
  32. package/dist/contract/rules.d.ts.map +1 -0
  33. package/dist/contract/rules.js +87 -0
  34. package/dist/contract/types.d.ts +57 -0
  35. package/dist/contract/types.d.ts.map +1 -0
  36. package/dist/contract/types.js +2 -0
  37. package/dist/icons/defaults.d.ts +25 -0
  38. package/dist/icons/defaults.d.ts.map +1 -0
  39. package/dist/icons/defaults.js +111 -0
  40. package/dist/index.d.ts +15 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +12 -0
  43. package/dist/platforms.d.ts +13 -0
  44. package/dist/platforms.d.ts.map +1 -0
  45. package/dist/platforms.js +14 -0
  46. package/dist/theme/defineNativeTheme.d.ts +75 -0
  47. package/dist/theme/defineNativeTheme.d.ts.map +1 -0
  48. package/dist/theme/defineNativeTheme.js +126 -0
  49. package/dist/types/android.d.ts +8 -0
  50. package/dist/types/core.d.ts +35 -0
  51. package/dist/types/ios.d.ts +8 -0
  52. package/dist/types/macos.d.ts +8 -0
  53. package/dist/types/shared.d.ts +6 -0
  54. package/dist/types/windows.d.ts +6 -0
  55. package/package.json +72 -5
  56. package/src/components/Selector.ts +50 -0
  57. package/src/components/index.ts +14 -0
  58. package/src/contract/contracts.test.ts +150 -0
  59. package/src/contract/fromComponentDoc.ts +131 -0
  60. package/src/contract/generateSwift.ts +170 -0
  61. package/src/contract/generateTypeScript.ts +115 -0
  62. package/src/contract/index.ts +53 -0
  63. package/src/contract/names.ts +103 -0
  64. package/src/contract/parseDocType.test.ts +52 -0
  65. package/src/contract/parseDocType.ts +211 -0
  66. package/src/contract/rules.ts +132 -0
  67. package/src/contract/types.ts +52 -0
  68. package/src/icons/defaults.test.ts +47 -0
  69. package/src/icons/defaults.ts +136 -0
  70. package/src/index.ts +35 -0
  71. package/src/platforms.ts +20 -0
  72. package/src/theme/defineNativeTheme.test.ts +88 -0
  73. package/src/theme/defineNativeTheme.ts +224 -0
@@ -0,0 +1,115 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file contract/generateTypeScript.ts
5
+ * @input Component contracts
6
+ * @output Declaration files: core.d.ts, one per native platform, and shared.d.ts
7
+ * @position Produces the platform-scoped types (AST-082 FR5, FR6). The package
8
+ * exports map selects one file per type-resolution condition
9
+ */
10
+
11
+ import {NATIVE_PLATFORMS, type NativePlatform} from '../platforms.js';
12
+ import type {ComponentContract, PropMap, PropType} from './types.js';
13
+
14
+ const HEADER = '// Generated by @astryxdesign/native. Do not edit.\n';
15
+
16
+ function tsType(type: PropType): string {
17
+ switch (type.kind) {
18
+ case 'string':
19
+ case 'number':
20
+ case 'boolean':
21
+ return type.kind;
22
+ case 'enum':
23
+ return type.values.map(value => `'${value}'`).join(' | ');
24
+ case 'array': {
25
+ const inner = tsType(type.of);
26
+ return `ReadonlyArray<${inner}>`;
27
+ }
28
+ case 'record':
29
+ return `{${members(type.fields, ' ')}}`;
30
+ case 'fn':
31
+ return `(${type.args.map((arg, i) => `arg${i}: ${tsType(arg)}`).join(', ')}) => void`;
32
+ case 'named':
33
+ return type.name;
34
+ }
35
+ }
36
+
37
+ function key(name: string): string {
38
+ return /^[A-Za-z_$][\w$]*$/.test(name) ? name : `'${name}'`;
39
+ }
40
+
41
+ function members(props: PropMap, separator = '\n '): string {
42
+ return Object.entries(props)
43
+ .map(
44
+ ([name, spec]) =>
45
+ `${key(name)}${spec.optional ? '?' : ''}: ${tsType(spec.type)};`,
46
+ )
47
+ .join(separator);
48
+ }
49
+
50
+ function coreFile(contracts: readonly ComponentContract[]): string {
51
+ const types = new Map<string, PropMap>();
52
+ for (const contract of contracts) {
53
+ for (const [name, fields] of Object.entries(contract.types)) {
54
+ types.set(name, fields);
55
+ }
56
+ }
57
+ const typeDecls = [...types].map(
58
+ ([name, fields]) => `export interface ${name} {\n ${members(fields)}\n}`,
59
+ );
60
+ const props = contracts.map(
61
+ contract =>
62
+ `/** Core contract. Base props attach to the ${contract.baseTarget}. */\n` +
63
+ `export interface ${contract.name}CoreProps extends AstryxSharedBaseProps {\n ${members(contract.core)}\n}`,
64
+ );
65
+ return (
66
+ HEADER +
67
+ '/** Base props shared by every platform (AST-082 FR7). */\n' +
68
+ 'export interface AstryxSharedBaseProps {\n id?: string;\n isHidden?: boolean;\n}\n' +
69
+ [...typeDecls, ...props].join('\n') +
70
+ '\n'
71
+ );
72
+ }
73
+
74
+ function reExports(contracts: readonly ComponentContract[]): string {
75
+ const names = new Set<string>(['AstryxSharedBaseProps']);
76
+ for (const contract of contracts) {
77
+ Object.keys(contract.types).forEach(name => names.add(name));
78
+ names.add(`${contract.name}CoreProps`);
79
+ }
80
+ return `export type {${[...names].join(', ')}} from './core.js';\n`;
81
+ }
82
+
83
+ function platformFile(
84
+ contracts: readonly ComponentContract[],
85
+ platform: NativePlatform | null,
86
+ ): string {
87
+ const imports = `import type {${contracts.map(c => `${c.name}CoreProps`).join(', ')}} from './core.js';\n`;
88
+ const components = contracts.map(contract => {
89
+ const extra = platform ? (contract.platforms[platform] ?? {}) : {};
90
+ const body = Object.keys(extra).length > 0 ? `\n ${members(extra)}\n` : '';
91
+ const scope = platform
92
+ ? `core contract plus the ${platform} contract`
93
+ : 'core contract only';
94
+ return (
95
+ `/** ${contract.name} props: ${scope}. */\n` +
96
+ `export interface ${contract.name}Props extends ${contract.name}CoreProps {${body}}\n` +
97
+ `export declare function ${contract.name}(props: ${contract.name}Props): unknown;`
98
+ );
99
+ });
100
+ return HEADER + imports + reExports(contracts) + components.join('\n') + '\n';
101
+ }
102
+
103
+ /** Returns file name to contents, relative to the types output folder. */
104
+ export function generateTypeScript(
105
+ contracts: readonly ComponentContract[],
106
+ ): Record<string, string> {
107
+ const files: Record<string, string> = {
108
+ 'core.d.ts': coreFile(contracts),
109
+ 'shared.d.ts': platformFile(contracts, null),
110
+ };
111
+ for (const platform of NATIVE_PLATFORMS) {
112
+ files[`${platform}.d.ts`] = platformFile(contracts, platform);
113
+ }
114
+ return files;
115
+ }
@@ -0,0 +1,53 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file contract/index.ts
5
+ * @input Core component documentation, supplied by the caller
6
+ * @output Contracts for every native component, checked, plus the generators
7
+ * @position Build-time entry (`@astryxdesign/native/contract`). Reads core
8
+ * documentation as data; nothing here runs in an app
9
+ */
10
+
11
+ import {NATIVE_COMPONENTS} from '../components/index.js';
12
+ import {fromComponentDoc, type ComponentDocLike} from './fromComponentDoc.js';
13
+ import {checkContract, type ContractIssue} from './rules.js';
14
+ import type {ComponentContract} from './types.js';
15
+
16
+ export {fromComponentDoc} from './fromComponentDoc.js';
17
+ export type {ComponentDocLike, NativeAdditions} from './fromComponentDoc.js';
18
+ export {generateSwift} from './generateSwift.js';
19
+ export {generateTypeScript} from './generateTypeScript.js';
20
+ export {parseDocType} from './parseDocType.js';
21
+ export {checkContract, coreNameProblem} from './rules.js';
22
+ export type {ContractIssue} from './rules.js';
23
+ export type * from './types.js';
24
+
25
+ /** Loads one core component's documentation by component name. */
26
+ export type ComponentDocLoader = (name: string) => Promise<ComponentDocLike>;
27
+
28
+ export interface BuiltContracts {
29
+ readonly contracts: readonly ComponentContract[];
30
+ readonly warnings: readonly ContractIssue[];
31
+ }
32
+
33
+ /** Builds and checks every native component contract; throws on any error. */
34
+ export async function buildContracts(
35
+ loadDoc: ComponentDocLoader,
36
+ ): Promise<BuiltContracts> {
37
+ const contracts: ComponentContract[] = [];
38
+ const warnings: ContractIssue[] = [];
39
+ const errors: ContractIssue[] = [];
40
+ for (const additions of NATIVE_COMPONENTS) {
41
+ const contract = fromComponentDoc(await loadDoc(additions.name), additions);
42
+ for (const issue of checkContract(contract)) {
43
+ (issue.level === 'error' ? errors : warnings).push(issue);
44
+ }
45
+ contracts.push(contract);
46
+ }
47
+ if (errors.length > 0) {
48
+ throw new Error(
49
+ `Native contracts are invalid:\n${errors.map(e => ` ${e.message}`).join('\n')}`,
50
+ );
51
+ }
52
+ return {contracts, warnings};
53
+ }
@@ -0,0 +1,103 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file contract/names.ts
5
+ * @input None
6
+ * @output Name lists the AST-082 FR8 rules check against
7
+ * @position Data for contract/rules.ts and contract/fromComponentDoc.ts
8
+ */
9
+
10
+ export const SWIFT_KEYWORDS: ReadonlySet<string> = new Set(
11
+ (
12
+ 'associatedtype class deinit enum extension fileprivate func import init inout ' +
13
+ 'internal let open operator private precedencegroup protocol public rethrows ' +
14
+ 'static struct subscript typealias var break case catch continue default defer ' +
15
+ 'do else fallthrough for guard if in repeat return throw switch where while Any ' +
16
+ 'as await false is nil self Self super throws true try'
17
+ ).split(' '),
18
+ );
19
+
20
+ export const KOTLIN_KEYWORDS: ReadonlySet<string> = new Set(
21
+ (
22
+ 'as break class continue do else false for fun if in interface is null object ' +
23
+ 'package return super this throw true try typealias typeof val var when while'
24
+ ).split(' '),
25
+ );
26
+
27
+ /**
28
+ * Names each platform toolkit already uses with its own meaning. A core prop
29
+ * must not take one of them unless Astryx reclaims it (RECLAIMED).
30
+ */
31
+ export const TOOLKIT_NAMES: Readonly<Record<string, ReadonlySet<string>>> = {
32
+ web: new Set([
33
+ 'children',
34
+ 'className',
35
+ 'style',
36
+ 'title',
37
+ 'hidden',
38
+ 'role',
39
+ 'tabIndex',
40
+ 'dir',
41
+ 'slot',
42
+ 'is',
43
+ 'key',
44
+ 'ref',
45
+ 'onChange',
46
+ 'color',
47
+ 'content',
48
+ ]),
49
+ reactNative: new Set([
50
+ 'style',
51
+ 'testID',
52
+ 'nativeID',
53
+ 'onPress',
54
+ 'onLongPress',
55
+ 'onLayout',
56
+ 'accessibilityLabel',
57
+ 'accessibilityHint',
58
+ 'accessibilityRole',
59
+ 'accessibilityState',
60
+ 'accessible',
61
+ 'hitSlop',
62
+ 'pointerEvents',
63
+ 'disabled',
64
+ 'editable',
65
+ 'numberOfLines',
66
+ 'onChangeText',
67
+ 'selectable',
68
+ ]),
69
+ swiftUI: new Set([
70
+ 'body',
71
+ 'disabled',
72
+ 'hidden',
73
+ 'opacity',
74
+ 'frame',
75
+ 'tint',
76
+ 'font',
77
+ 'background',
78
+ 'overlay',
79
+ 'accessibilityLabel',
80
+ 'accessibilityHint',
81
+ 'accessibilityIdentifier',
82
+ ]),
83
+ compose: new Set([
84
+ 'modifier',
85
+ 'enabled',
86
+ 'onClick',
87
+ 'colors',
88
+ 'testTag',
89
+ 'contentDescription',
90
+ ]),
91
+ };
92
+
93
+ /**
94
+ * Web names Astryx deliberately takes over for a component-owned concept
95
+ * (AST-082 FR8). The platform's own meaning is removed from that component's
96
+ * base props.
97
+ */
98
+ export const RECLAIMED: ReadonlySet<string> = new Set([
99
+ 'onChange',
100
+ 'title',
101
+ 'color',
102
+ 'content',
103
+ ]);
@@ -0,0 +1,52 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import {describe, expect, it} from 'vitest';
4
+ import {parseDocType} from './parseDocType.js';
5
+
6
+ describe('parseDocType', () => {
7
+ it('reads portable forms', () => {
8
+ expect(parseDocType('string')).toEqual({ok: true, type: {kind: 'string'}});
9
+ expect(parseDocType("'sm' | 'md' | 'lg'")).toEqual({
10
+ ok: true,
11
+ type: {kind: 'enum', values: ['sm', 'md', 'lg']},
12
+ });
13
+ expect(parseDocType('SelectorOption[]')).toEqual({
14
+ ok: true,
15
+ type: {kind: 'array', of: {kind: 'named', name: 'SelectorOption'}},
16
+ });
17
+ expect(parseDocType('(value: string) => void')).toEqual({
18
+ ok: true,
19
+ type: {kind: 'fn', args: [{kind: 'string'}]},
20
+ });
21
+ expect(
22
+ parseDocType("{type: 'error' | 'warning', message?: string}"),
23
+ ).toEqual({
24
+ ok: true,
25
+ type: {
26
+ kind: 'record',
27
+ fields: {
28
+ type: {
29
+ type: {kind: 'enum', values: ['error', 'warning']},
30
+ optional: false,
31
+ },
32
+ message: {type: {kind: 'string'}, optional: true},
33
+ },
34
+ },
35
+ });
36
+ });
37
+
38
+ it.each([
39
+ ['ReactNode', 'free-form content'],
40
+ ['IconType | ReactNode', 'web icon component'],
41
+ ['(option: SelectorOptionData) => ReactNode', 'render function'],
42
+ ['(event: MouseEvent) => void', 'DOM event'],
43
+ ['StyleXStyles', 'web styling input'],
44
+ ['number | string', 'union of different kinds'],
45
+ ])('keeps %s out of the core', (type, reason) => {
46
+ const parsed = parseDocType(type);
47
+ expect(parsed.ok).toBe(false);
48
+ if (!parsed.ok) {
49
+ expect(parsed.reason).toContain(reason);
50
+ }
51
+ });
52
+ });
@@ -0,0 +1,211 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file contract/parseDocType.ts
5
+ * @input A prop's type string as a component's .doc.mjs documents it
6
+ * @output A portable PropType, or the reason the type has no portable form
7
+ * @position Reads core documentation into the contract model (AST-082 FR4)
8
+ */
9
+
10
+ import type {PropSpec, PropType} from './types.js';
11
+
12
+ export type ParsedType =
13
+ | {readonly ok: true; readonly type: PropType}
14
+ | {readonly ok: false; readonly reason: string};
15
+
16
+ /** Web-only types, keyed by name, with the reason each has no native form. */
17
+ const WEB_ONLY_TYPES: Readonly<Record<string, string>> = {
18
+ ReactNode: 'free-form content; use a typed slot in the core',
19
+ ReactElement: 'free-form content; use a typed slot in the core',
20
+ 'JSX.Element': 'free-form content; use a typed slot in the core',
21
+ StyleXStyles: 'web styling input',
22
+ CSSProperties: 'web styling input',
23
+ SizeValue: 'CSS length',
24
+ IconType: 'web icon component; native icons use shared icon names',
25
+ ElementType: 'web element choice',
26
+ ComponentType: 'web component reference',
27
+ LinkComponentType: 'web link component',
28
+ AriaRole: 'web accessibility attribute',
29
+ MouseEvent: 'DOM event',
30
+ FocusEvent: 'DOM event',
31
+ KeyboardEvent: 'DOM event',
32
+ PointerEvent: 'DOM event',
33
+ ChangeEvent: 'DOM event',
34
+ DragEvent: 'DOM event',
35
+ ClipboardEvent: 'DOM event',
36
+ };
37
+
38
+ const ok = (type: PropType): ParsedType => ({ok: true, type});
39
+ const no = (reason: string): ParsedType => ({ok: false, reason});
40
+
41
+ /** Splits on a separator at bracket depth zero, ignoring quoted text. */
42
+ function splitTop(text: string, separator: string): string[] {
43
+ const parts: string[] = [];
44
+ let depth = 0;
45
+ let quote: string | null = null;
46
+ let start = 0;
47
+ for (let i = 0; i < text.length; i++) {
48
+ const ch = text[i];
49
+ if (quote) {
50
+ if (ch === quote) {
51
+ quote = null;
52
+ }
53
+ continue;
54
+ }
55
+ if (ch === "'" || ch === '"') {
56
+ quote = ch;
57
+ } else if (text.startsWith('=>', i)) {
58
+ // An arrow is not a closing angle bracket.
59
+ if (depth === 0 && separator === '=>') {
60
+ parts.push(text.slice(start, i));
61
+ start = i + 2;
62
+ }
63
+ i += 1;
64
+ } else if ('([{<'.includes(ch)) {
65
+ depth++;
66
+ } else if (')]}>'.includes(ch)) {
67
+ depth--;
68
+ } else if (depth === 0 && text.startsWith(separator, i)) {
69
+ parts.push(text.slice(start, i));
70
+ start = i + separator.length;
71
+ i += separator.length - 1;
72
+ }
73
+ }
74
+ parts.push(text.slice(start));
75
+ return parts.map(part => part.trim()).filter(part => part.length > 0);
76
+ }
77
+
78
+ /** True when the first `(` closes at the last character, as in `(A | B)`. */
79
+ function isWrappedInParens(text: string): boolean {
80
+ if (!text.startsWith('(') || !text.endsWith(')')) {
81
+ return false;
82
+ }
83
+ let depth = 0;
84
+ for (let i = 0; i < text.length; i++) {
85
+ if (text[i] === '(') {
86
+ depth++;
87
+ } else if (text[i] === ')') {
88
+ depth--;
89
+ }
90
+ if (depth === 0 && i < text.length - 1) {
91
+ return false;
92
+ }
93
+ }
94
+ return true;
95
+ }
96
+
97
+ function stripOuterParens(text: string): string {
98
+ let t = text.trim();
99
+ while (isWrappedInParens(t)) {
100
+ t = t.slice(1, -1).trim();
101
+ }
102
+ return t;
103
+ }
104
+
105
+ function parseRecord(body: string): ParsedType {
106
+ const fields: Record<string, PropSpec> = {};
107
+ for (const member of splitTop(body, ',').flatMap(part =>
108
+ splitTop(part, ';'),
109
+ )) {
110
+ const match = /^([A-Za-z_$][\w$]*)(\?)?\s*:\s*([\s\S]+)$/.exec(member);
111
+ if (!match) {
112
+ return no(`record member "${member}" is not a plain field`);
113
+ }
114
+ const parsed = parseDocType(match[3]);
115
+ if (!parsed.ok) {
116
+ return no(`field "${match[1]}": ${parsed.reason}`);
117
+ }
118
+ fields[match[1]] = {type: parsed.type, optional: match[2] === '?'};
119
+ }
120
+ return ok({kind: 'record', fields});
121
+ }
122
+
123
+ function parseFunction(text: string): ParsedType | null {
124
+ const arrow = splitTop(text, '=>');
125
+ if (arrow.length !== 2 || !arrow[0].startsWith('(')) {
126
+ return null;
127
+ }
128
+ const returns = arrow[1].trim();
129
+ if (returns !== 'void') {
130
+ return no(
131
+ /React|ReactNode|JSX/.test(returns)
132
+ ? 'render function; it returns web UI'
133
+ : 'callback returns a value; only void callbacks are portable today',
134
+ );
135
+ }
136
+ const params = arrow[0].slice(1, -1).trim();
137
+ const args: PropType[] = [];
138
+ for (const param of params ? splitTop(params, ',') : []) {
139
+ const match = /^([A-Za-z_$][\w$]*)\??\s*:\s*([\s\S]+)$/.exec(param);
140
+ const parsed = parseDocType(match ? match[2] : param);
141
+ if (!parsed.ok) {
142
+ return no(`callback argument: ${parsed.reason}`);
143
+ }
144
+ args.push(parsed.type);
145
+ }
146
+ return ok({kind: 'fn', args});
147
+ }
148
+
149
+ /**
150
+ * Parses a documented type string. Supported portable forms: `string`,
151
+ * `number`, `boolean`, closed string unions, arrays, plain object literals,
152
+ * callbacks with portable arguments, and named types. A union of different
153
+ * kinds has no Swift or Kotlin form.
154
+ */
155
+ export function parseDocType(raw: string): ParsedType {
156
+ const text = stripOuterParens(raw.replace(/\s+/g, ' ').trim());
157
+ if (text === '') {
158
+ return no('no documented type');
159
+ }
160
+
161
+ const fn = parseFunction(text);
162
+ if (fn) {
163
+ return fn;
164
+ }
165
+
166
+ const members = splitTop(text, '|').filter(
167
+ m => m !== 'undefined' && m !== 'null',
168
+ );
169
+ if (members.length > 1) {
170
+ if (members.every(m => /^'[^']*'$/.test(m))) {
171
+ return ok({kind: 'enum', values: members.map(m => m.slice(1, -1))});
172
+ }
173
+ const webOnly = members.find(m => WEB_ONLY_TYPES[m] != null);
174
+ if (webOnly) {
175
+ return no(WEB_ONLY_TYPES[webOnly]);
176
+ }
177
+ return no('a union of different kinds has no Swift or Kotlin form');
178
+ }
179
+ const t = members[0] ?? '';
180
+
181
+ if (t === 'string' || t === 'number' || t === 'boolean') {
182
+ return ok({kind: t});
183
+ }
184
+ if (/^'[^']*'$/.test(t)) {
185
+ return ok({kind: 'enum', values: [t.slice(1, -1)]});
186
+ }
187
+ if (WEB_ONLY_TYPES[t] != null) {
188
+ return no(WEB_ONLY_TYPES[t]);
189
+ }
190
+
191
+ const generic = /^(?:Readonly)?Array<([\s\S]+)>$/.exec(t);
192
+ if (t.endsWith('[]') || generic) {
193
+ const inner = parseDocType(generic ? generic[1] : t.slice(0, -2));
194
+ return inner.ok
195
+ ? ok({kind: 'array', of: inner.type})
196
+ : no(`array item: ${inner.reason}`);
197
+ }
198
+ if (t.startsWith('{') && t.endsWith('}')) {
199
+ return parseRecord(t.slice(1, -1));
200
+ }
201
+ if (
202
+ /^(?:React\.)?(?:Ref|RefObject|MutableRefObject)</.test(t) ||
203
+ /^HTML\w*Element$/.test(t)
204
+ ) {
205
+ return no('web element reference');
206
+ }
207
+ if (/^[A-Z][\w$]*$/.test(t)) {
208
+ return ok({kind: 'named', name: t});
209
+ }
210
+ return no(`unsupported type "${t}"`);
211
+ }
@@ -0,0 +1,132 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file contract/rules.ts
5
+ * @input A component contract
6
+ * @output Naming and portability issues under AST-082 FR4 and FR8
7
+ * @position Run by the generator before it writes any binding; an error stops
8
+ * generation
9
+ */
10
+
11
+ import {NATIVE_PLATFORMS} from '../platforms.js';
12
+ import {
13
+ KOTLIN_KEYWORDS,
14
+ RECLAIMED,
15
+ SWIFT_KEYWORDS,
16
+ TOOLKIT_NAMES,
17
+ } from './names.js';
18
+ import type {ComponentContract, PropMap, PropType} from './types.js';
19
+
20
+ export interface ContractIssue {
21
+ readonly level: 'error' | 'warning';
22
+ readonly rule:
23
+ | 'core-is-portable'
24
+ | 'name-keyword'
25
+ | 'name-toolkit'
26
+ | 'platform-shadows-core'
27
+ | 'same-name-different-meaning'
28
+ | 'unknown-type';
29
+ readonly message: string;
30
+ }
31
+
32
+ /** Why a core prop name is not allowed, or null when it is. */
33
+ export function coreNameProblem(name: string): string | null {
34
+ if (SWIFT_KEYWORDS.has(name)) {
35
+ return `"${name}" is a Swift keyword`;
36
+ }
37
+ if (KOTLIN_KEYWORDS.has(name)) {
38
+ return `"${name}" is a Kotlin keyword`;
39
+ }
40
+ if (RECLAIMED.has(name)) {
41
+ return null;
42
+ }
43
+ for (const [toolkit, names] of Object.entries(TOOLKIT_NAMES)) {
44
+ if (names.has(name)) {
45
+ return `"${name}" already means something else in ${toolkit}`;
46
+ }
47
+ }
48
+ return null;
49
+ }
50
+
51
+ function namedTypesIn(type: PropType, into: Set<string>): Set<string> {
52
+ if (type.kind === 'named') {
53
+ into.add(type.name);
54
+ } else if (type.kind === 'array') {
55
+ namedTypesIn(type.of, into);
56
+ } else if (type.kind === 'fn') {
57
+ type.args.forEach(arg => namedTypesIn(arg, into));
58
+ } else if (type.kind === 'record') {
59
+ Object.values(type.fields).forEach(field => namedTypesIn(field.type, into));
60
+ }
61
+ return into;
62
+ }
63
+
64
+ export function checkContract(contract: ComponentContract): ContractIssue[] {
65
+ const issues: ContractIssue[] = [];
66
+ const add = (
67
+ level: ContractIssue['level'],
68
+ rule: ContractIssue['rule'],
69
+ message: string,
70
+ ) => issues.push({level, rule, message: `${contract.name}: ${message}`});
71
+
72
+ const referenced = new Set<string>();
73
+ const collect = (props: PropMap) =>
74
+ Object.values(props).forEach(spec => namedTypesIn(spec.type, referenced));
75
+
76
+ for (const name of Object.keys(contract.core)) {
77
+ const problem = coreNameProblem(name);
78
+ if (problem) {
79
+ add(
80
+ 'error',
81
+ SWIFT_KEYWORDS.has(name) || KOTLIN_KEYWORDS.has(name)
82
+ ? 'name-keyword'
83
+ : 'name-toolkit',
84
+ `core prop ${problem}`,
85
+ );
86
+ }
87
+ }
88
+ collect(contract.core);
89
+
90
+ const seen = new Map<string, {platform: string; type: string}>();
91
+ for (const platform of NATIVE_PLATFORMS) {
92
+ const props = contract.platforms[platform] ?? {};
93
+ collect(props);
94
+ for (const [name, spec] of Object.entries(props)) {
95
+ if (name in contract.core) {
96
+ add(
97
+ 'error',
98
+ 'platform-shadows-core',
99
+ `${platform} prop "${name}" reuses a core prop name`,
100
+ );
101
+ }
102
+ if (SWIFT_KEYWORDS.has(name) || KOTLIN_KEYWORDS.has(name)) {
103
+ add(
104
+ 'error',
105
+ 'name-keyword',
106
+ `${platform} prop "${name}" is a Swift or Kotlin keyword`,
107
+ );
108
+ }
109
+ const type = JSON.stringify(spec.type);
110
+ const earlier = seen.get(name);
111
+ if (earlier && earlier.type !== type) {
112
+ add(
113
+ 'warning',
114
+ 'same-name-different-meaning',
115
+ `"${name}" has different values on ${earlier.platform} and ${platform}; ` +
116
+ 'allowed in platform-scoped types, never in a merged type',
117
+ );
118
+ }
119
+ if (!earlier) {
120
+ seen.set(name, {platform, type});
121
+ }
122
+ }
123
+ }
124
+
125
+ Object.values(contract.types).forEach(collect);
126
+ for (const name of referenced) {
127
+ if (!(name in contract.types)) {
128
+ add('error', 'unknown-type', `type "${name}" has no definition`);
129
+ }
130
+ }
131
+ return issues;
132
+ }