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
11 changes: 11 additions & 0 deletions packages/target-solid/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,17 @@ export default function Counter(
keeps its own name (`card.title`). A prop no expression reads takes no default, and a props object nothing
reads is `_props` (an object form's name that is `_` and more is kept): unused bindings fail
L5.
- The source's `import type` names the output reads, by name (ADR-0067): 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 other type exports, an exported type no component reaches and a
re-export (`export type { Tone } from "./types"`), among the copied types in source order
(ADR-0068).
- A generic component is a generic function, `function List<T>(props: ListProps<T>)`, whose
consumers' JSX infers `T` (ADR-0068). The interfaces the output declares for its events, models
and slots take the type parameters their members read (`PickerSlots<T>`), so a scoped slot's
fill is typed with the consumer's `T`, and a `<select>` bound to a model of `T` writes the
chosen value as `T`.
- Expressions exactly as the source writes them, with each prop read through `props`.
Conditionals are Solid's control flow, which test truthiness as the IR does (`0` renders
nothing): `<Show when={c} fallback={…}>`, `<Show when={!c}>` when only the else branch
Expand Down
25 changes: 12 additions & 13 deletions packages/target-solid/src/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import type {
UfModule,
} from "@unframework/ir";

import { generatedInterface } from "./generics.ts";
import type { GeneratedInterface } from "./generics.ts";
import { eventProp } from "./props.ts";

/** What a component's composition adds to its output. */
Expand All @@ -40,6 +42,8 @@ export class SolidComposition {
readonly #imports: ImportSet;
/** The interfaces the props parameter takes beside its own type: the models', the slots'. */
readonly types: TypeDeclaration[] = [];
/** How the props parameter names each of `types`: `PickerSlots<T>` (src/generics.ts). */
readonly #references: string[] = [];
/** The inline type the props parameter takes for fallthrough and `ref`, or none. */
readonly inline: string | undefined;
/**
Expand Down Expand Up @@ -86,7 +90,7 @@ export class SolidComposition {
` ${eventProp(`${model.name}Change`)}?: (value: ${type}) => void;`,
];
});
this.types.push(declaration(name, members, model0Span(models)));
this.#declare(generatedInterface(component, name, members, model0Span(models)));
}
const { slots } = component;
if (slots) {
Expand All @@ -98,7 +102,7 @@ export class SolidComposition {
: `${jsx()}.Element`;
return ` ${propertyKey(prop)}${slot.optional ? "?" : ""}: ${type};`;
});
this.types.push(declaration(name, members, slots.span));
this.#declare(generatedInterface(component, name, members, slots.span));
}
this.fallthrough = fallthroughRoot(component);
this.scopeRoots = new Set(scopeRoots(component).map(({ start, end }) => `${start}:${end}`));
Expand Down Expand Up @@ -163,7 +167,12 @@ export class SolidComposition {

/** The type texts the props parameter takes beside the props type and the events. */
parameterTypes(): string[] {
return [...this.types.map((type) => type.name), ...(this.inline ? [this.inline] : [])];
return [...this.#references, ...(this.inline ? [this.inline] : [])];
}

#declare({ declaration, reference }: GeneratedInterface): void {
this.types.push(declaration);
this.#references.push(reference);
}

/** Whether the output reads the props object for composition. */
Expand Down Expand Up @@ -253,16 +262,6 @@ function model0Span(models: readonly ModelItem[]): Span {
return models[0]!.span;
}

/** An exported interface of members, which the props parameter's type takes. */
function declaration(name: string, members: readonly string[], span: Span): TypeDeclaration {
return {
name,
exported: true,
code: `interface ${name} {\n${members.join("\n")}\n}`,
span,
};
}

/** A property's key: as it is when it is an identifier, quoted otherwise. */
function propertyKey(name: string): string {
return /^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name);
Expand Down
139 changes: 139 additions & 0 deletions packages/target-solid/src/generics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Generic components as Solid writes them (ADR-0068): a generic function component,
// `function List<T>(props: ListProps<T>)`, whose consumers' JSX infers `T` from the props they
// pass. The interfaces the output declares beside the props type (its events', models' and
// slots') take the type parameters their members read, `PickerSlots<T>`, so a scoped slot's fill
// is typed with the consumer's `T` (`generic-slot-props`, native).
import { codeNames, js, typeNames } from "@unframework/codegen";
import type { Placeholders } from "@unframework/codegen";
import type {
BindingId,
ConstItem,
FunctionItem,
Span,
TypeDeclaration,
TypeParameter,
UfComponent,
} from "@unframework/ir";

type TypeParameterDeclaration = NonNullable<
ReturnType<typeof js.functionDeclaration>["typeParameters"]
>;

/**
* The component function's type parameters, `<T extends Item = Item>`, as the source writes
* them, or `null` for a component that is not generic.
*/
export function typeParameterDeclaration(
component: UfComponent,
placeholders: Placeholders,
): TypeParameterDeclaration | null {
if (!component.typeParameters?.length) return null;
return {
type: "TSTypeParameterDeclaration",
params: component.typeParameters.map((parameter) => ({
type: "TSTypeParameter",
name: js.bindingIdentifier(parameter.name),
constraint: parameter.constraint ? placeholders.type(parameter.constraint.code) : null,
default: parameter.default ? placeholders.type(parameter.default.code) : null,
in: false,
out: false,
const: false,
start: 0,
end: 0,
})),
start: 0,
end: 0,
};
}

/** An interface the output declares beside the props type, and how the props parameter names it. */
export interface GeneratedInterface {
declaration: TypeDeclaration;
/** `PickerSlots<T>`, or the bare name when its members read no type parameter. */
reference: string;
}

/**
* An exported interface of members, generic over the component's type parameters its members
* read, in their declared order: `export interface PickerSlots<T> { item?: …; }`.
*/
export function generatedInterface(
component: UfComponent,
name: string,
members: readonly string[],
span: Span,
): GeneratedInterface {
const read = typeNames(`{\n${members.join("\n")}\n}`);
// A parameter's constraint or default may name another (`T extends U, U extends V`), which the
// interface then declares too, and so on until no name is added.
for (let added = true; added;) {
added = false;
for (const parameter of component.typeParameters ?? []) {
if (!read.has(parameter.name)) continue;
for (const text of [parameter.constraint, parameter.default]) {
if (!text) continue;
for (const each of typeNames(text)) {
if (read.has(each)) continue;
read.add(each);
added = true;
}
}
}
}
const parameters = (component.typeParameters ?? []).filter((each) => read.has(each.name));
const declared = parameters.length ? `<${parameters.map(parameterText).join(", ")}>` : "";
const applied = parameters.length ? `<${parameters.map((each) => each.name).join(", ")}>` : "";
return {
declaration: {
name,
exported: true,
code: `interface ${name}${declared} {\n${members.join("\n")}\n}`,
span,
},
reference: `${name}${applied}`,
};
}

/**
* The type of the component's own model bound to `binding`, where it reads one of the
* component's type parameters (`defineModel<T>`), or none.
*/
export function genericModelType(component: UfComponent, binding: BindingId): string | undefined {
const model = component.setup.find((item) => item.kind === "Model" && item.binding === binding);
if (model?.kind !== "Model" || !model.type) return undefined;
const read = typeNames(model.type);
return component.typeParameters?.some(({ name }) => read.has(name)) ? model.type.code : undefined;
}

/**
* Whether a setup constant or function names one of the component's type parameters
* (`function label(item: T)`, `const none: T[] = []`): it stays inside the generic function,
* where `T` is in scope, never hoisted to the module.
*/
export function namesTypeParameter(
component: UfComponent,
item: ConstItem | FunctionItem,
): boolean {
if (!component.typeParameters?.length) return false;
const names = new Set<string>();
const add = (found: Iterable<string>) => {
for (const name of found) names.add(name);
};
if (item.kind === "Const") {
if (item.type) add(typeNames(item.type));
add(codeNames(item.value.code));
} else {
const fn = item.function;
for (const parameter of fn.parameters) if (parameter.type) add(typeNames(parameter.type));
if (fn.returnType) add(typeNames(fn.returnType));
add(codeNames(fn.body.code, fn.expression ? "expression" : "statements"));
}
return component.typeParameters.some(({ name }) => names.has(name));
}

/** `T extends Item = Item`, as written. */
function parameterText(parameter: TypeParameter): string {
const constraint = parameter.constraint ? ` extends ${parameter.constraint.code}` : "";
const fallback = parameter.default ? ` = ${parameter.default.code}` : "";
return `${parameter.name}${constraint}${fallback}`;
}
34 changes: 32 additions & 2 deletions packages/target-solid/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
addPassThroughImports,
addTypeImports,
componentTypes,
composableUse,
defineTarget,
Expand All @@ -14,11 +15,13 @@ import {
Placeholders,
printComponentModule,
sourceNames,
typeExportsCode,
} from "@unframework/codegen";
import type { EmitContext, JsxContext, OutputFile, Target } from "@unframework/codegen";
import type { UfComponent } from "@unframework/ir";
import type { TypeDeclaration, UfComponent, UfModule } from "@unframework/ir";

import { SolidComposition } from "./composition.ts";
import { typeParameterDeclaration } from "./generics.ts";
import { NEXT_TICK_HELPER, WATCHER_HELPER } from "./helpers.ts";
import { CLASS_HELPER, SCOPE_HELPER, solidJsx, STYLE_HELPER } from "./jsx.ts";
import { solidListeners } from "./listeners.ts";
Expand Down Expand Up @@ -181,6 +184,8 @@ export const solid: Target = defineTarget({
const imports = new ImportSet(scope);
// The imports that pass through, which the render reads as written (ADR-0064).
addPassThroughImports(component, context.module, imports);
// The types it imports, by name (ADR-0067): a child's from its output, `./Field`.
addTypeImports(component, context.module, imports, (name) => name);
const placeholders = new Placeholders();
const composition = new SolidComposition(component, context.module, imports);
const props = solidProps(component, imports, placeholders, {
Expand Down Expand Up @@ -225,6 +230,8 @@ export const solid: Target = defineTarget({
...(exposed ? [placeholders.statements(`${exposed}\n`)] : []),
js.returnStatement(rendered(component, code, narrowings.types, jsx)),
]);
// A generic component is a generic function, `function List<T>(…)` (ADR-0068).
fn.typeParameters = typeParameterDeclaration(component, placeholders);
const statements = exportDeclaration(
component.name,
fn,
Expand All @@ -243,7 +250,11 @@ export const solid: Target = defineTarget({
path: `${component.name}.tsx`,
contents: printComponentModule(
{
types: [...types, ...(props.events ? [props.events] : []), ...composition.types],
types: [
...moduleTypes(component, context.module, types),
...(props.events ? [props.events] : []),
...composition.types,
],
body: [...keys, ...setup.hoisted, ...statements],
helpers: [...helpers(), ...code.helpers()],
// Collected last: the helpers import what they call.
Expand Down Expand Up @@ -293,6 +304,25 @@ function rendered(
return wrapped;
}

/**
* The types an output declares, in source order: those it reaches, copied as written, and in
* the output of the module's main component the module's type exports (ADR-0068), each a
* statement of its own, `export type { Tone } from "./types";`, that the module prints as it is.
*/
function moduleTypes(
component: UfComponent,
module: UfModule,
types: readonly TypeDeclaration[],
): TypeDeclaration[] {
const exports = typeExportsCode(component, module, (name) => name).map(({ code, span }) => ({
name: "",
exported: false,
code,
span,
}));
return [...types, ...exports].toSorted((a, b) => a.span.start - b.span.start);
}

/**
* The source names an output may take for its own (ADR-0045): `onCleanup`, the name a watcher's
* or `watchEffect`'s cleanup parameter is given, which Solid's import of that name serves, and
Expand Down
14 changes: 11 additions & 3 deletions packages/target-solid/src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { js, jsxBinding, jsxExpression } from "@unframework/codegen";
import type { ImportSet, JsxContext } from "@unframework/codegen";
import type { ElementNode, ModelAttribute, UfComponent } from "@unframework/ir";

import { genericModelType } from "./generics.ts";
import { unmarkedOptions } from "./render.ts";
import type { SolidCode } from "./setup.ts";

Expand Down Expand Up @@ -173,10 +174,17 @@ export function modelAttribute(
return [prop("value", text(value())), prop("onInput", listener(set(target("value"))))];
case "textarea":
return [...textarea(value), prop("onInput", listener(set(target("value"))))];
case "select":
case "select": {
// Each option is `selected` where it holds the state's value (src/render.ts): Solid's
// server writes a `<select>`'s `value` as an attribute.
return [prop("onChange", listener(set(target("value")))), ...selection()];
// server writes a `<select>`'s `value` as an attribute. A model of a generic component's
// type parameter (`defineModel<T>`) takes the chosen option's value as `T`: it is one of
// the options, which the consumer's `T` types (ADR-0068).
const generic = genericModelType(context.component, ref.binding);
const chosen = generic
? js.asExpression(target("value"), context.placeholders.type(generic))
: target("value");
return [prop("onChange", listener(set(chosen))), ...selection()];
}
case "checkbox":
return [prop("checked", value()), prop("onChange", listener(set(target("checked"))))];
case "radio":
Expand Down
28 changes: 16 additions & 12 deletions packages/target-solid/src/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import type { ImportSet, Placeholders } from "@unframework/codegen";
import { codeOf, walk } from "@unframework/ir";
import type { Binding, BindingId, Prop, TypeDeclaration, UfComponent } from "@unframework/ir";

import { generatedInterface } from "./generics.ts";
import type { GeneratedInterface } from "./generics.ts";

// The ESTree node types, named through codegen's builders: a target imports only ir and codegen.
type Parameter = Parameters<typeof js.functionDeclaration>[1][number];
type Statement = Parameters<typeof js.program>[0][number];
Expand Down Expand Up @@ -63,13 +66,15 @@ export function solidProps(
composition: { types: readonly string[]; reads: boolean },
): SolidProps {
const parameter = component.propsParameter;
const events = eventsInterface(component, imports);
const generated = eventsInterface(component, imports);
const events = generated?.declaration;
const emitted = component.emits !== undefined && emitsAny(component);
// A component without props still takes the scope attributes of the styled parents that
// render it, as an object (ADR-0063).
const typeCode =
[parameter?.type.code, events?.name, ...composition.types].filter(Boolean).join(" & ") ||
"object";
[parameter?.type.code, generated?.reference, ...composition.types]
.filter(Boolean)
.join(" & ") || "object";
const type = () => placeholders.type(typeCode);
if (!parameter && !events && !composition.types.length && !composition.reads) {
return { parameters: [], statements: [], read: asWritten };
Expand Down Expand Up @@ -177,10 +182,14 @@ export function eventProp(event: string): string {
/**
* The interface of a component's events (ADR-0047): each event's listener an optional prop
* named by {@link eventProp}, taking the event's payload as its parameters, by their labels:
* `export interface CounterEvents { onChange?: (value: number) => void; }`. Its name is claimed
* from the file's scope (`CounterEvents`, or `CounterEvents_1` beside a source name).
* `export interface CounterEvents { onChange?: (value: number) => void; }`, generic over the type
* parameters its payloads read (src/generics.ts). Its name is claimed from the file's scope
* (`CounterEvents`, or `CounterEvents_1` beside a source name).
*/
function eventsInterface(component: UfComponent, imports: ImportSet): TypeDeclaration | undefined {
function eventsInterface(
component: UfComponent,
imports: ImportSet,
): GeneratedInterface | undefined {
const { emits } = component;
if (!emits) return undefined;
const name = imports.claim(`${component.name}Events`);
Expand All @@ -190,12 +199,7 @@ function eventsInterface(component: UfComponent, imports: ImportSet): TypeDeclar
.join(", ");
return ` ${eventProp(event.name)}?: (${parameters}) => void;`;
});
return {
name,
exported: true,
code: `interface ${name} {\n${members.join("\n")}\n}`,
span: emits.span,
};
return generatedInterface(component, name, members, emits.span);
}

/** Whether any code of a component emits one of its events. */
Expand Down
Loading
Loading