Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions packages/target-angular/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,30 @@ options need; the component.

- **Types.** The source's type declarations the output reads (its inputs' and outputs' types, the
setup's annotations and code) and every exported one, copied as written and exported as the
source exports them. An input is typed by its member's type, never by the props type, so a
props type the source does not export is left out (it would be declared and never used).
source exports them. An input is typed by its member's type, so a props type the source does
not export is left out (it would be declared and never used), unless an input reads a member
from it (below).
- **Imported types** (ADR-0067). The types the source imports, by name, where the output reads
them: from a `.ts` module or a package as written, `import type { BadgeProps } from "./types"`,
and from a child's output, `import Field, { type FieldProps } from "./field"`. The output of the
module's main component also writes the module's type exports, in source order among its
types (ADR-0068): `export type { Tone } from "./types"`, and an exported type no component
reaches.
- **Inputs.** Every prop is a public signal input, whether the template reads it or not (a
prop is the component's API, and Angular reports a value for an undeclared input):
`input.required<T>()` when required, `input<T>()` when optional, and
`input<T, T | undefined>(default, { transform })` with a default, whose transform is
`(value) => (value === undefined ? default : value)`: an explicit `undefined` takes the
default, as JavaScript's destructuring does, and `null` stays a value.
default, as JavaScript's destructuring does, and `null` stays a value. A member another
declaration declares (inherited, intersected, picked or imported) is written in that file's
names, so its input reads it from the props type by indexed access (ADR-0067):
`input.required<BadgeProps["label"]>()`, and with a default
`input<Exclude<BadgeProps["tone"], undefined>, BadgeProps["tone"]>(…)`.
- **Generic components** (ADR-0068). A generic component is a generic class,
`export default class List<T extends Item = Item>`, its constraints and defaults as written,
whose type parameters ngtsc infers from a consumer's inputs. A select's `v-model` of a state of
such a type takes the chosen value through the template's own cast,
`this.value.set($any(valueField.value))`, since a template writes no `as`.
- **Outputs.** Every event `defineEmits` declares is a public `output()` of its name (ADR-0012),
or, for a name angular-eslint's `no-output-native` knows as a native DOM event, `uf` and the name
in PascalCase (`change` → `ufChange`, ADR-0056), which the native event bubbling out of the
Expand Down Expand Up @@ -118,7 +134,7 @@ annotation, or the type TypeScript gives the `let` where the value's text makes
`note ?? "none"` and `a ? "x" : "y"`, `.length`, the string, number and array methods whose
result the receiver fixes, the pure globals' results, and reads of a prop or of a member whose
type is known so); otherwise, as a last resort, a private method computes the value, and the field
is typed by it (`ReturnType<typeof this.initialFound>`), since no type oracle exists before M5.
is typed by it (`ReturnType<typeof this.initialFound>`), since a target reads no type oracle.

A local function code passes as a value is an arrow field, declared before every other field of
the setup: a field initialiser runs while the class constructs, in field order, and an initial
Expand Down Expand Up @@ -281,7 +297,8 @@ spreads; ADR-0065) and composition's cells that ADR-0056 decides: `named-slot`,
`contextual-root`, unsupported (UF4001, errors), `child-root-scope` and `sheet-order`,
unsupported (UF4003 and UF4002, info), and `generic-slot-props`, unsupported (UF4004, info): slot
directives cannot infer a component's type parameter, so a scoped slot's context takes its
constraint (ADR-0068). Where an unsupported cell is reported, the
constraint (ADR-0068), or `any` for a parameter without one, as ngtsc types a generic context it
cannot infer. Where an unsupported cell is reported, the
output still compiles: default-slot presence reads `true`, and the forwarded default slot
re-projects `<ng-content />`.

Expand Down
8 changes: 6 additions & 2 deletions packages/target-angular/src/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ import type {
SlotDeclaration,
SlotFill,
Span,
TypeParameter,
UfComponent,
} from "@unframework/ir";

import type { Plan } from "./plan.ts";
import { atConstraints } from "./types.ts";

/** A slot of the component's own that a directive selects: a named one, or one with props. */
export interface SlotDirective {
Expand Down Expand Up @@ -255,12 +257,14 @@ export function childEvent(plan: Plan, local: string, event: string): ApiEvent {
/**
* A slot directive's code (ADR-0056): the selector its consumers' `<ng-template>` names and, for
* a scoped slot, the context guard that types their `let-` variables as the slot's props, by
* name and whole (`$implicit`).
* name and whole (`$implicit`). A directive cannot infer its component's type parameters, so
* the props read each one as its constraint (`generic-slot-props`, UF4004, ADR-0068).
*/
export function slotDirectiveCode(
component: string,
directive: SlotDirective,
decorator: string,
typeParameters: readonly TypeParameter[] = [],
): string {
const { slot, className, exported, attribute, context } = directive;
const selector = `@${decorator}({ selector: "ng-template[${attribute}]" })`;
Expand All @@ -270,7 +274,7 @@ export function slotDirectiveCode(
...(context
? [
`/** What a template of \`${component}\`'s \`${slot.name}\` slot reads: the slot's props. */`,
`export type ${context} = ${slot.props!.code}${aliases(slot.props!.code)};`,
`export type ${context} = ${atConstraints(`${slot.props!.code}${aliases(slot.props!.code)}`, typeParameters)};`,
"",
]
: []),
Expand Down
74 changes: 64 additions & 10 deletions packages/target-angular/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
addTypeImports,
angularDialect,
codeNames,
codeKind,
Expand All @@ -17,6 +18,7 @@ import {
scopeRoots,
sourceNames,
typeDeclarationCode,
typeExportsCode,
typeNames,
} from "@unframework/codegen";
import type {
Expand All @@ -27,7 +29,7 @@ import type {
Target,
} from "@unframework/codegen";
import { codeOf, functionsOf, walk } from "@unframework/ir";
import type { Span, UfComponent, UfModule } from "@unframework/ir";
import type { Span, TypeText, UfComponent, UfModule } from "@unframework/ir";

import {
firstUse,
Expand Down Expand Up @@ -402,11 +404,13 @@ function emitComponent(component: UfComponent, context: EmitContext): OutputFile
{ accessibility: "protected", readonly: true },
),
];
const declaration = js.classDeclaration(className, [], [...members, ...(hostMember ?? [])], {
implements: interfaces,
});
declaration.typeParameters = typeParameters(component, placeholders);
const statements = exportDeclaration(
className,
js.classDeclaration(className, [], [...members, ...(hostMember ?? [])], {
implements: interfaces,
}),
declaration,
exportsOf(component.name, module.exports),
);
const types = declaredTypes(closure, typeRoots(component, module));
Expand Down Expand Up @@ -438,17 +442,30 @@ function emitComponent(component: UfComponent, context: EmitContext): OutputFile
...(refObject === undefined ? [] : [refObjectCode(refObject)]),
...[...models.helpers, ...models.support].map(({ code }) => `${code}\n`),
...composition.slots.map((slot) =>
slotDirectiveCode(component.name, slot, imports.add("@angular/core", "Directive")),
slotDirectiveCode(
component.name,
slot,
imports.add("@angular/core", "Directive"),
component.typeParameters,
),
),
...(scope ? [scopeDirectiveCode(scope, imports)] : []),
];
if (models.helpers.some(({ code }) => code.includes("WritableSignal"))) {
imports.add("@angular/core", "WritableSignal", { type: true });
}
// The types it imports, by name, from where the source imports them, a child's from its
// output (`./field`), and in the main component's file the module's type exports, in source
// order among the types it declares (ADR-0067, ADR-0068).
addTypeImports(component, module, imports, kebabCase);
const typeCode = [
...types.map((type) => ({ code: typeDeclarationCode(type), span: type.span })),
...typeExportsCode(component, module, kebabCase),
].toSorted((a, b) => a.span.start - b.span.start);
const contents = placeholders.print(() =>
[
printProgram(js.program(imports.toDeclarations())),
...types.map((declaration) => `${typeDeclarationCode(declaration)}\n`),
...typeCode.map(({ code }) => `${code}\n`),
...(nextId === undefined ? [] : [`let ${nextId} = 0;\n`]),
...declarations,
...directives.map(
Expand Down Expand Up @@ -664,17 +681,29 @@ function providers(
}

/**
* The names of the types the output reads: its inputs' and outputs' types, and every name the
* setup's code, annotations and functions read (`ref<Item[]>`, `as Item`, `(item: Item)`). The
* props type itself is never printed: an input is typed by its member.
* The names of the types the output reads: its inputs' and outputs' types, its type parameters'
* constraints and defaults, and every name the setup's code, annotations and functions read
* (`ref<Item[]>`, `as Item`, `(item: Item)`). An input is typed by its member, so the props type
* itself is printed only where another declaration declares a member, which an input reads from
* it by indexed access (`propType`).
*/
function typeRoots(component: UfComponent, module: UfModule): Set<string> {
const names = new Set<string>();
const add = (found: Iterable<string>) => {
for (const name of found) names.add(name);
};
const declared = new Set(module.types.map(({ name }) => name));
for (const prop of component.props) add(typeNames(prop.type));
for (const prop of component.props) {
add(
typeNames(
prop.declaredBy && component.propsParameter ? component.propsParameter.type : prop.type,
),
);
}
for (const parameter of component.typeParameters ?? []) {
if (parameter.constraint) add(typeNames(parameter.constraint));
if (parameter.default) add(typeNames(parameter.default));
}
for (const event of component.emits?.events ?? []) {
for (const parameter of event.parameters) add(typeNames(parameter.type));
}
Expand All @@ -689,6 +718,31 @@ function typeRoots(component: UfComponent, module: UfModule): Set<string> {
return new Set([...names].filter((name) => declared.has(name)));
}

/**
* A generic component's type parameters, `class List<T extends Item = Item>` (ADR-0068): a
* generic class, whose parameters ngtsc infers from a consumer's inputs, its constraints and
* defaults copied as written. `null` for a component without them.
*/
function typeParameters(
component: UfComponent,
placeholders: Placeholders,
): ReturnType<typeof js.classDeclaration>["typeParameters"] {
if (!component.typeParameters?.length) return null;
const type = (text: TypeText | undefined) => (text ? placeholders.type(text.code) : null);
const params = component.typeParameters.map((parameter) => ({
type: "TSTypeParameter" as const,
name: js.bindingIdentifier(parameter.name),
constraint: type(parameter.constraint),
default: type(parameter.default),
in: false,
out: false,
const: false,
start: 0,
end: 0,
}));
return { type: "TSTypeParameterDeclaration", params, start: 0, end: 0 };
}

/**
* A printed class with a blank line before each of its methods, as Angular code sets them apart
* from the fields and from each other (oxfmt keeps a blank line, and adds none). Read before the
Expand Down
27 changes: 17 additions & 10 deletions packages/target-angular/src/members.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
rewriteCode,
} from "@unframework/codegen";
import type { Placeholders, RewriteRules } from "@unframework/codegen";
import type { Code, Prop, TypeDeclaration } from "@unframework/ir";
import type { Code, Prop, TypeDeclaration, UfComponent } from "@unframework/ir";

import { keyOf } from "./composition.ts";
import type { SlotDirective } from "./composition.ts";
Expand All @@ -31,7 +31,7 @@ import { bindingById, formOf, slotQuery, unreachable } from "./plan.ts";
import type { Plan } from "./plan.ts";
import { outputType, tidied } from "./rules.ts";
import { parenthesised, valueType } from "./seeded.ts";
import { withoutUndefined } from "./types.ts";
import { defaultedType, propType } from "./types.ts";

type Member = ReturnType<typeof js.propertyDefinition> | ReturnType<typeof js.methodDefinition>;

Expand Down Expand Up @@ -102,7 +102,7 @@ export function classMembers(context: MemberContext): {
? ("protected" as const)
: ("private" as const);
const members: Member[] = component.props.map((prop) =>
inputMember(prop, core("input"), placeholders, context.declarations),
inputMember(prop, component, core("input"), placeholders, context.declarations),
);
if (context.fallthrough) {
// The consumer's `class` and `style`, which the root merges (ADR-0056): inputs, so a static
Expand Down Expand Up @@ -653,32 +653,36 @@ function statements(
}

/**
* A prop as a signal input, typed by its member's type as written:
* A prop as a signal input, typed by its member's type as written, or read from the props type
* where another declaration declares it (`propType`, ADR-0067):
* - required: `readonly label = input.required<string>();`
* - optional without a default: `readonly count = input<number>();`, whose value is
* `number | undefined` (Angular's own spelling of an optional input);
* - with a default: `readonly tone = input<Tone, Tone | undefined>("info", { transform })`, whose
* - with a default: `readonly tone = input<Tone, Tone | undefined>("info", { transform })`, or
* `input<Exclude<ButtonProps["tone"], undefined>, ButtonProps["tone"]>` by indexed access, whose
* transform turns `undefined` into the default. A destructured default applies to an absent
* prop and to an explicit `undefined` alike, while Angular's initial value covers only the
* first (`setInput(name, undefined)` sets `undefined`). `null` is a value, as in JavaScript,
* so the test is `=== undefined`, never `??` (ADR-0034).
*/
function inputMember(
prop: Prop,
component: UfComponent,
input: string,
placeholders: Placeholders,
declarations: readonly TypeDeclaration[],
): Member {
const callee = js.identifier(input);
const { type, default: initial } = prop;
const { default: initial } = prop;
const type = propType(prop, component);
if (!prop.optional) {
const required = js.memberExpression(callee, "required");
return readonlyField(prop, js.callExpression(required, [], [placeholders.type(type.code)]));
return readonlyField(prop, js.callExpression(required, [], [placeholders.type(type)]));
}
if (initial === undefined) {
return readonlyField(prop, js.callExpression(callee, [], [placeholders.type(type.code)]));
return readonlyField(prop, js.callExpression(callee, [], [placeholders.type(type)]));
}
const value = withoutUndefined(type.code, declarations);
const value = defaultedType(prop, component, declarations);
const transform = js.arrowFunction(
[js.bindingIdentifier("value")],
js.conditionalExpression(
Expand All @@ -694,7 +698,10 @@ function inputMember(
[placeholders.expression(initial.code), js.objectExpression([["transform", transform]])],
[
placeholders.type(value),
js.unionType([placeholders.type(value), js.keywordType("undefined")]),
// An indexed access of an optional member admits `undefined` already.
prop.declaredBy && type !== prop.type.code
? placeholders.type(type)
: js.unionType([placeholders.type(value), js.keywordType("undefined")]),
],
),
);
Expand Down
25 changes: 23 additions & 2 deletions packages/target-angular/src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,17 @@ import {
bindingForms,
mergesWholeStyle,
rewriteExpression,
typeNames,
} from "@unframework/codegen";
import type { PrintedAttribute } from "@unframework/codegen";
import type { Attribute, ElementNode, EventAttribute, ModelAttribute } from "@unframework/ir";
import type {
Attribute,
BindingId,
ElementNode,
EventAttribute,
ModelAttribute,
UfComponent,
} from "@unframework/ir";

import { bindingById } from "./plan.ts";
import type { Plan } from "./plan.ts";
Expand Down Expand Up @@ -259,9 +267,13 @@ export function planModels(plan: Plan): Models {
// reads it from; in the browser it stays unset, as Vue leaves it.
const multiple = model.control === "select-multiple";
const name = field();
// The chosen option's value is one of the options, which a generic component's consumer
// types: a state of its type parameter (`defineModel<T>`) takes it as `T`, through the
// template's own cast, since a template writes no `as` (ADR-0068).
const chosen = multiple ? `${helper("selectedValues")}(${name})` : `${name}.value`;
listen(
"change",
`${signal}.set(${multiple ? `${helper("selectedValues")}(${name})` : `${name}.value`})`,
`${signal}.set(${generic(plan.component, reference.binding) ? `$any(${chosen})` : chosen})`,
);
server ??= claimVariable(plan, "onServer");
if (variable !== undefined) printed.unshift({ name: `#${variable}`, text: `#${variable}` });
Expand Down Expand Up @@ -365,6 +377,15 @@ function optionValue(
return `${optionText()}(${['""', ...parts].join(" + ")})`;
}

/** Whether a binding's declared type names a type parameter of its component (ADR-0068). */
function generic(component: UfComponent, binding: BindingId): boolean {
const parameters = new Set(component.typeParameters?.map(({ name }) => name));
if (!parameters.size) return false;
const item = component.setup.find((each) => "binding" in each && each.binding === binding);
const type = item && "type" in item ? item.type : undefined;
return type !== undefined && [...typeNames(type)].some((name) => parameters.has(name));
}

/** A template variable's name, free beside the class's members and the lists' variables. */
function claimVariable(plan: Plan, base: string): string {
let name = plan.members.claim(base);
Expand Down
13 changes: 7 additions & 6 deletions packages/target-angular/src/seeded.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
// `note ?? "none"`, `a ? "x" : "y"`), `.length`, the string, number and array methods whose
// result the receiver fixes (`title.trim()`), the pure globals' (`Math.max(…)`), and reads of a
// prop or of a member whose own type is known so. Otherwise none, and the class types the field
// by a method that computes the initial value (./members.ts). There is no type oracle before M5
// (plan §9).
// by a method that computes the initial value (./members.ts). A target reads no type oracle:
// shapes reach the analyser alone (ADR-0067).
import { parseExpression } from "@unframework/codegen";
import type { BindingId, Code, TypeDeclaration } from "@unframework/ir";

import { bindingById } from "./plan.ts";
import type { Plan } from "./plan.ts";
import { arrayElement, withoutNullish, withoutUndefined } from "./types.ts";
import { arrayElement, defaultedType, propType, withoutNullish } from "./types.ts";

interface Node {
type: string;
Expand Down Expand Up @@ -334,11 +334,12 @@ function bindingType(
if (binding.kind === "prop") {
const prop = component.props.find((each) => each.binding === id);
if (prop === undefined) return undefined;
if (!prop.optional) return prop.type.code;
const type = propType(prop, component);
if (!prop.optional) return type;
// The input's value: a default leaves `undefined` out (./members.ts `inputMember`).
return prop.default === undefined
? `${parenthesised(prop.type.code)} | undefined`
: withoutUndefined(prop.type.code, declarations);
? `${parenthesised(type)} | undefined`
: defaultedType(prop, component, declarations);
}
const item = component.setup.find((each) => "binding" in each && each.binding === id);
switch (item?.kind) {
Expand Down
Loading
Loading