@astryxdesign/native 0.7.4-canary.720bb10 → 0.7.5-canary.51c62ca

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/README.md CHANGED
@@ -34,6 +34,22 @@ const props: SelectorProps = {
34
34
  };
35
35
  ```
36
36
 
37
+ How core documentation becomes a contract:
38
+
39
+ - A callback whose arguments are values followed by the DOM event that caused
40
+ them, such as `(checked: boolean, e: ChangeEvent) => void`, keeps the values
41
+ in the core; the event stays on the web.
42
+ - A callback that carries only an event stays on the web, unless the component
43
+ names it in `callbacks`, as a button's `onClick` would. Its core form takes no
44
+ arguments. A callback with an event before a value stays on the web either
45
+ way.
46
+ - Free-form content becomes a typed slot only where a component declares one
47
+ in `slots`; `text` is a string, as for a badge's label or a heading's words.
48
+ - A closed set of numbers, such as a heading level, is a Swift enum with raw
49
+ values.
50
+ - A core prop may share a platform toolkit's name only when the component
51
+ declares in `sameMeaning` that it means the same thing there.
52
+
37
53
  ## Working on it
38
54
 
39
55
  ```sh
@@ -1,19 +1,56 @@
1
1
  import type { BaseTarget, ComponentContract, PropMap } from './types.js';
2
2
  import type { NativePlatform } from '../platforms.js';
3
- /** The subset of a ComponentDoc this reader needs. */
3
+ interface DocProp {
4
+ readonly name: string;
5
+ readonly type?: unknown;
6
+ readonly required?: boolean;
7
+ readonly description?: unknown;
8
+ }
9
+ /**
10
+ * The subset of a ComponentDoc this reader needs. A file that documents
11
+ * several components, such as Stack with HStack and VStack, lists them in
12
+ * `components`.
13
+ */
4
14
  export interface ComponentDocLike {
5
15
  readonly name: string;
6
- readonly props?: ReadonlyArray<{
16
+ readonly props?: ReadonlyArray<DocProp>;
17
+ readonly components?: ReadonlyArray<{
7
18
  readonly name: string;
8
- readonly type?: unknown;
9
- readonly required?: boolean;
10
- readonly description?: unknown;
19
+ readonly props?: ReadonlyArray<DocProp>;
11
20
  }>;
12
21
  }
22
+ /**
23
+ * Typed slots (AST-082 FR4) for documented free-form content. `text` is a
24
+ * string in the core contract, as a badge's label or a heading's words.
25
+ */
26
+ export type SlotKind = 'text';
13
27
  /** What the native package adds to one core component. */
14
28
  export interface NativeAdditions {
15
29
  readonly name: string;
16
30
  readonly baseTarget: BaseTarget;
31
+ /**
32
+ * Where core documents the component when it is not `<Name>/<Name>.doc.mjs`:
33
+ * `file` relative to core's `src` without `.doc.mjs`, and `component` when
34
+ * the file documents several components.
35
+ */
36
+ readonly doc?: {
37
+ readonly file: string;
38
+ readonly component?: string;
39
+ };
40
+ /** Documented free-form content props, given a typed slot in the core. */
41
+ readonly slots?: Readonly<Record<string, SlotKind>>;
42
+ /**
43
+ * Callbacks whose only documented argument is a DOM event but which name a
44
+ * moment every platform has, such as a button's press. Their core form
45
+ * takes no arguments.
46
+ */
47
+ readonly callbacks?: readonly string[];
48
+ /**
49
+ * Core props whose name a platform toolkit also uses, with the same meaning
50
+ * (AST-082 FR8 forbids only a different meaning), and why: a button's
51
+ * `onClick` is a press in Compose too.
52
+ */
53
+ readonly sameMeaning?: Readonly<Record<string, string>>;
17
54
  /**
18
55
  * Portable definitions for named types the documentation references. Fields
19
56
  * the web type has that are not portable are left out on purpose.
@@ -29,4 +66,5 @@ export interface NativeAdditions {
29
66
  readonly webOnly?: Readonly<Record<string, string>>;
30
67
  }
31
68
  export declare function fromComponentDoc(doc: ComponentDocLike, additions: NativeAdditions): ComponentContract;
69
+ export {};
32
70
  //# sourceMappingURL=fromComponentDoc.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"fromComponentDoc.d.ts","sourceRoot":"","sources":["../../src/contract/fromComponentDoc.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EACV,UAAU,EACV,iBAAiB,EACjB,OAAO,EAGR,MAAM,YAAY,CAAC;AACpB,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,iBAAiB,CAAC;AAEpD,sDAAsD;AACtD,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,CAAC,EAAE,aAAa,CAAC;QAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QACtB,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;QACxB,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;QAC5B,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,CAAC;KAChC,CAAC,CAAC;CACJ;AAED,0DAA0D;AAC1D,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,UAAU,EAAE,UAAU,CAAC;IAChC;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACnD,gFAAgF;IAChF,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACtD,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,OAAO,CAAC,MAAM,CAAC,cAAc,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;IACxE;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACrD;AAED,wBAAgB,gBAAgB,CAC9B,GAAG,EAAE,gBAAgB,EACrB,SAAS,EAAE,eAAe,GACzB,iBAAiB,CA0EnB"}
1
+ {"version":3,"file":"fromComponentDoc.d.ts","sourceRoot":"","sources":["../../src/contract/fromComponentDoc.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EACV,UAAU,EACV,iBAAiB,EACjB,OAAO,EAIR,MAAM,YAAY,CAAC;AACpB,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,iBAAiB,CAAC;AAEpD,UAAU,OAAO;IACf,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,CAAC;CAChC;AAED;;;;GAIG;AACH,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,CAAC,EAAE,aAAa,CAAC,OAAO,CAAC,CAAC;IACxC,QAAQ,CAAC,UAAU,CAAC,EAAE,aAAa,CAAC;QAClC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QACtB,QAAQ,CAAC,KAAK,CAAC,EAAE,aAAa,CAAC,OAAO,CAAC,CAAC;KACzC,CAAC,CAAC;CACJ;AAED;;;GAGG;AACH,MAAM,MAAM,QAAQ,GAAG,MAAM,CAAC;AAE9B,0DAA0D;AAC1D,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,UAAU,EAAE,UAAU,CAAC;IAChC;;;;OAIG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAC,CAAC;IACpE,0EAA0E;IAC1E,QAAQ,CAAC,KAAK,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC;IACpD;;;;OAIG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACvC;;;;OAIG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACxD;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACnD,gFAAgF;IAChF,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACtD,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,OAAO,CAAC,MAAM,CAAC,cAAc,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;IACxE;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACrD;AAwBD,wBAAgB,gBAAgB,CAC9B,GAAG,EAAE,gBAAgB,EACrB,SAAS,EAAE,eAAe,GACzB,iBAAiB,CA8GnB"}
@@ -10,11 +10,32 @@
10
10
  */
11
11
  import { parseDocType } from './parseDocType.js';
12
12
  import { coreNameProblem } from './rules.js';
13
+ /** The documented props of the component the additions name. */
14
+ function documentedProps(doc, additions) {
15
+ const wanted = additions.doc?.component;
16
+ if (wanted == null) {
17
+ return doc.props ?? [];
18
+ }
19
+ const entry = doc.components?.find(component => component.name === wanted);
20
+ if (!entry) {
21
+ throw new Error(`${additions.name}: core documentation "${doc.name}" does not document "${wanted}"`);
22
+ }
23
+ return entry.props ?? [];
24
+ }
25
+ const SLOT_TYPES = {
26
+ text: { kind: 'string' },
27
+ };
13
28
  export function fromComponentDoc(doc, additions) {
14
29
  const core = {};
15
30
  const webOnly = [];
16
31
  const types = additions.types ?? {};
17
- for (const prop of doc.props ?? []) {
32
+ const describe = (prop) => typeof prop.description === 'string' ? { doc: prop.description } : {};
33
+ for (const prop of documentedProps(doc, additions)) {
34
+ // `id` and `isHidden` are shared base props on every platform (FR7), so a
35
+ // documented prop of either name is the base prop, not a core prop.
36
+ if (prop.name === 'id' || prop.name === 'isHidden') {
37
+ continue;
38
+ }
18
39
  const declaredWebOnly = additions.webOnly?.[prop.name];
19
40
  if (declaredWebOnly) {
20
41
  webOnly.push({ name: prop.name, reason: declaredWebOnly });
@@ -29,13 +50,33 @@ export function fromComponentDoc(doc, additions) {
29
50
  webOnly.push({ name: prop.name, reason: 'HTML attribute' });
30
51
  continue;
31
52
  }
32
- const parsed = parseDocType(String(prop.type ?? ''));
53
+ const slot = additions.slots?.[prop.name];
54
+ const slotNameProblem = slot &&
55
+ coreNameProblem(prop.name, prop.name in (additions.sameMeaning ?? {}));
56
+ if (slotNameProblem) {
57
+ webOnly.push({ name: prop.name, reason: slotNameProblem });
58
+ continue;
59
+ }
60
+ if (slot) {
61
+ core[prop.name] = {
62
+ type: SLOT_TYPES[slot],
63
+ optional: prop.required !== true,
64
+ ...describe(prop),
65
+ };
66
+ continue;
67
+ }
68
+ let parsed = parseDocType(String(prop.type ?? ''));
69
+ if (!parsed.ok &&
70
+ parsed.eventOnly === true &&
71
+ additions.callbacks?.includes(prop.name)) {
72
+ parsed = { ok: true, type: { kind: 'fn', args: [] } };
73
+ }
33
74
  if (!parsed.ok) {
34
75
  webOnly.push({ name: prop.name, reason: parsed.reason });
35
76
  continue;
36
77
  }
37
78
  // A portable type can still carry a name another platform owns.
38
- const nameProblem = coreNameProblem(prop.name);
79
+ const nameProblem = coreNameProblem(prop.name, prop.name in (additions.sameMeaning ?? {}));
39
80
  if (nameProblem) {
40
81
  webOnly.push({ name: prop.name, reason: nameProblem });
41
82
  continue;
@@ -67,7 +108,7 @@ export function fromComponentDoc(doc, additions) {
67
108
  core[prop.name] = {
68
109
  type,
69
110
  optional: prop.required !== true,
70
- ...(typeof prop.description === 'string' ? { doc: prop.description } : {}),
111
+ ...describe(prop),
71
112
  };
72
113
  }
73
114
  return {
@@ -77,5 +118,6 @@ export function fromComponentDoc(doc, additions) {
77
118
  core,
78
119
  platforms: additions.platforms ?? {},
79
120
  webOnly,
121
+ sameMeaning: Object.keys(additions.sameMeaning ?? {}).filter(name => name in core),
80
122
  };
81
123
  }
@@ -1 +1 @@
1
- {"version":3,"file":"generateSwift.d.ts","sourceRoot":"","sources":["../../src/contract/generateSwift.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAC,iBAAiB,EAAoB,MAAM,YAAY,CAAC;AAoJrE,6EAA6E;AAC7E,wBAAgB,aAAa,CAC3B,SAAS,EAAE,SAAS,iBAAiB,EAAE,GACtC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAMxB"}
1
+ {"version":3,"file":"generateSwift.d.ts","sourceRoot":"","sources":["../../src/contract/generateSwift.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAC,iBAAiB,EAAoB,MAAM,YAAY,CAAC;AAoKrE,6EAA6E;AAC7E,wBAAgB,aAAa,CAC3B,SAAS,EAAE,SAAS,iBAAiB,EAAE,GACtC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAMxB"}
@@ -16,6 +16,10 @@ const APPLE_PLATFORMS = [
16
16
  const camel = (text) => text.replace(/[-_ ]+([a-zA-Z0-9])/g, (_, ch) => ch.toUpperCase());
17
17
  const pascal = (text) => camel(text).replace(/^./, ch => ch.toUpperCase());
18
18
  const ident = (name) => SWIFT_KEYWORDS.has(name) ? `\`${name}\`` : name;
19
+ /** `1` → `v1`, `0.5` → `v0_5`, `-1` → `vMinus1`. */
20
+ function numberCaseName(value) {
21
+ return `v${String(value).replace('-', 'Minus').replace('.', '_')}`;
22
+ }
19
23
  function caseName(value) {
20
24
  const name = camel(value.replace(/[^A-Za-z0-9_-]/g, '-'));
21
25
  return ident(/^[0-9]/.test(name) ? `v${name}` : name || 'empty');
@@ -37,6 +41,13 @@ class SwiftWriter {
37
41
  this.decls.push(`public enum ${name}: String, CaseIterable, Sendable {\n${cases.join('\n')}\n}`);
38
42
  return name;
39
43
  }
44
+ case 'numberEnum': {
45
+ const name = `${owner}${pascal(prop)}`;
46
+ const raw = type.values.every(Number.isInteger) ? 'Int' : 'Double';
47
+ const cases = type.values.map(value => ` case ${numberCaseName(value)} = ${value}`);
48
+ this.decls.push(`public enum ${name}: ${raw}, CaseIterable, Sendable {\n${cases.join('\n')}\n}`);
49
+ return name;
50
+ }
40
51
  case 'array':
41
52
  return `[${this.type(owner, prop, type.of)}]`;
42
53
  case 'record': {
@@ -1 +1 @@
1
- {"version":3,"file":"generateTypeScript.d.ts","sourceRoot":"","sources":["../../src/contract/generateTypeScript.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAC,iBAAiB,EAAoB,MAAM,YAAY,CAAC;AA2FrE,0EAA0E;AAC1E,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,SAAS,iBAAiB,EAAE,GACtC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CASxB"}
1
+ {"version":3,"file":"generateTypeScript.d.ts","sourceRoot":"","sources":["../../src/contract/generateTypeScript.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAC,iBAAiB,EAAoB,MAAM,YAAY,CAAC;AA6FrE,0EAA0E;AAC1E,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,SAAS,iBAAiB,EAAE,GACtC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CASxB"}
@@ -16,6 +16,8 @@ function tsType(type) {
16
16
  return type.kind;
17
17
  case 'enum':
18
18
  return type.values.map(value => `'${value}'`).join(' | ');
19
+ case 'numberEnum':
20
+ return type.values.join(' | ');
19
21
  case 'array': {
20
22
  const inner = tsType(type.of);
21
23
  return `ReadonlyArray<${inner}>`;
@@ -2,15 +2,19 @@ import { type ComponentDocLike } from './fromComponentDoc.js';
2
2
  import { type ContractIssue } from './rules.js';
3
3
  import type { ComponentContract } from './types.js';
4
4
  export { fromComponentDoc } from './fromComponentDoc.js';
5
- export type { ComponentDocLike, NativeAdditions } from './fromComponentDoc.js';
5
+ export type { ComponentDocLike, NativeAdditions, SlotKind, } from './fromComponentDoc.js';
6
6
  export { generateSwift } from './generateSwift.js';
7
7
  export { generateTypeScript } from './generateTypeScript.js';
8
8
  export { parseDocType } from './parseDocType.js';
9
9
  export { checkContract, coreNameProblem } from './rules.js';
10
10
  export type { ContractIssue } from './rules.js';
11
11
  export type * from './types.js';
12
- /** Loads one core component's documentation by component name. */
13
- export type ComponentDocLoader = (name: string) => Promise<ComponentDocLike>;
12
+ /**
13
+ * Loads one core documentation file: a component name for
14
+ * `<Name>/<Name>.doc.mjs`, or a path such as `Text/Heading` relative to core's
15
+ * `src` without `.doc.mjs`.
16
+ */
17
+ export type ComponentDocLoader = (file: string) => Promise<ComponentDocLike>;
14
18
  export interface BuiltContracts {
15
19
  readonly contracts: readonly ComponentContract[];
16
20
  readonly warnings: readonly ContractIssue[];
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/contract/index.ts"],"names":[],"mappings":"AAWA,OAAO,EAAmB,KAAK,gBAAgB,EAAC,MAAM,uBAAuB,CAAC;AAC9E,OAAO,EAAgB,KAAK,aAAa,EAAC,MAAM,YAAY,CAAC;AAC7D,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,YAAY,CAAC;AAElD,OAAO,EAAC,gBAAgB,EAAC,MAAM,uBAAuB,CAAC;AACvD,YAAY,EAAC,gBAAgB,EAAE,eAAe,EAAC,MAAM,uBAAuB,CAAC;AAC7E,OAAO,EAAC,aAAa,EAAC,MAAM,oBAAoB,CAAC;AACjD,OAAO,EAAC,kBAAkB,EAAC,MAAM,yBAAyB,CAAC;AAC3D,OAAO,EAAC,YAAY,EAAC,MAAM,mBAAmB,CAAC;AAC/C,OAAO,EAAC,aAAa,EAAE,eAAe,EAAC,MAAM,YAAY,CAAC;AAC1D,YAAY,EAAC,aAAa,EAAC,MAAM,YAAY,CAAC;AAC9C,mBAAmB,YAAY,CAAC;AAEhC,kEAAkE;AAClE,MAAM,MAAM,kBAAkB,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,gBAAgB,CAAC,CAAC;AAE7E,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,SAAS,EAAE,SAAS,iBAAiB,EAAE,CAAC;IACjD,QAAQ,CAAC,QAAQ,EAAE,SAAS,aAAa,EAAE,CAAC;CAC7C;AAED,8EAA8E;AAC9E,wBAAsB,cAAc,CAClC,OAAO,EAAE,kBAAkB,GAC1B,OAAO,CAAC,cAAc,CAAC,CAiBzB"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/contract/index.ts"],"names":[],"mappings":"AAWA,OAAO,EAAmB,KAAK,gBAAgB,EAAC,MAAM,uBAAuB,CAAC;AAC9E,OAAO,EAAgB,KAAK,aAAa,EAAC,MAAM,YAAY,CAAC;AAC7D,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,YAAY,CAAC;AAElD,OAAO,EAAC,gBAAgB,EAAC,MAAM,uBAAuB,CAAC;AACvD,YAAY,EACV,gBAAgB,EAChB,eAAe,EACf,QAAQ,GACT,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAC,aAAa,EAAC,MAAM,oBAAoB,CAAC;AACjD,OAAO,EAAC,kBAAkB,EAAC,MAAM,yBAAyB,CAAC;AAC3D,OAAO,EAAC,YAAY,EAAC,MAAM,mBAAmB,CAAC;AAC/C,OAAO,EAAC,aAAa,EAAE,eAAe,EAAC,MAAM,YAAY,CAAC;AAC1D,YAAY,EAAC,aAAa,EAAC,MAAM,YAAY,CAAC;AAC9C,mBAAmB,YAAY,CAAC;AAEhC;;;;GAIG;AACH,MAAM,MAAM,kBAAkB,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,gBAAgB,CAAC,CAAC;AAE7E,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,SAAS,EAAE,SAAS,iBAAiB,EAAE,CAAC;IACjD,QAAQ,CAAC,QAAQ,EAAE,SAAS,aAAa,EAAE,CAAC;CAC7C;AAED,8EAA8E;AAC9E,wBAAsB,cAAc,CAClC,OAAO,EAAE,kBAAkB,GAC1B,OAAO,CAAC,cAAc,CAAC,CAkBzB"}
@@ -20,7 +20,8 @@ export async function buildContracts(loadDoc) {
20
20
  const warnings = [];
21
21
  const errors = [];
22
22
  for (const additions of NATIVE_COMPONENTS) {
23
- const contract = fromComponentDoc(await loadDoc(additions.name), additions);
23
+ const doc = await loadDoc(additions.doc?.file ?? additions.name);
24
+ const contract = fromComponentDoc(doc, additions);
24
25
  for (const issue of checkContract(contract)) {
25
26
  (issue.level === 'error' ? errors : warnings).push(issue);
26
27
  }
@@ -11,11 +11,14 @@ export type ParsedType = {
11
11
  } | {
12
12
  readonly ok: false;
13
13
  readonly reason: string;
14
+ /** True for a callback whose only arguments are DOM events. */
15
+ readonly eventOnly?: boolean;
14
16
  };
15
17
  /**
16
18
  * Parses a documented type string. Supported portable forms: `string`,
17
- * `number`, `boolean`, closed string unions, arrays, plain object literals,
18
- * callbacks with portable arguments, and named types. A union of different
19
+ * `number`, `boolean`, closed string or number unions, arrays, plain object
20
+ * literals, callbacks with portable arguments (a trailing DOM event argument
21
+ * is the web's part), and named types. A union of different
19
22
  * kinds has no Swift or Kotlin form.
20
23
  */
21
24
  export declare function parseDocType(raw: string): ParsedType;
@@ -1 +1 @@
1
- {"version":3,"file":"parseDocType.d.ts","sourceRoot":"","sources":["../../src/contract/parseDocType.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AAEH,OAAO,KAAK,EAAW,QAAQ,EAAC,MAAM,YAAY,CAAC;AAEnD,MAAM,MAAM,UAAU,GAClB;IAAC,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;CAAC,GAC5C;IAAC,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAC,CAAC;AAuIlD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,CAwDpD"}
1
+ {"version":3,"file":"parseDocType.d.ts","sourceRoot":"","sources":["../../src/contract/parseDocType.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AAEH,OAAO,KAAK,EAAW,QAAQ,EAAC,MAAM,YAAY,CAAC;AAEnD,MAAM,MAAM,UAAU,GAClB;IAAC,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;CAAC,GAC5C;IACE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IACnB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,+DAA+D;IAC/D,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC;CAC9B,CAAC;AA8JN;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,CA8DpD"}
@@ -19,7 +19,15 @@ const WEB_ONLY_TYPES = {
19
19
  ChangeEvent: 'DOM event',
20
20
  DragEvent: 'DOM event',
21
21
  ClipboardEvent: 'DOM event',
22
+ FormEvent: 'DOM event',
23
+ TouchEvent: 'DOM event',
24
+ SyntheticEvent: 'DOM event',
22
25
  };
26
+ /** True for a DOM event type, with or without its element parameter. */
27
+ function isDomEvent(text) {
28
+ const name = /^(?:React\.)?([A-Z]\w*)(?:<[\s\S]*>)?$/.exec(text.trim())?.[1];
29
+ return name != null && WEB_ONLY_TYPES[name] === 'DOM event';
30
+ }
23
31
  const ok = (type) => ({ ok: true, type });
24
32
  const no = (reason) => ({ ok: false, reason });
25
33
  /** Splits on a separator at bracket depth zero, ignoring quoted text. */
@@ -115,10 +123,24 @@ function parseFunction(text) {
115
123
  : 'callback returns a value; only void callbacks are portable today');
116
124
  }
117
125
  const params = arrow[0].slice(1, -1).trim();
118
- const args = [];
119
- for (const param of params ? splitTop(params, ',') : []) {
126
+ const types = (params ? splitTop(params, ',') : []).map(param => {
120
127
  const match = /^([A-Za-z_$][\w$]*)\??\s*:\s*([\s\S]+)$/.exec(param);
121
- const parsed = parseDocType(match ? match[2] : param);
128
+ return match ? match[2] : param;
129
+ });
130
+ // A value callback ends with the DOM event that caused it, as in
131
+ // `(checked: boolean, e: ChangeEvent) => void`. The values are the portable
132
+ // callback; the trailing event is the web's part. A callback that carries
133
+ // only an event stays on the web.
134
+ let end = types.length;
135
+ while (end > 0 && isDomEvent(types[end - 1])) {
136
+ end--;
137
+ }
138
+ if (end === 0 && types.length > 0) {
139
+ return { ok: false, reason: 'callback argument: DOM event', eventOnly: true };
140
+ }
141
+ const args = [];
142
+ for (const type of types.slice(0, end)) {
143
+ const parsed = parseDocType(type);
122
144
  if (!parsed.ok) {
123
145
  return no(`callback argument: ${parsed.reason}`);
124
146
  }
@@ -128,8 +150,9 @@ function parseFunction(text) {
128
150
  }
129
151
  /**
130
152
  * Parses a documented type string. Supported portable forms: `string`,
131
- * `number`, `boolean`, closed string unions, arrays, plain object literals,
132
- * callbacks with portable arguments, and named types. A union of different
153
+ * `number`, `boolean`, closed string or number unions, arrays, plain object
154
+ * literals, callbacks with portable arguments (a trailing DOM event argument
155
+ * is the web's part), and named types. A union of different
133
156
  * kinds has no Swift or Kotlin form.
134
157
  */
135
158
  export function parseDocType(raw) {
@@ -146,6 +169,9 @@ export function parseDocType(raw) {
146
169
  if (members.every(m => /^'[^']*'$/.test(m))) {
147
170
  return ok({ kind: 'enum', values: members.map(m => m.slice(1, -1)) });
148
171
  }
172
+ if (members.every(m => /^-?\d+(?:\.\d+)?$/.test(m))) {
173
+ return ok({ kind: 'numberEnum', values: members.map(Number) });
174
+ }
149
175
  const webOnly = members.find(m => WEB_ONLY_TYPES[m] != null);
150
176
  if (webOnly) {
151
177
  return no(WEB_ONLY_TYPES[webOnly]);
@@ -162,6 +188,9 @@ export function parseDocType(raw) {
162
188
  if (WEB_ONLY_TYPES[t] != null) {
163
189
  return no(WEB_ONLY_TYPES[t]);
164
190
  }
191
+ if (isDomEvent(t)) {
192
+ return no('DOM event');
193
+ }
165
194
  const generic = /^(?:Readonly)?Array<([\s\S]+)>$/.exec(t);
166
195
  if (t.endsWith('[]') || generic) {
167
196
  const inner = parseDocType(generic ? generic[1] : t.slice(0, -2));
@@ -4,7 +4,10 @@ export interface ContractIssue {
4
4
  readonly rule: 'core-is-portable' | 'name-keyword' | 'name-toolkit' | 'platform-shadows-core' | 'same-name-different-meaning' | 'unknown-type';
5
5
  readonly message: string;
6
6
  }
7
- /** Why a core prop name is not allowed, or null when it is. */
8
- export declare function coreNameProblem(name: string): string | null;
7
+ /**
8
+ * Why a core prop name is not allowed, or null when it is. A toolkit name is
9
+ * allowed when the component declares it has the same meaning there.
10
+ */
11
+ export declare function coreNameProblem(name: string, sameMeaning?: boolean): string | null;
9
12
  export declare function checkContract(contract: ComponentContract): ContractIssue[];
10
13
  //# sourceMappingURL=rules.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"rules.d.ts","sourceRoot":"","sources":["../../src/contract/rules.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAC,iBAAiB,EAAoB,MAAM,YAAY,CAAC;AAErE,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,SAAS,CAAC;IACpC,QAAQ,CAAC,IAAI,EACT,kBAAkB,GAClB,cAAc,GACd,cAAc,GACd,uBAAuB,GACvB,6BAA6B,GAC7B,cAAc,CAAC;IACnB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED,+DAA+D;AAC/D,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAgB3D;AAeD,wBAAgB,aAAa,CAAC,QAAQ,EAAE,iBAAiB,GAAG,aAAa,EAAE,CAoE1E"}
1
+ {"version":3,"file":"rules.d.ts","sourceRoot":"","sources":["../../src/contract/rules.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAC,iBAAiB,EAAoB,MAAM,YAAY,CAAC;AAErE,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,SAAS,CAAC;IACpC,QAAQ,CAAC,IAAI,EACT,kBAAkB,GAClB,cAAc,GACd,cAAc,GACd,uBAAuB,GACvB,6BAA6B,GAC7B,cAAc,CAAC;IACnB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,IAAI,EAAE,MAAM,EACZ,WAAW,UAAQ,GAClB,MAAM,GAAG,IAAI,CAgBf;AAeD,wBAAgB,aAAa,CAAC,QAAQ,EAAE,iBAAiB,GAAG,aAAa,EAAE,CAqE1E"}
@@ -8,15 +8,18 @@
8
8
  */
9
9
  import { NATIVE_PLATFORMS } from '../platforms.js';
10
10
  import { KOTLIN_KEYWORDS, RECLAIMED, SWIFT_KEYWORDS, TOOLKIT_NAMES, } from './names.js';
11
- /** Why a core prop name is not allowed, or null when it is. */
12
- export function coreNameProblem(name) {
11
+ /**
12
+ * Why a core prop name is not allowed, or null when it is. A toolkit name is
13
+ * allowed when the component declares it has the same meaning there.
14
+ */
15
+ export function coreNameProblem(name, sameMeaning = false) {
13
16
  if (SWIFT_KEYWORDS.has(name)) {
14
17
  return `"${name}" is a Swift keyword`;
15
18
  }
16
19
  if (KOTLIN_KEYWORDS.has(name)) {
17
20
  return `"${name}" is a Kotlin keyword`;
18
21
  }
19
- if (RECLAIMED.has(name)) {
22
+ if (RECLAIMED.has(name) || sameMeaning) {
20
23
  return null;
21
24
  }
22
25
  for (const [toolkit, names] of Object.entries(TOOLKIT_NAMES)) {
@@ -46,8 +49,9 @@ export function checkContract(contract) {
46
49
  const add = (level, rule, message) => issues.push({ level, rule, message: `${contract.name}: ${message}` });
47
50
  const referenced = new Set();
48
51
  const collect = (props) => Object.values(props).forEach(spec => namedTypesIn(spec.type, referenced));
52
+ const sameMeaning = new Set(contract.sameMeaning ?? []);
49
53
  for (const name of Object.keys(contract.core)) {
50
- const problem = coreNameProblem(name);
54
+ const problem = coreNameProblem(name, sameMeaning.has(name));
51
55
  if (problem) {
52
56
  add('error', SWIFT_KEYWORDS.has(name) || KOTLIN_KEYWORDS.has(name)
53
57
  ? 'name-keyword'
@@ -16,6 +16,11 @@ export type PropType = {
16
16
  } | {
17
17
  kind: 'enum';
18
18
  values: readonly string[];
19
+ }
20
+ /** A closed set of numbers, such as a heading level or a spacing step. */
21
+ | {
22
+ kind: 'numberEnum';
23
+ values: readonly number[];
19
24
  } | {
20
25
  kind: 'array';
21
26
  of: PropType;
@@ -53,5 +58,7 @@ export interface ComponentContract {
53
58
  readonly platforms: Readonly<Partial<Record<NativePlatform, PropMap>>>;
54
59
  /** Core-doc props that stay in the web contract. */
55
60
  readonly webOnly: readonly WebOnlyProp[];
61
+ /** Core props that share a toolkit name with the same meaning (FR8). */
62
+ readonly sameMeaning?: readonly string[];
56
63
  }
57
64
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/contract/types.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,iBAAiB,CAAC;AAEpD,+EAA+E;AAC/E,MAAM,MAAM,QAAQ,GAChB;IAAC,IAAI,EAAE,QAAQ,CAAA;CAAC,GAChB;IAAC,IAAI,EAAE,QAAQ,CAAA;CAAC,GAChB;IAAC,IAAI,EAAE,SAAS,CAAA;CAAC,GACjB;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;CAAC,GACzC;IAAC,IAAI,EAAE,OAAO,CAAC;IAAC,EAAE,EAAE,QAAQ,CAAA;CAAC,GAC7B;IAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAA;CAAC,GAC5D;IAAC,IAAI,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,SAAS,QAAQ,EAAE,CAAA;CAAC,GACvC;IAAC,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAC,CAAC;AAElC,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,MAAM,OAAO,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC;AAEzD,sEAAsE;AACtE,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,SAAS,GAAG,SAAS,GAAG,WAAW,CAAC;AAEtE,iEAAiE;AACjE,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,UAAU,EAAE,UAAU,CAAC;IAChC,2EAA2E;IAC3E,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAClD,0DAA0D;IAC1D,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB,uEAAuE;IACvE,QAAQ,CAAC,SAAS,EAAE,QAAQ,CAAC,OAAO,CAAC,MAAM,CAAC,cAAc,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;IACvE,oDAAoD;IACpD,QAAQ,CAAC,OAAO,EAAE,SAAS,WAAW,EAAE,CAAC;CAC1C"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/contract/types.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,iBAAiB,CAAC;AAEpD,+EAA+E;AAC/E,MAAM,MAAM,QAAQ,GAChB;IAAC,IAAI,EAAE,QAAQ,CAAA;CAAC,GAChB;IAAC,IAAI,EAAE,QAAQ,CAAA;CAAC,GAChB;IAAC,IAAI,EAAE,SAAS,CAAA;CAAC,GACjB;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;CAAC;AAC3C,0EAA0E;GACxE;IAAC,IAAI,EAAE,YAAY,CAAC;IAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;CAAC,GAC/C;IAAC,IAAI,EAAE,OAAO,CAAC;IAAC,EAAE,EAAE,QAAQ,CAAA;CAAC,GAC7B;IAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAA;CAAC,GAC5D;IAAC,IAAI,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,SAAS,QAAQ,EAAE,CAAA;CAAC,GACvC;IAAC,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAC,CAAC;AAElC,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,MAAM,OAAO,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC;AAEzD,sEAAsE;AACtE,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,SAAS,GAAG,SAAS,GAAG,WAAW,CAAC;AAEtE,iEAAiE;AACjE,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,UAAU,EAAE,UAAU,CAAC;IAChC,2EAA2E;IAC3E,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAClD,0DAA0D;IAC1D,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB,uEAAuE;IACvE,QAAQ,CAAC,SAAS,EAAE,QAAQ,CAAC,OAAO,CAAC,MAAM,CAAC,cAAc,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;IACvE,oDAAoD;IACpD,QAAQ,CAAC,OAAO,EAAE,SAAS,WAAW,EAAE,CAAC;IACzC,wEAAwE;IACxE,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1C"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/native",
3
- "version": "0.7.4-canary.720bb10",
3
+ "version": "0.7.5-canary.51c62ca",
4
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
5
  "author": "Meta Open Source",
6
6
  "license": "MIT",
@@ -64,10 +64,10 @@
64
64
  }
65
65
  },
66
66
  "peerDependencies": {
67
- "@astryxdesign/core": "0.7.4-canary.720bb10"
67
+ "@astryxdesign/core": "0.7.5-canary.51c62ca"
68
68
  },
69
69
  "devDependencies": {
70
- "@astryxdesign/core": "0.7.4-canary.720bb10"
70
+ "@astryxdesign/core": "0.7.5-canary.51c62ca"
71
71
  },
72
72
  "scripts": {
73
73
  "build": "node ../../scripts/clean-dist.mjs && tsc --project tsconfig.build.json && node scripts/generate.mjs --types",
@@ -0,0 +1,97 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import {describe, expect, it} from 'vitest';
4
+ import {fromComponentDoc, type ComponentDocLike} from './fromComponentDoc.js';
5
+ import {generateSwift} from './generateSwift.js';
6
+
7
+ const doc: ComponentDocLike = {
8
+ name: 'Toggle',
9
+ props: [
10
+ {name: 'label', type: 'ReactNode', required: true},
11
+ {name: 'icon', type: 'ReactNode'},
12
+ {name: 'level', type: '1 | 2 | 3'},
13
+ {
14
+ name: 'onChange',
15
+ type: '(on: boolean, e: ChangeEvent<HTMLInputElement>) => void',
16
+ },
17
+ {name: 'onClick', type: '(e: MouseEvent) => void'},
18
+ {name: 'onFocus', type: '(e: FocusEvent) => void'},
19
+ {name: 'onPick', type: '(e: MouseEvent, value: string) => void'},
20
+ ],
21
+ };
22
+
23
+ describe('fromComponentDoc', () => {
24
+ const contract = fromComponentDoc(doc, {
25
+ name: 'Toggle',
26
+ baseTarget: 'control',
27
+ slots: {label: 'text'},
28
+ callbacks: ['onClick', 'onPick'],
29
+ sameMeaning: {onClick: 'a press in Compose too'},
30
+ });
31
+
32
+ it('gives declared content a typed text slot and leaves the rest on the web', () => {
33
+ expect(contract.core.label).toEqual({
34
+ type: {kind: 'string'},
35
+ optional: false,
36
+ });
37
+ expect(contract.webOnly.map(prop => prop.name)).toContain('icon');
38
+ });
39
+
40
+ it('keeps a value callback and a declared press, and leaves other event callbacks on the web', () => {
41
+ expect(contract.core.onChange?.type).toEqual({
42
+ kind: 'fn',
43
+ args: [{kind: 'boolean'}],
44
+ });
45
+ expect(contract.core.onClick?.type).toEqual({kind: 'fn', args: []});
46
+ expect(contract.webOnly).toContainEqual({
47
+ name: 'onFocus',
48
+ reason: 'callback argument: DOM event',
49
+ });
50
+ // `callbacks` never drops values: an event before a value stays on the web.
51
+ expect(contract.core).not.toHaveProperty('onPick');
52
+ expect(contract.webOnly.map(prop => prop.name)).toContain('onPick');
53
+ });
54
+
55
+ it('refuses a toolkit name unless the component declares the same meaning', () => {
56
+ const plain = fromComponentDoc(doc, {
57
+ name: 'Toggle',
58
+ baseTarget: 'control',
59
+ callbacks: ['onClick'],
60
+ });
61
+ expect(plain.core).not.toHaveProperty('onClick');
62
+ expect(
63
+ plain.webOnly.find(prop => prop.name === 'onClick')?.reason,
64
+ ).toContain('compose');
65
+ });
66
+
67
+ it('writes a closed set of numbers as a Swift enum with raw values', () => {
68
+ const swift = generateSwift([contract])['Toggle.swift'];
69
+ expect(swift).toContain(
70
+ 'public enum ToggleLevel: Int, CaseIterable, Sendable {',
71
+ );
72
+ expect(swift).toContain(' case v1 = 1');
73
+ });
74
+
75
+ it('reads one component of a file that documents several', () => {
76
+ const stack: ComponentDocLike = {
77
+ name: 'Stack',
78
+ components: [
79
+ {name: 'Stack', props: [{name: 'gap', type: '0 | 0.5 | 1'}]},
80
+ {name: 'VStack', props: [{name: 'wrap', type: 'boolean'}]},
81
+ ],
82
+ };
83
+ const vstack = fromComponentDoc(stack, {
84
+ name: 'VStack',
85
+ baseTarget: 'container',
86
+ doc: {file: 'Stack', component: 'VStack'},
87
+ });
88
+ expect(Object.keys(vstack.core)).toEqual(['wrap']);
89
+ expect(() =>
90
+ fromComponentDoc(stack, {
91
+ name: 'Missing',
92
+ baseTarget: 'root',
93
+ doc: {file: 'Stack', component: 'Missing'},
94
+ }),
95
+ ).toThrow('does not document "Missing"');
96
+ });
97
+ });
@@ -17,25 +17,62 @@ import type {
17
17
  ComponentContract,
18
18
  PropMap,
19
19
  PropSpec,
20
+ PropType,
20
21
  WebOnlyProp,
21
22
  } from './types.js';
22
23
  import type {NativePlatform} from '../platforms.js';
23
24
 
24
- /** The subset of a ComponentDoc this reader needs. */
25
+ interface DocProp {
26
+ readonly name: string;
27
+ readonly type?: unknown;
28
+ readonly required?: boolean;
29
+ readonly description?: unknown;
30
+ }
31
+
32
+ /**
33
+ * The subset of a ComponentDoc this reader needs. A file that documents
34
+ * several components, such as Stack with HStack and VStack, lists them in
35
+ * `components`.
36
+ */
25
37
  export interface ComponentDocLike {
26
38
  readonly name: string;
27
- readonly props?: ReadonlyArray<{
39
+ readonly props?: ReadonlyArray<DocProp>;
40
+ readonly components?: ReadonlyArray<{
28
41
  readonly name: string;
29
- readonly type?: unknown;
30
- readonly required?: boolean;
31
- readonly description?: unknown;
42
+ readonly props?: ReadonlyArray<DocProp>;
32
43
  }>;
33
44
  }
34
45
 
46
+ /**
47
+ * Typed slots (AST-082 FR4) for documented free-form content. `text` is a
48
+ * string in the core contract, as a badge's label or a heading's words.
49
+ */
50
+ export type SlotKind = 'text';
51
+
35
52
  /** What the native package adds to one core component. */
36
53
  export interface NativeAdditions {
37
54
  readonly name: string;
38
55
  readonly baseTarget: BaseTarget;
56
+ /**
57
+ * Where core documents the component when it is not `<Name>/<Name>.doc.mjs`:
58
+ * `file` relative to core's `src` without `.doc.mjs`, and `component` when
59
+ * the file documents several components.
60
+ */
61
+ readonly doc?: {readonly file: string; readonly component?: string};
62
+ /** Documented free-form content props, given a typed slot in the core. */
63
+ readonly slots?: Readonly<Record<string, SlotKind>>;
64
+ /**
65
+ * Callbacks whose only documented argument is a DOM event but which name a
66
+ * moment every platform has, such as a button's press. Their core form
67
+ * takes no arguments.
68
+ */
69
+ readonly callbacks?: readonly string[];
70
+ /**
71
+ * Core props whose name a platform toolkit also uses, with the same meaning
72
+ * (AST-082 FR8 forbids only a different meaning), and why: a button's
73
+ * `onClick` is a press in Compose too.
74
+ */
75
+ readonly sameMeaning?: Readonly<Record<string, string>>;
39
76
  /**
40
77
  * Portable definitions for named types the documentation references. Fields
41
78
  * the web type has that are not portable are left out on purpose.
@@ -51,6 +88,28 @@ export interface NativeAdditions {
51
88
  readonly webOnly?: Readonly<Record<string, string>>;
52
89
  }
53
90
 
91
+ /** The documented props of the component the additions name. */
92
+ function documentedProps(
93
+ doc: ComponentDocLike,
94
+ additions: NativeAdditions,
95
+ ): ReadonlyArray<DocProp> {
96
+ const wanted = additions.doc?.component;
97
+ if (wanted == null) {
98
+ return doc.props ?? [];
99
+ }
100
+ const entry = doc.components?.find(component => component.name === wanted);
101
+ if (!entry) {
102
+ throw new Error(
103
+ `${additions.name}: core documentation "${doc.name}" does not document "${wanted}"`,
104
+ );
105
+ }
106
+ return entry.props ?? [];
107
+ }
108
+
109
+ const SLOT_TYPES: Readonly<Record<SlotKind, PropType>> = {
110
+ text: {kind: 'string'},
111
+ };
112
+
54
113
  export function fromComponentDoc(
55
114
  doc: ComponentDocLike,
56
115
  additions: NativeAdditions,
@@ -58,8 +117,15 @@ export function fromComponentDoc(
58
117
  const core: Record<string, PropSpec> = {};
59
118
  const webOnly: WebOnlyProp[] = [];
60
119
  const types = additions.types ?? {};
120
+ const describe = (prop: DocProp) =>
121
+ typeof prop.description === 'string' ? {doc: prop.description} : {};
61
122
 
62
- for (const prop of doc.props ?? []) {
123
+ for (const prop of documentedProps(doc, additions)) {
124
+ // `id` and `isHidden` are shared base props on every platform (FR7), so a
125
+ // documented prop of either name is the base prop, not a core prop.
126
+ if (prop.name === 'id' || prop.name === 'isHidden') {
127
+ continue;
128
+ }
63
129
  const declaredWebOnly = additions.webOnly?.[prop.name];
64
130
  if (declaredWebOnly) {
65
131
  webOnly.push({name: prop.name, reason: declaredWebOnly});
@@ -76,13 +142,39 @@ export function fromComponentDoc(
76
142
  webOnly.push({name: prop.name, reason: 'HTML attribute'});
77
143
  continue;
78
144
  }
79
- const parsed = parseDocType(String(prop.type ?? ''));
145
+ const slot = additions.slots?.[prop.name];
146
+ const slotNameProblem =
147
+ slot &&
148
+ coreNameProblem(prop.name, prop.name in (additions.sameMeaning ?? {}));
149
+ if (slotNameProblem) {
150
+ webOnly.push({name: prop.name, reason: slotNameProblem});
151
+ continue;
152
+ }
153
+ if (slot) {
154
+ core[prop.name] = {
155
+ type: SLOT_TYPES[slot],
156
+ optional: prop.required !== true,
157
+ ...describe(prop),
158
+ };
159
+ continue;
160
+ }
161
+ let parsed = parseDocType(String(prop.type ?? ''));
162
+ if (
163
+ !parsed.ok &&
164
+ parsed.eventOnly === true &&
165
+ additions.callbacks?.includes(prop.name)
166
+ ) {
167
+ parsed = {ok: true, type: {kind: 'fn', args: []}};
168
+ }
80
169
  if (!parsed.ok) {
81
170
  webOnly.push({name: prop.name, reason: parsed.reason});
82
171
  continue;
83
172
  }
84
173
  // A portable type can still carry a name another platform owns.
85
- const nameProblem = coreNameProblem(prop.name);
174
+ const nameProblem = coreNameProblem(
175
+ prop.name,
176
+ prop.name in (additions.sameMeaning ?? {}),
177
+ );
86
178
  if (nameProblem) {
87
179
  webOnly.push({name: prop.name, reason: nameProblem});
88
180
  continue;
@@ -116,7 +208,7 @@ export function fromComponentDoc(
116
208
  core[prop.name] = {
117
209
  type,
118
210
  optional: prop.required !== true,
119
- ...(typeof prop.description === 'string' ? {doc: prop.description} : {}),
211
+ ...describe(prop),
120
212
  };
121
213
  }
122
214
 
@@ -127,5 +219,8 @@ export function fromComponentDoc(
127
219
  core,
128
220
  platforms: additions.platforms ?? {},
129
221
  webOnly,
222
+ sameMeaning: Object.keys(additions.sameMeaning ?? {}).filter(
223
+ name => name in core,
224
+ ),
130
225
  };
131
226
  }
@@ -25,6 +25,11 @@ const pascal = (text: string) =>
25
25
  const ident = (name: string) =>
26
26
  SWIFT_KEYWORDS.has(name) ? `\`${name}\`` : name;
27
27
 
28
+ /** `1` → `v1`, `0.5` → `v0_5`, `-1` → `vMinus1`. */
29
+ function numberCaseName(value: number): string {
30
+ return `v${String(value).replace('-', 'Minus').replace('.', '_')}`;
31
+ }
32
+
28
33
  function caseName(value: string): string {
29
34
  const name = camel(value.replace(/[^A-Za-z0-9_-]/g, '-'));
30
35
  return ident(/^[0-9]/.test(name) ? `v${name}` : name || 'empty');
@@ -52,6 +57,17 @@ class SwiftWriter {
52
57
  );
53
58
  return name;
54
59
  }
60
+ case 'numberEnum': {
61
+ const name = `${owner}${pascal(prop)}`;
62
+ const raw = type.values.every(Number.isInteger) ? 'Int' : 'Double';
63
+ const cases = type.values.map(
64
+ value => ` case ${numberCaseName(value)} = ${value}`,
65
+ );
66
+ this.decls.push(
67
+ `public enum ${name}: ${raw}, CaseIterable, Sendable {\n${cases.join('\n')}\n}`,
68
+ );
69
+ return name;
70
+ }
55
71
  case 'array':
56
72
  return `[${this.type(owner, prop, type.of)}]`;
57
73
  case 'record': {
@@ -21,6 +21,8 @@ function tsType(type: PropType): string {
21
21
  return type.kind;
22
22
  case 'enum':
23
23
  return type.values.map(value => `'${value}'`).join(' | ');
24
+ case 'numberEnum':
25
+ return type.values.join(' | ');
24
26
  case 'array': {
25
27
  const inner = tsType(type.of);
26
28
  return `ReadonlyArray<${inner}>`;
@@ -14,7 +14,11 @@ import {checkContract, type ContractIssue} from './rules.js';
14
14
  import type {ComponentContract} from './types.js';
15
15
 
16
16
  export {fromComponentDoc} from './fromComponentDoc.js';
17
- export type {ComponentDocLike, NativeAdditions} from './fromComponentDoc.js';
17
+ export type {
18
+ ComponentDocLike,
19
+ NativeAdditions,
20
+ SlotKind,
21
+ } from './fromComponentDoc.js';
18
22
  export {generateSwift} from './generateSwift.js';
19
23
  export {generateTypeScript} from './generateTypeScript.js';
20
24
  export {parseDocType} from './parseDocType.js';
@@ -22,8 +26,12 @@ export {checkContract, coreNameProblem} from './rules.js';
22
26
  export type {ContractIssue} from './rules.js';
23
27
  export type * from './types.js';
24
28
 
25
- /** Loads one core component's documentation by component name. */
26
- export type ComponentDocLoader = (name: string) => Promise<ComponentDocLike>;
29
+ /**
30
+ * Loads one core documentation file: a component name for
31
+ * `<Name>/<Name>.doc.mjs`, or a path such as `Text/Heading` relative to core's
32
+ * `src` without `.doc.mjs`.
33
+ */
34
+ export type ComponentDocLoader = (file: string) => Promise<ComponentDocLike>;
27
35
 
28
36
  export interface BuiltContracts {
29
37
  readonly contracts: readonly ComponentContract[];
@@ -38,7 +46,8 @@ export async function buildContracts(
38
46
  const warnings: ContractIssue[] = [];
39
47
  const errors: ContractIssue[] = [];
40
48
  for (const additions of NATIVE_COMPONENTS) {
41
- const contract = fromComponentDoc(await loadDoc(additions.name), additions);
49
+ const doc = await loadDoc(additions.doc?.file ?? additions.name);
50
+ const contract = fromComponentDoc(doc, additions);
42
51
  for (const issue of checkContract(contract)) {
43
52
  (issue.level === 'error' ? errors : warnings).push(issue);
44
53
  }
@@ -50,3 +50,48 @@ describe('parseDocType', () => {
50
50
  }
51
51
  });
52
52
  });
53
+
54
+ describe('parseDocType callbacks and closed number sets', () => {
55
+ it('keeps the values of a value callback and leaves its DOM event on the web', () => {
56
+ expect(
57
+ parseDocType(
58
+ '(checked: boolean, e: ChangeEvent<HTMLInputElement>) => void',
59
+ ),
60
+ ).toEqual({ok: true, type: {kind: 'fn', args: [{kind: 'boolean'}]}});
61
+ expect(
62
+ parseDocType('(value: string, event: React.ChangeEvent) => void'),
63
+ ).toEqual({ok: true, type: {kind: 'fn', args: [{kind: 'string'}]}});
64
+ });
65
+
66
+ it.each([
67
+ '(e: FocusEvent<HTMLInputElement>) => void',
68
+ '(event: MouseEvent) => void',
69
+ ])('keeps the event-only callback %s on the web', type => {
70
+ expect(parseDocType(type)).toEqual({
71
+ ok: false,
72
+ reason: 'callback argument: DOM event',
73
+ eventOnly: true,
74
+ });
75
+ });
76
+
77
+ it('refuses an event that is not last', () => {
78
+ const parsed = parseDocType('(e: MouseEvent, value: string) => void');
79
+ expect(parsed).toMatchObject({
80
+ ok: false,
81
+ reason: 'callback argument: DOM event',
82
+ });
83
+ expect(parsed).not.toHaveProperty('eventOnly');
84
+ });
85
+
86
+ it('reads a closed set of numbers', () => {
87
+ expect(parseDocType('1 | 2 | 3')).toEqual({
88
+ ok: true,
89
+ type: {kind: 'numberEnum', values: [1, 2, 3]},
90
+ });
91
+ expect(parseDocType('0 | 0.5 | 1')).toEqual({
92
+ ok: true,
93
+ type: {kind: 'numberEnum', values: [0, 0.5, 1]},
94
+ });
95
+ expect(parseDocType("1 | 'auto'").ok).toBe(false);
96
+ });
97
+ });
@@ -11,7 +11,12 @@ import type {PropSpec, PropType} from './types.js';
11
11
 
12
12
  export type ParsedType =
13
13
  | {readonly ok: true; readonly type: PropType}
14
- | {readonly ok: false; readonly reason: string};
14
+ | {
15
+ readonly ok: false;
16
+ readonly reason: string;
17
+ /** True for a callback whose only arguments are DOM events. */
18
+ readonly eventOnly?: boolean;
19
+ };
15
20
 
16
21
  /** Web-only types, keyed by name, with the reason each has no native form. */
17
22
  const WEB_ONLY_TYPES: Readonly<Record<string, string>> = {
@@ -33,8 +38,17 @@ const WEB_ONLY_TYPES: Readonly<Record<string, string>> = {
33
38
  ChangeEvent: 'DOM event',
34
39
  DragEvent: 'DOM event',
35
40
  ClipboardEvent: 'DOM event',
41
+ FormEvent: 'DOM event',
42
+ TouchEvent: 'DOM event',
43
+ SyntheticEvent: 'DOM event',
36
44
  };
37
45
 
46
+ /** True for a DOM event type, with or without its element parameter. */
47
+ function isDomEvent(text: string): boolean {
48
+ const name = /^(?:React\.)?([A-Z]\w*)(?:<[\s\S]*>)?$/.exec(text.trim())?.[1];
49
+ return name != null && WEB_ONLY_TYPES[name] === 'DOM event';
50
+ }
51
+
38
52
  const ok = (type: PropType): ParsedType => ({ok: true, type});
39
53
  const no = (reason: string): ParsedType => ({ok: false, reason});
40
54
 
@@ -134,10 +148,24 @@ function parseFunction(text: string): ParsedType | null {
134
148
  );
135
149
  }
136
150
  const params = arrow[0].slice(1, -1).trim();
137
- const args: PropType[] = [];
138
- for (const param of params ? splitTop(params, ',') : []) {
151
+ const types = (params ? splitTop(params, ',') : []).map(param => {
139
152
  const match = /^([A-Za-z_$][\w$]*)\??\s*:\s*([\s\S]+)$/.exec(param);
140
- const parsed = parseDocType(match ? match[2] : param);
153
+ return match ? match[2] : param;
154
+ });
155
+ // A value callback ends with the DOM event that caused it, as in
156
+ // `(checked: boolean, e: ChangeEvent) => void`. The values are the portable
157
+ // callback; the trailing event is the web's part. A callback that carries
158
+ // only an event stays on the web.
159
+ let end = types.length;
160
+ while (end > 0 && isDomEvent(types[end - 1])) {
161
+ end--;
162
+ }
163
+ if (end === 0 && types.length > 0) {
164
+ return {ok: false, reason: 'callback argument: DOM event', eventOnly: true};
165
+ }
166
+ const args: PropType[] = [];
167
+ for (const type of types.slice(0, end)) {
168
+ const parsed = parseDocType(type);
141
169
  if (!parsed.ok) {
142
170
  return no(`callback argument: ${parsed.reason}`);
143
171
  }
@@ -148,8 +176,9 @@ function parseFunction(text: string): ParsedType | null {
148
176
 
149
177
  /**
150
178
  * 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
179
+ * `number`, `boolean`, closed string or number unions, arrays, plain object
180
+ * literals, callbacks with portable arguments (a trailing DOM event argument
181
+ * is the web's part), and named types. A union of different
153
182
  * kinds has no Swift or Kotlin form.
154
183
  */
155
184
  export function parseDocType(raw: string): ParsedType {
@@ -170,6 +199,9 @@ export function parseDocType(raw: string): ParsedType {
170
199
  if (members.every(m => /^'[^']*'$/.test(m))) {
171
200
  return ok({kind: 'enum', values: members.map(m => m.slice(1, -1))});
172
201
  }
202
+ if (members.every(m => /^-?\d+(?:\.\d+)?$/.test(m))) {
203
+ return ok({kind: 'numberEnum', values: members.map(Number)});
204
+ }
173
205
  const webOnly = members.find(m => WEB_ONLY_TYPES[m] != null);
174
206
  if (webOnly) {
175
207
  return no(WEB_ONLY_TYPES[webOnly]);
@@ -187,6 +219,9 @@ export function parseDocType(raw: string): ParsedType {
187
219
  if (WEB_ONLY_TYPES[t] != null) {
188
220
  return no(WEB_ONLY_TYPES[t]);
189
221
  }
222
+ if (isDomEvent(t)) {
223
+ return no('DOM event');
224
+ }
190
225
 
191
226
  const generic = /^(?:Readonly)?Array<([\s\S]+)>$/.exec(t);
192
227
  if (t.endsWith('[]') || generic) {
@@ -29,15 +29,21 @@ export interface ContractIssue {
29
29
  readonly message: string;
30
30
  }
31
31
 
32
- /** Why a core prop name is not allowed, or null when it is. */
33
- export function coreNameProblem(name: string): string | null {
32
+ /**
33
+ * Why a core prop name is not allowed, or null when it is. A toolkit name is
34
+ * allowed when the component declares it has the same meaning there.
35
+ */
36
+ export function coreNameProblem(
37
+ name: string,
38
+ sameMeaning = false,
39
+ ): string | null {
34
40
  if (SWIFT_KEYWORDS.has(name)) {
35
41
  return `"${name}" is a Swift keyword`;
36
42
  }
37
43
  if (KOTLIN_KEYWORDS.has(name)) {
38
44
  return `"${name}" is a Kotlin keyword`;
39
45
  }
40
- if (RECLAIMED.has(name)) {
46
+ if (RECLAIMED.has(name) || sameMeaning) {
41
47
  return null;
42
48
  }
43
49
  for (const [toolkit, names] of Object.entries(TOOLKIT_NAMES)) {
@@ -73,8 +79,9 @@ export function checkContract(contract: ComponentContract): ContractIssue[] {
73
79
  const collect = (props: PropMap) =>
74
80
  Object.values(props).forEach(spec => namedTypesIn(spec.type, referenced));
75
81
 
82
+ const sameMeaning = new Set(contract.sameMeaning ?? []);
76
83
  for (const name of Object.keys(contract.core)) {
77
- const problem = coreNameProblem(name);
84
+ const problem = coreNameProblem(name, sameMeaning.has(name));
78
85
  if (problem) {
79
86
  add(
80
87
  'error',
@@ -16,6 +16,8 @@ export type PropType =
16
16
  | {kind: 'number'}
17
17
  | {kind: 'boolean'}
18
18
  | {kind: 'enum'; values: readonly string[]}
19
+ /** A closed set of numbers, such as a heading level or a spacing step. */
20
+ | {kind: 'numberEnum'; values: readonly number[]}
19
21
  | {kind: 'array'; of: PropType}
20
22
  | {kind: 'record'; fields: Readonly<Record<string, PropSpec>>}
21
23
  | {kind: 'fn'; args: readonly PropType[]}
@@ -49,4 +51,6 @@ export interface ComponentContract {
49
51
  readonly platforms: Readonly<Partial<Record<NativePlatform, PropMap>>>;
50
52
  /** Core-doc props that stay in the web contract. */
51
53
  readonly webOnly: readonly WebOnlyProp[];
54
+ /** Core props that share a toolkit name with the same meaning (FR8). */
55
+ readonly sameMeaning?: readonly string[];
52
56
  }