diff --git a/packages/compiler/package.json b/packages/compiler/package.json index 701006425..e63b2d721 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -40,6 +40,7 @@ "license": "MIT", "sideEffects": [], "dependencies": { - "@typed/core": "workspace:*" + "@typed/core": "workspace:*", + "effect": "^3.2.7" } } diff --git a/packages/compiler/src/Compiler.ts b/packages/compiler/src/Compiler.ts index c2033d6fd..04e7c3ded 100644 --- a/packages/compiler/src/Compiler.ts +++ b/packages/compiler/src/Compiler.ts @@ -21,11 +21,31 @@ import { Service } from "./typescript/Service.js" export class Compiler { private _cmdLine: ts.ParsedCommandLine private _service: Service = new Service() + private primitives: { + string: ts.Type + number: ts.Type + boolean: ts.Type + bigint: ts.Type + null: ts.Type + undefined: ts.Type + void: ts.Type + } readonly project: Project + checker: ts.TypeChecker constructor(readonly directory: string, readonly tsConfig?: string) { this._cmdLine = findTsConfig(directory, tsConfig) this.project = this._service.openProject(this._cmdLine, this.enhanceLanguageServiceHost) + this.checker = this.project.typeChecker + this.primitives = { + string: this.checker.getStringType(), + number: this.checker.getNumberType(), + boolean: this.checker.getBooleanType(), + bigint: this.checker.getBigIntType(), + null: this.checker.getNullType(), + undefined: this.checker.getUndefinedType(), + void: this.checker.getVoidType() + } } parseTemplates(sourceFile: ts.SourceFile): ReadonlyArray { @@ -33,10 +53,11 @@ export class Compiler { getTaggedTemplateLiteralExpressions(sourceFile).forEach((expression) => { const tag = expression.tag.getText() + // Only parse html tagged templates if (tag === "html") { const literal = expression.template - const template = parseTemplateFromNode(literal) - templates.push({ literal, template }) + const [template, parts] = this.parseTemplateFromNode(literal) + templates.push({ literal, template, parts }) } }) @@ -45,6 +66,63 @@ export class Compiler { private enhanceLanguageServiceHost = (_host: ts.LanguageServiceHost): void => { } + + private parseTemplateFromNode(node: ts.TemplateLiteral): readonly [Template, ReadonlyArray] { + if (node.kind === ts.SyntaxKind.NoSubstitutionTemplateLiteral) { + return [parse([node.getText().slice(1, -1)]), []] + } else { + const [head, syntaxList] = node.getChildren() + const children = syntaxList.getChildren() + const lastChild = children[children.length - 1] + const parts: Array = [] + const spans = children.map((child, i) => { + if (child.kind === ts.SyntaxKind.TemplateSpan) { + const [part, literal] = child.getChildren() + parts.push(this.parsePart(part, i)) + const text = literal.getText() + if (child === lastChild) return text.slice(1, -1) + return text.slice(1) + } else { + throw new Error(`Unexpected syntax kind: ${ts.SyntaxKind[child.kind]}`) + } + }) + + return [parse([head.getText().slice(1, -2), ...spans]), parts] + } + } + + private parsePart(part: ts.Node, index: number): ParsedPart { + const type = this.project.getType(part) + return { + index, + kind: this.getPartType(type), + type + } + } + + private getPartType(type: ts.Type): ParsedPart["kind"] { + if (this.isPrimitiveType(type)) return "primitive" + + const properties = type.getProperties().map((p) => p.name) + + let isEffect = false + let isFx = false + for (const name of properties) { + if (name.startsWith("__@DirectiveTypeId")) return "directive" + if (name.startsWith("__@EffectTypeId")) isEffect = true + if (name.startsWith("__@FxTypeId")) isFx = true + } + + if (isFx && isEffect) return "fxEffect" + if (isFx) return "fx" + if (isEffect) return "effect" + + return "placeholder" + } + + private isPrimitiveType(type: ts.Type) { + return Object.values(this.primitives).some((t) => this.checker.isTypeAssignableTo(type, t)) + } } // Ensure that nested templates are handled first @@ -63,29 +141,14 @@ function getSpan(template: ParsedTemplate) { export interface ParsedTemplate { readonly literal: ts.TemplateLiteral + readonly parts: ReadonlyArray readonly template: Template } -function parseTemplateFromNode(node: ts.TemplateLiteral): Template { - if (node.kind === ts.SyntaxKind.NoSubstitutionTemplateLiteral) { - return parse([node.getText().slice(1, -1)]) - } else { - const [head, syntaxList] = node.getChildren() - const children = syntaxList.getChildren() - const lastChild = children[children.length - 1] - const parts = children.map((child) => { - if (child.kind === ts.SyntaxKind.TemplateSpan) { - const [, literal] = child.getChildren() - const text = literal.getText() - if (child === lastChild) return text.slice(1, -1) - return text.slice(1) - } else { - throw new Error(`Unexpected syntax kind: ${ts.SyntaxKind[child.kind]}`) - } - }) - - return parse([head.getText().slice(1, -2), ...parts]) - } +export interface ParsedPart { + readonly index: number + readonly kind: "placeholder" | "fxEffect" | "fx" | "effect" | "primitive" | "directive" + readonly type: ts.Type } function getTaggedTemplateLiteralExpressions(node: ts.SourceFile) { diff --git a/packages/compiler/src/typescript/Project.ts b/packages/compiler/src/typescript/Project.ts index a1bbd9c2d..62fe7acea 100644 --- a/packages/compiler/src/typescript/Project.ts +++ b/packages/compiler/src/typescript/Project.ts @@ -12,6 +12,8 @@ export class Project { private languageService: ts.LanguageService private program: ts.Program + readonly typeChecker: ts.TypeChecker + constructor( documentRegistry: ts.DocumentRegistry, diagnosticWriter: DiagnosticWriter, @@ -98,6 +100,7 @@ export class Project { documentRegistry ) this.program = this.languageService.getProgram()! + this.typeChecker = this.program.getTypeChecker() } addFile(filePath: string) { @@ -106,6 +109,14 @@ export class Project { return this.program.getSourceFile(filePath)! } + getType(node: ts.Node): ts.Type { + return this.typeChecker.getTypeAtLocation(node) + } + + getSymbol(node: ts.Node): ts.Symbol | undefined { + return this.typeChecker.getSymbolAtLocation(node) + } + getCommandLine(): ts.ParsedCommandLine { return this.cmdLine } diff --git a/packages/compiler/test/fixtures/div-with-interpolated-bigint.ts b/packages/compiler/test/fixtures/div-with-interpolated-bigint.ts new file mode 100644 index 000000000..9edf75f60 --- /dev/null +++ b/packages/compiler/test/fixtures/div-with-interpolated-bigint.ts @@ -0,0 +1,3 @@ +import { html } from "@typed/core" + +export const render = html`
${42n}
` diff --git a/packages/compiler/test/fixtures/div-with-interpolated-directive.ts b/packages/compiler/test/fixtures/div-with-interpolated-directive.ts new file mode 100644 index 000000000..30d8431d8 --- /dev/null +++ b/packages/compiler/test/fixtures/div-with-interpolated-directive.ts @@ -0,0 +1,3 @@ +import { Directive, html } from "@typed/core" + +export const render = html`
${Directive.node((part) => part.update("Hello World"))}
` diff --git a/packages/compiler/test/fixtures/div-with-interpolated-effect.ts b/packages/compiler/test/fixtures/div-with-interpolated-effect.ts new file mode 100644 index 000000000..e9dd6d8b1 --- /dev/null +++ b/packages/compiler/test/fixtures/div-with-interpolated-effect.ts @@ -0,0 +1,4 @@ +import { html } from "@typed/core" +import { Effect } from "effect" + +export const render = html`
${Effect.succeed(42n)}
` diff --git a/packages/compiler/test/fixtures/div-with-interpolated-fx.ts b/packages/compiler/test/fixtures/div-with-interpolated-fx.ts new file mode 100644 index 000000000..03360f3e1 --- /dev/null +++ b/packages/compiler/test/fixtures/div-with-interpolated-fx.ts @@ -0,0 +1,3 @@ +import { Fx, html } from "@typed/core" + +export const render = html`
${Fx.succeed(42n)}
` diff --git a/packages/compiler/test/fixtures/div-with-interpolated-null.ts b/packages/compiler/test/fixtures/div-with-interpolated-null.ts new file mode 100644 index 000000000..f12117c39 --- /dev/null +++ b/packages/compiler/test/fixtures/div-with-interpolated-null.ts @@ -0,0 +1,3 @@ +import { html } from "@typed/core" + +export const render = html`
${null}
` diff --git a/packages/compiler/test/fixtures/div-with-interpolated-number.ts b/packages/compiler/test/fixtures/div-with-interpolated-number.ts new file mode 100644 index 000000000..8bf9d03c6 --- /dev/null +++ b/packages/compiler/test/fixtures/div-with-interpolated-number.ts @@ -0,0 +1,3 @@ +import { html } from "@typed/core" + +export const render = html`
${42}
` diff --git a/packages/compiler/test/fixtures/div-with-interpolated-refsubject.ts b/packages/compiler/test/fixtures/div-with-interpolated-refsubject.ts new file mode 100644 index 000000000..4a4052811 --- /dev/null +++ b/packages/compiler/test/fixtures/div-with-interpolated-refsubject.ts @@ -0,0 +1,5 @@ +import { html, RefSubject } from "@typed/core" + +const ref = RefSubject.tagged()("ref") + +export const render = html`
${ref}
` diff --git a/packages/compiler/test/fixtures/div-with-interpolated-undefined.ts b/packages/compiler/test/fixtures/div-with-interpolated-undefined.ts new file mode 100644 index 000000000..e26c21edd --- /dev/null +++ b/packages/compiler/test/fixtures/div-with-interpolated-undefined.ts @@ -0,0 +1,3 @@ +import { html } from "@typed/core" + +export const render = html`
${undefined}
` diff --git a/packages/compiler/test/fixtures/div-with-interpolated-void.ts b/packages/compiler/test/fixtures/div-with-interpolated-void.ts new file mode 100644 index 000000000..a3138231c --- /dev/null +++ b/packages/compiler/test/fixtures/div-with-interpolated-void.ts @@ -0,0 +1,4 @@ +import { html } from "@typed/core" + +const value: void = void 0 +export const render = html`
${value}
` diff --git a/packages/compiler/test/index.ts b/packages/compiler/test/index.ts index 0c9a1d2ae..1981e04d2 100644 --- a/packages/compiler/test/index.ts +++ b/packages/compiler/test/index.ts @@ -33,6 +33,7 @@ describe("Compiler", () => { const expected = new Template([new ElementNode("div", [], [new TextNode("Hello World")])], ``, []) equalTemplates(div.template, expected) + expect(div.parts).toEqual([]) }) it("
with interpolated text", () => { @@ -43,6 +44,106 @@ describe("Compiler", () => { const expected = new Template([new ElementNode("div", [], [nodePart])], ``, [[nodePart, Chunk.of(0)]]) equalTemplates(div.template, expected) + equalParts(div.parts, { index: 0, kind: "primitive" }) + }) + + it("
with interpolated bigint", () => { + const templates = compiler.parseTemplates(files["div-with-interpolated-bigint.ts"]) + expect(templates).toHaveLength(1) + const [div] = templates + const nodePart = new NodePart(0) + const expected = new Template([new ElementNode("div", [], [nodePart])], ``, [[nodePart, Chunk.of(0)]]) + + equalTemplates(div.template, expected) + equalParts(div.parts, { index: 0, kind: "primitive" }) + }) + + it("
with interpolated null", () => { + const templates = compiler.parseTemplates(files["div-with-interpolated-null.ts"]) + expect(templates).toHaveLength(1) + const [div] = templates + const nodePart = new NodePart(0) + const expected = new Template([new ElementNode("div", [], [nodePart])], ``, [[nodePart, Chunk.of(0)]]) + + equalTemplates(div.template, expected) + equalParts(div.parts, { index: 0, kind: "primitive" }) + }) + + it("
with interpolated number", () => { + const templates = compiler.parseTemplates(files["div-with-interpolated-number.ts"]) + expect(templates).toHaveLength(1) + const [div] = templates + const nodePart = new NodePart(0) + const expected = new Template([new ElementNode("div", [], [nodePart])], ``, [[nodePart, Chunk.of(0)]]) + + equalTemplates(div.template, expected) + equalParts(div.parts, { index: 0, kind: "primitive" }) + }) + + it("
with interpolated undefined", () => { + const templates = compiler.parseTemplates(files["div-with-interpolated-undefined.ts"]) + expect(templates).toHaveLength(1) + const [div] = templates + const nodePart = new NodePart(0) + const expected = new Template([new ElementNode("div", [], [nodePart])], ``, [[nodePart, Chunk.of(0)]]) + + equalTemplates(div.template, expected) + equalParts(div.parts, { index: 0, kind: "primitive" }) + }) + + it("
with interpolated void", () => { + const templates = compiler.parseTemplates(files["div-with-interpolated-void.ts"]) + expect(templates).toHaveLength(1) + const [div] = templates + const nodePart = new NodePart(0) + const expected = new Template([new ElementNode("div", [], [nodePart])], ``, [[nodePart, Chunk.of(0)]]) + + equalTemplates(div.template, expected) + equalParts(div.parts, { index: 0, kind: "primitive" }) + }) + + it("
with interpolated effect", () => { + const templates = compiler.parseTemplates(files["div-with-interpolated-effect.ts"]) + expect(templates).toHaveLength(1) + const [div] = templates + const nodePart = new NodePart(0) + const expected = new Template([new ElementNode("div", [], [nodePart])], ``, [[nodePart, Chunk.of(0)]]) + + equalTemplates(div.template, expected) + equalParts(div.parts, { index: 0, kind: "effect" }) + }) + + it("
with interpolated fx", () => { + const templates = compiler.parseTemplates(files["div-with-interpolated-fx.ts"]) + expect(templates).toHaveLength(1) + const [div] = templates + const nodePart = new NodePart(0) + const expected = new Template([new ElementNode("div", [], [nodePart])], ``, [[nodePart, Chunk.of(0)]]) + + equalTemplates(div.template, expected) + equalParts(div.parts, { index: 0, kind: "fx" }) + }) + + it("
with interpolated RefSubject", () => { + const templates = compiler.parseTemplates(files["div-with-interpolated-refsubject.ts"]) + expect(templates).toHaveLength(1) + const [div] = templates + const nodePart = new NodePart(0) + const expected = new Template([new ElementNode("div", [], [nodePart])], ``, [[nodePart, Chunk.of(0)]]) + + equalTemplates(div.template, expected) + equalParts(div.parts, { index: 0, kind: "fxEffect" }) + }) + + it("
with interpolated Directive", () => { + const templates = compiler.parseTemplates(files["div-with-interpolated-directive.ts"]) + expect(templates).toHaveLength(1) + const [div] = templates + const nodePart = new NodePart(0) + const expected = new Template([new ElementNode("div", [], [nodePart])], ``, [[nodePart, Chunk.of(0)]]) + + equalTemplates(div.template, expected) + equalParts(div.parts, { index: 0, kind: "directive" }) }) it("nested template", () => { @@ -64,3 +165,10 @@ function equalTemplates(actual: Template, expected: Template) { expect(actual.nodes).toEqual(expected.nodes) expect(actual.parts).toEqual(expected.parts) } + +function equalParts(actual: ReadonlyArray<_.ParsedPart>, ...expected: ReadonlyArray>) { + actual.forEach((p, i) => { + expect(p.index).toEqual(expected[i].index) + expect(p.kind).toEqual(expected[i].kind) + }) +} diff --git a/packages/fx/src/Form.ts b/packages/fx/src/Form.ts index 07b596df3..b85a49eb3 100644 --- a/packages/fx/src/Form.ts +++ b/packages/fx/src/Form.ts @@ -17,7 +17,7 @@ import { FxEffectBase } from "./internal/protos.js" import { hold } from "./internal/share.js" import * as RefSubject from "./RefSubject.js" import type * as Sink from "./Sink.js" -import { RefSubjectTypeId, TypeId } from "./TypeId.js" +import { FxTypeId, RefSubjectTypeId } from "./TypeId.js" import type * as Versioned from "./Versioned.js" /** @@ -484,6 +484,6 @@ const propOf = ( | Effect.Effect, key: keyof O ) => { - if (TypeId in input) return core.map(input, (o) => o[key]) + if (FxTypeId in input) return core.map(input, (o) => o[key]) else return Effect.map(input, (o) => o[key]) } diff --git a/packages/fx/src/Fx.ts b/packages/fx/src/Fx.ts index fb3ca5f9e..68ea62531 100644 --- a/packages/fx/src/Fx.ts +++ b/packages/fx/src/Fx.ts @@ -41,14 +41,14 @@ import * as coreWithKey from "./internal/withKey.js" import { type RefSubject, transform } from "./RefSubject.js" import * as Sink from "./Sink.js" import type * as Subject from "./Subject.js" -import { TypeId } from "./TypeId.js" +import { FxTypeId } from "./TypeId.js" /** * Fx is a push-based reactive primitive built atop of Effect. * @since 1.20.0 */ export interface Fx extends Pipeable.Pipeable { - readonly [TypeId]: Fx.Variance + readonly [FxTypeId]: Fx.Variance /** * @since 1.20.0 @@ -119,7 +119,7 @@ export const unify = >(fx: T): Unify => fx as any * @since 1.20.0 */ export function isFx(u: unknown): u is Fx { - return u === null ? false : hasProperty(u, TypeId) + return u === null ? false : hasProperty(u, FxTypeId) } /** diff --git a/packages/fx/src/RefSubject.ts b/packages/fx/src/RefSubject.ts index e25b4d52e..983727ff6 100644 --- a/packages/fx/src/RefSubject.ts +++ b/packages/fx/src/RefSubject.ts @@ -32,7 +32,7 @@ import { hold } from "./internal/share.js" import type { UnionToTuple } from "./internal/UnionToTuple.js" import * as Sink from "./Sink.js" import * as Subject from "./Subject.js" -import { ComputedTypeId, FilteredTypeId, RefSubjectTypeId, TypeId } from "./TypeId.js" +import { ComputedTypeId, FilteredTypeId, FxTypeId, RefSubjectTypeId } from "./TypeId.js" import * as Versioned from "./Versioned.js" const UNBOUNDED = { concurrency: "unbounded" } as const @@ -332,7 +332,7 @@ export const make: { options?: RefSubjectOptions ): Effect.Effect { if (RefSubjectTypeId in fxOrEffect) return fromRefSubject(fxOrEffect as RefSubject, options) - else if (TypeId in fxOrEffect) return fromFx(fxOrEffect, options) + else if (FxTypeId in fxOrEffect) return fromFx(fxOrEffect, options) else return fromEffect(fxOrEffect, options) } diff --git a/packages/fx/src/Subject.ts b/packages/fx/src/Subject.ts index c22bb2dc6..63ddd0412 100644 --- a/packages/fx/src/Subject.ts +++ b/packages/fx/src/Subject.ts @@ -22,7 +22,7 @@ import { awaitScopeClose, RingBuffer, withScope } from "./internal/helpers.js" import { FxBase } from "./internal/protos.js" import type { Push } from "./Push.js" import type { Sink } from "./Sink.js" -import { TypeId } from "./TypeId.js" +import { FxTypeId } from "./TypeId.js" /** * Subject is an Fx type which can also be imperatively pushed into. @@ -307,7 +307,7 @@ export function tagged(): { } const isDataFirst = (args: IArguments): boolean => - args.length === 2 || Effect.isEffect(args[0]) || hasProperty(args[0], TypeId) + args.length === 2 || Effect.isEffect(args[0]) || hasProperty(args[0], FxTypeId) class TaggedImpl extends FromTag, A, E, never> implements Subject.Tagged { readonly provide: Subject.Tagged["provide"] @@ -318,7 +318,7 @@ class TaggedImpl extends FromTag, A, E, never> impleme this.provide = dual( isDataFirst, (fxOrEffect: Fx | Effect.Effect, replay?: number) => { - if (TypeId in fxOrEffect) return provide(fxOrEffect as Fx>, this.make(replay)) + if (FxTypeId in fxOrEffect) return provide(fxOrEffect as Fx>, this.make(replay)) else return Effect.provide(fxOrEffect as Effect.Effect, this.make(replay)) } ) diff --git a/packages/fx/src/TypeId.ts b/packages/fx/src/TypeId.ts index b53061a82..ff4d8a76d 100644 --- a/packages/fx/src/TypeId.ts +++ b/packages/fx/src/TypeId.ts @@ -2,13 +2,13 @@ * @since 1.18.0 * @category symbols */ -export const TypeId = Symbol.for("@typed/fx/Fx") +export const FxTypeId = Symbol.for("@typed/fx/Fx") /** * @since 1.18.0 * @category symbols */ -export type TypeId = typeof TypeId +export type FxTypeId = typeof FxTypeId /** * @since 1.18.0 diff --git a/packages/fx/src/internal/protos.ts b/packages/fx/src/internal/protos.ts index 45e41867e..32478bfd3 100644 --- a/packages/fx/src/internal/protos.ts +++ b/packages/fx/src/internal/protos.ts @@ -4,7 +4,7 @@ import { identity } from "effect/Function" import { pipeArguments } from "effect/Pipeable" import type { Fx } from "../Fx.js" import type { Sink } from "../Sink.js" -import { TypeId } from "../TypeId.js" +import { FxTypeId } from "../TypeId.js" const Variance: Fx.Variance = { _R: identity, @@ -13,7 +13,7 @@ const Variance: Fx.Variance = { } export abstract class FxBase implements Fx { - readonly [TypeId]: Fx.Variance = Variance + readonly [FxTypeId]: Fx.Variance = Variance abstract run(sink: Sink): Effect.Effect @@ -25,7 +25,7 @@ export abstract class FxBase implements Fx { export abstract class FxEffectBase extends Effectable.StructuralClass implements Fx, Effect.Effect { - readonly [TypeId]: Fx.Variance = Variance + readonly [FxTypeId]: Fx.Variance = Variance abstract run(sink: Sink): Effect.Effect diff --git a/packages/template/src/Html.ts b/packages/template/src/Html.ts index bd705b3ca..13a216699 100644 --- a/packages/template/src/Html.ts +++ b/packages/template/src/Html.ts @@ -5,7 +5,7 @@ import type { CurrentEnvironment } from "@typed/environment" import * as Fx from "@typed/fx/Fx" import * as Sink from "@typed/fx/Sink" -import { TypeId } from "@typed/fx/TypeId" +import { FxTypeId } from "@typed/fx/TypeId" import { join } from "effect/Array" import * as Effect from "effect/Effect" import * as Layer from "effect/Layer" @@ -380,7 +380,7 @@ function unwrapRenderable( return Fx.fromFxEffect( Effect.map(renderable as any, unwrapRenderable) ) - } else if (TypeId in renderable) { + } else if (FxTypeId in renderable) { return renderable as any } else return Fx.succeed(renderable as any) } diff --git a/packages/template/src/internal/v2/render.ts b/packages/template/src/internal/v2/render.ts index 750bcf492..bf01e0622 100644 --- a/packages/template/src/internal/v2/render.ts +++ b/packages/template/src/internal/v2/render.ts @@ -501,7 +501,7 @@ function unwrapRenderable(renderable: unknown): Fx.Fx { ? Fx.succeed(null) // TODO: We need to ensure the ordering of these values in server environments : Fx.map(Fx.tuple(renderable.map(unwrapRenderable)), (xs) => xs.flat()) as any - } else if (Fx.TypeId in renderable) { + } else if (Fx.FxTypeId in renderable) { return renderable as any } else if (Effect.EffectTypeId in renderable) { return Fx.fromFxEffect(Effect.map(renderable as any, unwrapRenderable)) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c372c9664..9b13ccf85 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -543,6 +543,9 @@ importers: '@typed/core': specifier: workspace:* version: link:../core/dist + effect: + specifier: ^3.2.7 + version: 3.2.7 publishDirectory: dist packages/context: