@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.
- package/LICENSE +21 -0
- package/README.md +119 -1
- package/apple/Package.swift +18 -0
- package/apple/Sources/AstryxNative/Generated/AstryxBase.swift +12 -0
- package/apple/Sources/AstryxNative/Generated/Selector.swift +159 -0
- package/apple/Tests/AstryxNativeTests/SelectorPropsTests.swift +17 -0
- package/dist/components/Selector.d.ts +10 -0
- package/dist/components/Selector.d.ts.map +1 -0
- package/dist/components/Selector.js +38 -0
- package/dist/components/index.d.ts +10 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +3 -0
- package/dist/contract/fromComponentDoc.d.ts +32 -0
- package/dist/contract/fromComponentDoc.d.ts.map +1 -0
- package/dist/contract/fromComponentDoc.js +81 -0
- package/dist/contract/generateSwift.d.ts +4 -0
- package/dist/contract/generateSwift.d.ts.map +1 -0
- package/dist/contract/generateSwift.js +133 -0
- package/dist/contract/generateTypeScript.d.ts +4 -0
- package/dist/contract/generateTypeScript.d.ts.map +1 -0
- package/dist/contract/generateTypeScript.js +87 -0
- package/dist/contract/index.d.ts +20 -0
- package/dist/contract/index.d.ts.map +1 -0
- package/dist/contract/index.js +33 -0
- package/dist/contract/names.d.ts +20 -0
- package/dist/contract/names.d.ts.map +1 -0
- package/dist/contract/names.js +90 -0
- package/dist/contract/parseDocType.d.ts +22 -0
- package/dist/contract/parseDocType.d.ts.map +1 -0
- package/dist/contract/parseDocType.js +183 -0
- package/dist/contract/rules.d.ts +10 -0
- package/dist/contract/rules.d.ts.map +1 -0
- package/dist/contract/rules.js +87 -0
- package/dist/contract/types.d.ts +57 -0
- package/dist/contract/types.d.ts.map +1 -0
- package/dist/contract/types.js +2 -0
- package/dist/icons/defaults.d.ts +25 -0
- package/dist/icons/defaults.d.ts.map +1 -0
- package/dist/icons/defaults.js +111 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/platforms.d.ts +13 -0
- package/dist/platforms.d.ts.map +1 -0
- package/dist/platforms.js +14 -0
- package/dist/theme/defineNativeTheme.d.ts +75 -0
- package/dist/theme/defineNativeTheme.d.ts.map +1 -0
- package/dist/theme/defineNativeTheme.js +126 -0
- package/dist/types/android.d.ts +8 -0
- package/dist/types/core.d.ts +35 -0
- package/dist/types/ios.d.ts +8 -0
- package/dist/types/macos.d.ts +8 -0
- package/dist/types/shared.d.ts +6 -0
- package/dist/types/windows.d.ts +6 -0
- package/package.json +72 -5
- package/src/components/Selector.ts +50 -0
- package/src/components/index.ts +14 -0
- package/src/contract/contracts.test.ts +150 -0
- package/src/contract/fromComponentDoc.ts +131 -0
- package/src/contract/generateSwift.ts +170 -0
- package/src/contract/generateTypeScript.ts +115 -0
- package/src/contract/index.ts +53 -0
- package/src/contract/names.ts +103 -0
- package/src/contract/parseDocType.test.ts +52 -0
- package/src/contract/parseDocType.ts +211 -0
- package/src/contract/rules.ts +132 -0
- package/src/contract/types.ts +52 -0
- package/src/icons/defaults.test.ts +47 -0
- package/src/icons/defaults.ts +136 -0
- package/src/index.ts +35 -0
- package/src/platforms.ts +20 -0
- package/src/theme/defineNativeTheme.test.ts +88 -0
- package/src/theme/defineNativeTheme.ts +224 -0
package/package.json
CHANGED
|
@@ -1,11 +1,78 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/native",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
3
|
+
"version": "0.7.4-canary.720bb10",
|
|
4
|
+
"description": "Experimental native platforms for Astryx (iOS, Android, macOS, Windows) — published to npm only under the @canary dist-tag; never released as a stable (latest) version.",
|
|
5
|
+
"author": "Meta Open Source",
|
|
6
6
|
"license": "MIT",
|
|
7
|
+
"homepage": "https://github.com/facebook/astryx#readme",
|
|
7
8
|
"repository": {
|
|
8
9
|
"type": "git",
|
|
9
|
-
"url": "git+https://github.com/facebook/astryx.git"
|
|
10
|
+
"url": "git+https://github.com/facebook/astryx.git",
|
|
11
|
+
"directory": "packages/native"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/facebook/astryx/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"astryx",
|
|
18
|
+
"native",
|
|
19
|
+
"experimental",
|
|
20
|
+
"design-system"
|
|
21
|
+
],
|
|
22
|
+
"astryx": {
|
|
23
|
+
"canaryOnly": true,
|
|
24
|
+
"_comment": "Published ONLY to the @canary dist-tag (see .github/workflows/release.yml). Never published as a stable `latest` release. `private: true` is intentional and must stay — it is npm's hard guarantee against an accidental stable publish."
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"sideEffects": false,
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"src",
|
|
33
|
+
"apple",
|
|
34
|
+
"README.md"
|
|
35
|
+
],
|
|
36
|
+
"exports": {
|
|
37
|
+
".": {
|
|
38
|
+
"source": "./src/index.ts",
|
|
39
|
+
"types": "./dist/index.d.ts",
|
|
40
|
+
"default": "./dist/index.js"
|
|
41
|
+
},
|
|
42
|
+
"./components": {
|
|
43
|
+
"ios": {
|
|
44
|
+
"types": "./dist/types/ios.d.ts"
|
|
45
|
+
},
|
|
46
|
+
"android": {
|
|
47
|
+
"types": "./dist/types/android.d.ts"
|
|
48
|
+
},
|
|
49
|
+
"macos": {
|
|
50
|
+
"types": "./dist/types/macos.d.ts"
|
|
51
|
+
},
|
|
52
|
+
"windows": {
|
|
53
|
+
"types": "./dist/types/windows.d.ts"
|
|
54
|
+
},
|
|
55
|
+
"shared": {
|
|
56
|
+
"types": "./dist/types/shared.d.ts"
|
|
57
|
+
},
|
|
58
|
+
"types": "./dist/types/shared.d.ts"
|
|
59
|
+
},
|
|
60
|
+
"./contract": {
|
|
61
|
+
"source": "./src/contract/index.ts",
|
|
62
|
+
"types": "./dist/contract/index.d.ts",
|
|
63
|
+
"default": "./dist/contract/index.js"
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"peerDependencies": {
|
|
67
|
+
"@astryxdesign/core": "0.7.4-canary.720bb10"
|
|
68
|
+
},
|
|
69
|
+
"devDependencies": {
|
|
70
|
+
"@astryxdesign/core": "0.7.4-canary.720bb10"
|
|
71
|
+
},
|
|
72
|
+
"scripts": {
|
|
73
|
+
"build": "node ../../scripts/clean-dist.mjs && tsc --project tsconfig.build.json && node scripts/generate.mjs --types",
|
|
74
|
+
"generate:apple": "pnpm build && node scripts/generate.mjs --apple",
|
|
75
|
+
"test": "vitest run --root ../.. packages/native",
|
|
76
|
+
"typecheck": "tsc --project tsconfig.json --noEmit"
|
|
10
77
|
}
|
|
11
|
-
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file components/Selector.ts
|
|
5
|
+
* @input None
|
|
6
|
+
* @output What @astryxdesign/native adds to the core Selector contract
|
|
7
|
+
* @position Native additions read by fromComponentDoc together with core's
|
|
8
|
+
* Selector documentation
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {NativeAdditions} from '../contract/fromComponentDoc.js';
|
|
12
|
+
|
|
13
|
+
export const selector: NativeAdditions = {
|
|
14
|
+
name: 'Selector',
|
|
15
|
+
baseTarget: 'trigger',
|
|
16
|
+
typeNames: {SelectorOption: 'SelectorOption'},
|
|
17
|
+
types: {
|
|
18
|
+
// The portable part of core's option data. Its rich description, icon,
|
|
19
|
+
// and secondary control are web-only until typed slots exist.
|
|
20
|
+
SelectorOption: {
|
|
21
|
+
value: {type: {kind: 'string'}},
|
|
22
|
+
label: {type: {kind: 'string'}, optional: true},
|
|
23
|
+
disabled: {type: {kind: 'boolean'}, optional: true},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
webOnly: {
|
|
27
|
+
presentation:
|
|
28
|
+
'web presentation choice; native platforms use their own control (FR11)',
|
|
29
|
+
},
|
|
30
|
+
platforms: {
|
|
31
|
+
ios: {
|
|
32
|
+
menuStyle: {
|
|
33
|
+
type: {kind: 'enum', values: ['popUp', 'pullDown']},
|
|
34
|
+
optional: true,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
macos: {
|
|
38
|
+
menuStyle: {
|
|
39
|
+
type: {kind: 'enum', values: ['popUp', 'pullDown']},
|
|
40
|
+
optional: true,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
android: {
|
|
44
|
+
menuStyle: {
|
|
45
|
+
type: {kind: 'enum', values: ['dropdown', 'bottomSheet']},
|
|
46
|
+
optional: true,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file components/index.ts
|
|
5
|
+
* @input None
|
|
6
|
+
* @output Every component the native package adds to, by core component name
|
|
7
|
+
* @position The generator walks this list; each entry pairs with core's
|
|
8
|
+
* `<Name>/<Name>.doc.mjs`
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {NativeAdditions} from '../contract/fromComponentDoc.js';
|
|
12
|
+
import {selector} from './Selector.js';
|
|
13
|
+
|
|
14
|
+
export const NATIVE_COMPONENTS: readonly NativeAdditions[] = [selector];
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {fileURLToPath} from 'node:url';
|
|
7
|
+
import ts from 'typescript';
|
|
8
|
+
import {describe, expect, it} from 'vitest';
|
|
9
|
+
import {loadCoreDoc} from '../../scripts/core-docs.mjs';
|
|
10
|
+
import {
|
|
11
|
+
buildContracts,
|
|
12
|
+
checkContract,
|
|
13
|
+
generateSwift,
|
|
14
|
+
generateTypeScript,
|
|
15
|
+
} from './index.js';
|
|
16
|
+
import type {ComponentContract} from './types.js';
|
|
17
|
+
|
|
18
|
+
const PACKAGE_ROOT = path.resolve(
|
|
19
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
20
|
+
'../..',
|
|
21
|
+
);
|
|
22
|
+
const APPLE_GENERATED = path.join(
|
|
23
|
+
PACKAGE_ROOT,
|
|
24
|
+
'apple/Sources/AstryxNative/Generated',
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
describe('native contracts', () => {
|
|
28
|
+
it('splits core Selector documentation into a portable core and a web part', async () => {
|
|
29
|
+
const {contracts} = await buildContracts(loadCoreDoc);
|
|
30
|
+
const selector = contracts.find(contract => contract.name === 'Selector');
|
|
31
|
+
expect(selector).toBeDefined();
|
|
32
|
+
expect(Object.keys(selector!.core)).toEqual(
|
|
33
|
+
expect.arrayContaining([
|
|
34
|
+
'label',
|
|
35
|
+
'options',
|
|
36
|
+
'value',
|
|
37
|
+
'onChange',
|
|
38
|
+
'isDisabled',
|
|
39
|
+
'status',
|
|
40
|
+
]),
|
|
41
|
+
);
|
|
42
|
+
const web = new Map(
|
|
43
|
+
selector!.webOnly.map(prop => [prop.name, prop.reason]),
|
|
44
|
+
);
|
|
45
|
+
expect(web.get('renderOption')).toContain('render function');
|
|
46
|
+
expect(web.get('emptyText')).toContain('typed slot');
|
|
47
|
+
expect(web.get('onClick')).toContain('DOM event');
|
|
48
|
+
expect(web.get('presentation')).toContain(
|
|
49
|
+
'native platforms use their own control',
|
|
50
|
+
);
|
|
51
|
+
expect(selector!.core).not.toHaveProperty('controlXstyle');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('reports naming and portability errors', () => {
|
|
55
|
+
const bad: ComponentContract = {
|
|
56
|
+
name: 'Bad',
|
|
57
|
+
baseTarget: 'root',
|
|
58
|
+
types: {},
|
|
59
|
+
core: {
|
|
60
|
+
as: {type: {kind: 'string'}},
|
|
61
|
+
style: {type: {kind: 'string'}},
|
|
62
|
+
item: {type: {kind: 'named', name: 'Missing'}},
|
|
63
|
+
},
|
|
64
|
+
platforms: {
|
|
65
|
+
ios: {item: {type: {kind: 'string'}}, var: {type: {kind: 'boolean'}}},
|
|
66
|
+
},
|
|
67
|
+
webOnly: [],
|
|
68
|
+
};
|
|
69
|
+
const rules = checkContract(bad).map(issue => issue.rule);
|
|
70
|
+
expect(rules).toEqual(
|
|
71
|
+
expect.arrayContaining([
|
|
72
|
+
'name-keyword',
|
|
73
|
+
'name-toolkit',
|
|
74
|
+
'platform-shadows-core',
|
|
75
|
+
'unknown-type',
|
|
76
|
+
]),
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('keeps the committed Swift binding in sync with the contracts', async () => {
|
|
81
|
+
const {contracts} = await buildContracts(loadCoreDoc);
|
|
82
|
+
for (const [name, contents] of Object.entries(generateSwift(contracts))) {
|
|
83
|
+
const committed = fs.readFileSync(
|
|
84
|
+
path.join(APPLE_GENERATED, name),
|
|
85
|
+
'utf8',
|
|
86
|
+
);
|
|
87
|
+
expect(
|
|
88
|
+
committed,
|
|
89
|
+
`${name} is stale; run pnpm -F @astryxdesign/native generate:apple`,
|
|
90
|
+
).toBe(contents);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('exposes each platform only its own props through type-resolution conditions', async () => {
|
|
95
|
+
const {contracts} = await buildContracts(loadCoreDoc);
|
|
96
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-native-types-'));
|
|
97
|
+
const pkg = path.join(dir, 'node_modules/@astryxdesign/native');
|
|
98
|
+
fs.mkdirSync(path.join(pkg, 'types'), {recursive: true});
|
|
99
|
+
for (const [name, contents] of Object.entries(
|
|
100
|
+
generateTypeScript(contracts),
|
|
101
|
+
)) {
|
|
102
|
+
fs.writeFileSync(path.join(pkg, 'types', name), contents);
|
|
103
|
+
}
|
|
104
|
+
const manifest = JSON.parse(
|
|
105
|
+
fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8'),
|
|
106
|
+
);
|
|
107
|
+
const exportsMap = JSON.stringify(
|
|
108
|
+
manifest.exports['./components'],
|
|
109
|
+
).replaceAll('./dist/types/', './types/');
|
|
110
|
+
fs.writeFileSync(
|
|
111
|
+
path.join(pkg, 'package.json'),
|
|
112
|
+
JSON.stringify({
|
|
113
|
+
name: '@astryxdesign/native',
|
|
114
|
+
type: 'module',
|
|
115
|
+
exports: {'./components': JSON.parse(exportsMap)},
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
const usage = (menuStyle: string) =>
|
|
119
|
+
"import type {SelectorProps} from '@astryxdesign/native/components';\n" +
|
|
120
|
+
`export const props: SelectorProps = {label: 'Shipping', options: [], menuStyle: '${menuStyle}'};\n`;
|
|
121
|
+
|
|
122
|
+
const errorsFor = (condition: string | null, source: string) => {
|
|
123
|
+
const file = path.join(dir, `${condition ?? 'none'}.ts`);
|
|
124
|
+
fs.writeFileSync(file, source);
|
|
125
|
+
const program = ts.createProgram([file], {
|
|
126
|
+
strict: true,
|
|
127
|
+
noEmit: true,
|
|
128
|
+
module: ts.ModuleKind.ESNext,
|
|
129
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
130
|
+
customConditions: condition ? [condition] : [],
|
|
131
|
+
types: [],
|
|
132
|
+
});
|
|
133
|
+
return ts
|
|
134
|
+
.getPreEmitDiagnostics(program)
|
|
135
|
+
.map(d => ts.flattenDiagnosticMessageText(d.messageText, '\n'));
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
expect(errorsFor('ios', usage('pullDown'))).toEqual([]);
|
|
139
|
+
expect(errorsFor('android', usage('bottomSheet'))).toEqual([]);
|
|
140
|
+
expect(errorsFor('android', usage('pullDown')).join('\n')).toContain(
|
|
141
|
+
'pullDown',
|
|
142
|
+
);
|
|
143
|
+
expect(errorsFor('shared', usage('pullDown')).join('\n')).toContain(
|
|
144
|
+
'menuStyle',
|
|
145
|
+
);
|
|
146
|
+
expect(errorsFor(null, usage('pullDown')).join('\n')).toContain(
|
|
147
|
+
'menuStyle',
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file contract/fromComponentDoc.ts
|
|
5
|
+
* @input A component's documentation (.doc.mjs) plus the native additions for it
|
|
6
|
+
* @output A component contract: the portable core, native platform props, and the
|
|
7
|
+
* web-only props with reasons
|
|
8
|
+
* @position Turns core documentation into the contract the generators consume.
|
|
9
|
+
* Reads documentation as data at build time; never imports core at run time
|
|
10
|
+
* (AST-082 FR16)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {parseDocType} from './parseDocType.js';
|
|
14
|
+
import {coreNameProblem} from './rules.js';
|
|
15
|
+
import type {
|
|
16
|
+
BaseTarget,
|
|
17
|
+
ComponentContract,
|
|
18
|
+
PropMap,
|
|
19
|
+
PropSpec,
|
|
20
|
+
WebOnlyProp,
|
|
21
|
+
} from './types.js';
|
|
22
|
+
import type {NativePlatform} from '../platforms.js';
|
|
23
|
+
|
|
24
|
+
/** The subset of a ComponentDoc this reader needs. */
|
|
25
|
+
export interface ComponentDocLike {
|
|
26
|
+
readonly name: string;
|
|
27
|
+
readonly props?: ReadonlyArray<{
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly type?: unknown;
|
|
30
|
+
readonly required?: boolean;
|
|
31
|
+
readonly description?: unknown;
|
|
32
|
+
}>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** What the native package adds to one core component. */
|
|
36
|
+
export interface NativeAdditions {
|
|
37
|
+
readonly name: string;
|
|
38
|
+
readonly baseTarget: BaseTarget;
|
|
39
|
+
/**
|
|
40
|
+
* Portable definitions for named types the documentation references. Fields
|
|
41
|
+
* the web type has that are not portable are left out on purpose.
|
|
42
|
+
*/
|
|
43
|
+
readonly types?: Readonly<Record<string, PropMap>>;
|
|
44
|
+
/** Documented types renamed to a native definition, e.g. `SelectorOption[]`. */
|
|
45
|
+
readonly typeNames?: Readonly<Record<string, string>>;
|
|
46
|
+
readonly platforms?: Readonly<Partial<Record<NativePlatform, PropMap>>>;
|
|
47
|
+
/**
|
|
48
|
+
* Portable-typed props that still belong to the web contract, with the
|
|
49
|
+
* reason, such as a web presentation heuristic a native platform replaces.
|
|
50
|
+
*/
|
|
51
|
+
readonly webOnly?: Readonly<Record<string, string>>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function fromComponentDoc(
|
|
55
|
+
doc: ComponentDocLike,
|
|
56
|
+
additions: NativeAdditions,
|
|
57
|
+
): ComponentContract {
|
|
58
|
+
const core: Record<string, PropSpec> = {};
|
|
59
|
+
const webOnly: WebOnlyProp[] = [];
|
|
60
|
+
const types = additions.types ?? {};
|
|
61
|
+
|
|
62
|
+
for (const prop of doc.props ?? []) {
|
|
63
|
+
const declaredWebOnly = additions.webOnly?.[prop.name];
|
|
64
|
+
if (declaredWebOnly) {
|
|
65
|
+
webOnly.push({name: prop.name, reason: declaredWebOnly});
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (
|
|
69
|
+
/(?:Xstyle|XStyle|ClassName|Style)$/.test(prop.name) ||
|
|
70
|
+
prop.name === 'xstyle'
|
|
71
|
+
) {
|
|
72
|
+
webOnly.push({name: prop.name, reason: 'web styling input'});
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (prop.name.startsWith('html') || prop.name.startsWith('aria-')) {
|
|
76
|
+
webOnly.push({name: prop.name, reason: 'HTML attribute'});
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const parsed = parseDocType(String(prop.type ?? ''));
|
|
80
|
+
if (!parsed.ok) {
|
|
81
|
+
webOnly.push({name: prop.name, reason: parsed.reason});
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
// A portable type can still carry a name another platform owns.
|
|
85
|
+
const nameProblem = coreNameProblem(prop.name);
|
|
86
|
+
if (nameProblem) {
|
|
87
|
+
webOnly.push({name: prop.name, reason: nameProblem});
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
let type = parsed.type;
|
|
91
|
+
const renamed = (name: string) => additions.typeNames?.[name] ?? name;
|
|
92
|
+
if (type.kind === 'named') {
|
|
93
|
+
type = {kind: 'named', name: renamed(type.name)};
|
|
94
|
+
}
|
|
95
|
+
if (type.kind === 'array' && type.of.kind === 'named') {
|
|
96
|
+
type = {kind: 'array', of: {kind: 'named', name: renamed(type.of.name)}};
|
|
97
|
+
}
|
|
98
|
+
if (type.kind === 'named' && !(type.name in types)) {
|
|
99
|
+
webOnly.push({
|
|
100
|
+
name: prop.name,
|
|
101
|
+
reason: `type "${type.name}" has no portable definition yet`,
|
|
102
|
+
});
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (
|
|
106
|
+
type.kind === 'array' &&
|
|
107
|
+
type.of.kind === 'named' &&
|
|
108
|
+
!(type.of.name in types)
|
|
109
|
+
) {
|
|
110
|
+
webOnly.push({
|
|
111
|
+
name: prop.name,
|
|
112
|
+
reason: `type "${type.of.name}" has no portable definition yet`,
|
|
113
|
+
});
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
core[prop.name] = {
|
|
117
|
+
type,
|
|
118
|
+
optional: prop.required !== true,
|
|
119
|
+
...(typeof prop.description === 'string' ? {doc: prop.description} : {}),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
name: additions.name,
|
|
125
|
+
baseTarget: additions.baseTarget,
|
|
126
|
+
types,
|
|
127
|
+
core,
|
|
128
|
+
platforms: additions.platforms ?? {},
|
|
129
|
+
webOnly,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file contract/generateSwift.ts
|
|
5
|
+
* @input Component contracts
|
|
6
|
+
* @output Swift sources for the Apple platforms: shared base props and one file
|
|
7
|
+
* per component, with the iOS and macOS contracts behind `#if os(...)`
|
|
8
|
+
* @position Produces the Swift binding (AST-082 FR5). Generated files are
|
|
9
|
+
* committed under apple/ so a Swift package can pin a commit
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {SWIFT_KEYWORDS} from './names.js';
|
|
13
|
+
import type {ComponentContract, PropMap, PropType} from './types.js';
|
|
14
|
+
|
|
15
|
+
const HEADER = '// Generated by @astryxdesign/native. Do not edit.\n';
|
|
16
|
+
const APPLE_PLATFORMS = [
|
|
17
|
+
{platform: 'ios', condition: 'os(iOS)'},
|
|
18
|
+
{platform: 'macos', condition: 'os(macOS)'},
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
const camel = (text: string) =>
|
|
22
|
+
text.replace(/[-_ ]+([a-zA-Z0-9])/g, (_, ch: string) => ch.toUpperCase());
|
|
23
|
+
const pascal = (text: string) =>
|
|
24
|
+
camel(text).replace(/^./, ch => ch.toUpperCase());
|
|
25
|
+
const ident = (name: string) =>
|
|
26
|
+
SWIFT_KEYWORDS.has(name) ? `\`${name}\`` : name;
|
|
27
|
+
|
|
28
|
+
function caseName(value: string): string {
|
|
29
|
+
const name = camel(value.replace(/[^A-Za-z0-9_-]/g, '-'));
|
|
30
|
+
return ident(/^[0-9]/.test(name) ? `v${name}` : name || 'empty');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Collects nested declarations (enums, structs) while rendering types. */
|
|
34
|
+
class SwiftWriter {
|
|
35
|
+
readonly decls: string[] = [];
|
|
36
|
+
|
|
37
|
+
type(owner: string, prop: string, type: PropType): string {
|
|
38
|
+
switch (type.kind) {
|
|
39
|
+
case 'string':
|
|
40
|
+
return 'String';
|
|
41
|
+
case 'number':
|
|
42
|
+
return 'Double';
|
|
43
|
+
case 'boolean':
|
|
44
|
+
return 'Bool';
|
|
45
|
+
case 'enum': {
|
|
46
|
+
const name = `${owner}${pascal(prop)}`;
|
|
47
|
+
const cases = type.values.map(
|
|
48
|
+
value => ` case ${caseName(value)} = "${value}"`,
|
|
49
|
+
);
|
|
50
|
+
this.decls.push(
|
|
51
|
+
`public enum ${name}: String, CaseIterable, Sendable {\n${cases.join('\n')}\n}`,
|
|
52
|
+
);
|
|
53
|
+
return name;
|
|
54
|
+
}
|
|
55
|
+
case 'array':
|
|
56
|
+
return `[${this.type(owner, prop, type.of)}]`;
|
|
57
|
+
case 'record': {
|
|
58
|
+
const name = `${owner}${pascal(prop)}`;
|
|
59
|
+
this.decls.push(this.struct(name, type.fields, 'Equatable, Sendable'));
|
|
60
|
+
return name;
|
|
61
|
+
}
|
|
62
|
+
case 'fn':
|
|
63
|
+
return `(${type.args.map((arg, i) => this.type(owner, `${prop}Arg${i}`, arg)).join(', ')}) -> Void`;
|
|
64
|
+
case 'named':
|
|
65
|
+
return type.name;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
fields(
|
|
70
|
+
owner: string,
|
|
71
|
+
props: PropMap,
|
|
72
|
+
): Array<{name: string; decl: string; param: string; assign: string}> {
|
|
73
|
+
return Object.entries(props).map(([name, spec]) => {
|
|
74
|
+
const base = this.type(owner, name, spec.type);
|
|
75
|
+
const optional = spec.type.kind === 'fn' ? `(${base})?` : `${base}?`;
|
|
76
|
+
const swiftType = spec.optional ? optional : base;
|
|
77
|
+
return {
|
|
78
|
+
name,
|
|
79
|
+
decl: ` public var ${ident(name)}: ${swiftType}`,
|
|
80
|
+
param: `${name}: ${spec.type.kind === 'fn' && !spec.optional ? `@escaping ${swiftType}` : swiftType}${spec.optional ? ' = nil' : ''}`,
|
|
81
|
+
assign: ` self.${name} = ${ident(name)}`,
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
struct(
|
|
87
|
+
name: string,
|
|
88
|
+
props: PropMap,
|
|
89
|
+
conformances: string | null,
|
|
90
|
+
extra: string[] = [],
|
|
91
|
+
): string {
|
|
92
|
+
const fields = this.fields(name, props);
|
|
93
|
+
const head = conformances
|
|
94
|
+
? `public struct ${name}: ${conformances} {`
|
|
95
|
+
: `public struct ${name} {`;
|
|
96
|
+
return [
|
|
97
|
+
head,
|
|
98
|
+
...extra,
|
|
99
|
+
...fields.map(field => field.decl),
|
|
100
|
+
` public init(${fields.map(field => field.param).join(', ')}) {`,
|
|
101
|
+
...fields.map(field => field.assign),
|
|
102
|
+
' }',
|
|
103
|
+
'}',
|
|
104
|
+
].join('\n');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function componentFile(contract: ComponentContract): string {
|
|
109
|
+
const shared = new SwiftWriter();
|
|
110
|
+
const typeDecls = Object.entries(contract.types).map(([name, fields]) =>
|
|
111
|
+
shared.struct(name, fields, 'Equatable, Sendable'),
|
|
112
|
+
);
|
|
113
|
+
// Core enums and records are declared once, outside the platform blocks.
|
|
114
|
+
shared.fields(`${contract.name}`, contract.core);
|
|
115
|
+
|
|
116
|
+
const variants = APPLE_PLATFORMS.map(({platform, condition}) => {
|
|
117
|
+
const writer = new SwiftWriter();
|
|
118
|
+
const extra = contract.platforms[platform] ?? {};
|
|
119
|
+
const fields = writer.fields(contract.name, {...contract.core, ...extra});
|
|
120
|
+
// Core-prop declarations already live outside the platform blocks; keep
|
|
121
|
+
// only the declarations this platform's own props need.
|
|
122
|
+
writer.decls.length = 0;
|
|
123
|
+
writer.fields(contract.name, extra);
|
|
124
|
+
const struct = [
|
|
125
|
+
`public struct ${contract.name}Props {`,
|
|
126
|
+
` /// Base props attach to this part on every platform.`,
|
|
127
|
+
` public static let baseTarget: AstryxBaseTarget = .${contract.baseTarget}`,
|
|
128
|
+
' public var base: AstryxBaseProps',
|
|
129
|
+
...fields.map(field => field.decl),
|
|
130
|
+
` public init(${[...fields.map(field => field.param), 'base: AstryxBaseProps = AstryxBaseProps()'].join(', ')}) {`,
|
|
131
|
+
...fields.map(field => field.assign),
|
|
132
|
+
' self.base = base',
|
|
133
|
+
' }',
|
|
134
|
+
'}',
|
|
135
|
+
].join('\n');
|
|
136
|
+
return `#if ${condition}\n${[...writer.decls, struct].join('\n')}\n#endif`;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
HEADER + [...typeDecls, ...shared.decls, ...variants].join('\n') + '\n'
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const BASE_FILE =
|
|
145
|
+
HEADER +
|
|
146
|
+
[
|
|
147
|
+
'public enum AstryxBaseTarget: String, Sendable { case root, trigger, control, container }',
|
|
148
|
+
'',
|
|
149
|
+
'/// Base props shared by every platform (AST-082 FR7).',
|
|
150
|
+
'public struct AstryxBaseProps: Equatable, Sendable {',
|
|
151
|
+
' public var id: String?',
|
|
152
|
+
' public var isHidden: Bool?',
|
|
153
|
+
' public init(id: String? = nil, isHidden: Bool? = nil) {',
|
|
154
|
+
' self.id = id',
|
|
155
|
+
' self.isHidden = isHidden',
|
|
156
|
+
' }',
|
|
157
|
+
'}',
|
|
158
|
+
].join('\n') +
|
|
159
|
+
'\n';
|
|
160
|
+
|
|
161
|
+
/** Returns file name to contents, relative to the generated Swift folder. */
|
|
162
|
+
export function generateSwift(
|
|
163
|
+
contracts: readonly ComponentContract[],
|
|
164
|
+
): Record<string, string> {
|
|
165
|
+
const files: Record<string, string> = {'AstryxBase.swift': BASE_FILE};
|
|
166
|
+
for (const contract of contracts) {
|
|
167
|
+
files[`${contract.name}.swift`] = componentFile(contract);
|
|
168
|
+
}
|
|
169
|
+
return files;
|
|
170
|
+
}
|