diff --git a/.vscode/launch.json b/.vscode/launch.json index 519f172cc..62566f601 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,10 +1,10 @@ { "version": "0.2.0", "configurations": [ - { + { // For this to work, make sure you're running `tsc --build --watch` at the root, AND // `pnpm bundle:watch` from within vscode directory. - "name": "Debug Extension (TS Plugin, .gts)", + "name": "Debug Extension (TS Plugin, .gts, ember-app)", "type": "extensionHost", "request": "launch", // "preLaunchTask": "npm: build", @@ -24,6 +24,29 @@ "${workspaceFolder}/packages/vscode/__fixtures__/ember-app" ] }, + { + // For this to work, make sure you're running `tsc --build --watch` at the root, AND + // `pnpm bundle:watch` from within vscode directory. + "name": "Debug Extension (TS Plugin, .gts, ts-template-imports-app)", + "type": "extensionHost", + "request": "launch", + // "preLaunchTask": "npm: build", + "autoAttachChildProcesses": true, + "runtimeExecutable": "${execPath}", + "outFiles": [ + "${workspaceFolder}/**/*.js", + "!**/node_modules/**" + ], + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}/packages/vscode", + // Disable v1 vscode glint + "--disable-extension", + "typed-ember.glint-vscode", + // comment to activate your local extensions + // "--disable-extensions", + "${workspaceFolder}/test-packages/ts-template-imports-app" + ] + }, { "name": "Attach to TS Server", "type": "node", diff --git a/bin/build-elements.mjs b/bin/build-elements.mjs index 6ea0bee62..40312b3f7 100644 --- a/bin/build-elements.mjs +++ b/bin/build-elements.mjs @@ -48,6 +48,16 @@ const htmlElementsMap = new Map([[GLOBAL_HTML_ATTRIBUTES_NAME, 'HTMLElement']]); const svgElementsMap = new Map([[GLOBAL_SVG_ATTRIBUTES_NAME, 'SVGElement']]); const mathmlElementsMap = new Map(); +const tagNameAttributesMap = []; + +function addTagNameAttributesMapEntry(name, interfaceName) { + if (name === 'GlobalHTMLAttributes' || name === 'GlobalSVGAttributes') { + return; + } + + tagNameAttributesMap.push(` ['${name}']: ${interfaceName};`); +} + traverse(ast, { TSInterfaceDeclaration: function (path) { if (path.node.id.name === 'HTMLElementTagNameMap') { @@ -123,6 +133,7 @@ interface GlintHtmlElementAttributesMap {\n`; }); } htmlElementsContent += '}\n'; + addTagNameAttributesMapEntry(name, interfaceName); } function addMapEntry(type) { @@ -193,6 +204,7 @@ interface GlintSvgElementAttributesMap {\n`; }); } svgElementsContent += `}\n`; + addTagNameAttributesMapEntry(name, interfaceName); } function addMapEntry(type) { @@ -232,5 +244,23 @@ const filePath = resolve( fileURLToPath(import.meta.url), '../../packages/template/-private/dsl/elements.d.ts', ); -const content = prefix + createHtmlElementsAttributesMap() + createSvgElementAttributesMap(); + +function defineTagNameAttributesMap(contents) { + return [ + '', + `global {`, + `/* These are not all the elements, but they are the ones with types of their own, beyond HTMLElement/SVGElement */`, + `interface GlintTagNameAttributesMap {`, + contents.join('\n'), + `}`, + `}`, + `\n`, + ].join('\n'); +} + +const content = + prefix + + createHtmlElementsAttributesMap() + + createSvgElementAttributesMap() + + defineTagNameAttributesMap(tagNameAttributesMap); writeFileSync(filePath, content); diff --git a/docs/glint-types.md b/docs/glint-types.md index 91a6b936b..449f93962 100644 --- a/docs/glint-types.md +++ b/docs/glint-types.md @@ -207,3 +207,36 @@ declare global { } } ``` + +### Custom Elements / WebComponent types + +To add custom elements, there are 3 global interfaces you must merge with to register the custom element: + - The Invocation Registry, `GlintCustomElements` -- this is how Glint translates element names in HTML into types. + - The Type Registry, `GlintElementRegistry` -- this is how Glint converts the name of your custom element to the actual element type + - The Attribute Registry, `GlintHtmlElementAttributesMap` -- this is how Glint type-checks your props and attributes passed to your custom element. + + + To specify all 3, it would look something like this: + +```ts +import '@glint/template'; + +import type { MyCustomElementClass, MyCustomElementProps } from './wherever.ts'; + +declare global { + interface GlintCustomElements { + 'my-custom-element-emit-element': MyCustomElement; + } + + interface GlintElementRegistry { + MyCustomElement: MyCustomElement; + } + + interface GlintHtmlElementAttributesMap { + MyCustomElement: { + propNum: number; + propStr: string; + }; + } +} +``` \ No newline at end of file diff --git a/package.json b/package.json index be95b9c78..0b2a03dde 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@babel/core": "^7.26.10", "@babel/parser": "^7.27.0", "@glimmer/component": "^2.0.0", + "@glint/ember-tsc": "workspace:*", "@glint/tsserver-plugin": "workspace:*", "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^5.42.1", diff --git a/packages/core/src/transform/template/template-to-typescript.ts b/packages/core/src/transform/template/template-to-typescript.ts index 2481658d0..94b6376ac 100644 --- a/packages/core/src/transform/template/template-to-typescript.ts +++ b/packages/core/src/transform/template/template-to-typescript.ts @@ -877,11 +877,11 @@ export function templateToTypescript( mapper.text('__glintDSL__.applyAttributes('); - // We map the `__glintY__.element` arg to the first attribute node, which has the effect + // We map the `__glintY__` arg to the first attribute node, which has the effect // such that diagnostics due to passing attributes to invalid elements will show up // on the attribute, rather than on the whole element. mapper.forNode(attr, () => { - mapper.text('__glintY__.element'); + mapper.text('__glintY__'); }); mapper.text(', {'); diff --git a/packages/template/-private/dsl/custom-elements.d.ts b/packages/template/-private/dsl/custom-elements.d.ts new file mode 100644 index 000000000..ce0b6f70c --- /dev/null +++ b/packages/template/-private/dsl/custom-elements.d.ts @@ -0,0 +1,45 @@ +declare global { + /** + * Map of element tag names to their type. Used by `emitElement` via `ElementForTagName`. + * + * By default, this interface is empty; to add custom elements, you can + * augment it in your own project like so: + * ```ts + * declare global { + * interface GlintCustomElementRegistry { + * 'my-custom-element': MyCustomElementClass; + * } + * } + * ``` + * + */ + interface GlintCustomElementMap { + /* intentionally empty, as there are no custom elements by default */ + } + /** + * Map of custom element class names to their attributes type. + * + * This is a separate interface, because there isn't a TypeScript mechanism + * to get the list of attributes and properties assignable to a given element type. + * + * You _could_ set loose values such as `typeof YourELement`, but then you'll have things + * that don't make sense to assign in the template, such as methods (toString, etc) + * + * ```ts + * declare global { + * interface GlintCustomElementAttributesMap { + * // ok, with caveats, easiest. + * 'MyCustomElementClass': typeof MyCustomElementClass; + * // better, but more verbose + * 'MyCustomElementClass': { + * propNum: number; + * propStr: string; + * // etc + * } + * } + * ``` + */ + interface GlintCustomElementAttributesMap { + /* intentionally empty, as there are no custom elements by default */ + } +} diff --git a/packages/template/-private/dsl/elements.d.ts b/packages/template/-private/dsl/elements.d.ts index 731db1d99..d73e306b5 100644 --- a/packages/template/-private/dsl/elements.d.ts +++ b/packages/template/-private/dsl/elements.d.ts @@ -4396,3 +4396,128 @@ interface GlintSvgElementAttributesMap { ['SVGElement']: GlobalSVGAttributes; } } + +global { +interface GlintTagNameAttributesMap { + ['a']: HTMLAnchorElementAttributes; + ['area']: HTMLAreaElementAttributes; + ['audio']: HTMLAudioElementAttributes; + ['base']: HTMLBaseElementAttributes; + ['blockquote']: HTMLQuoteElementAttributes; + ['br']: HTMLBRElementAttributes; + ['button']: HTMLButtonElementAttributes; + ['canvas']: HTMLCanvasElementAttributes; + ['caption']: HTMLTableCaptionElementAttributes; + ['col']: HTMLTableColElementAttributes; + ['data']: HTMLDataElementAttributes; + ['del']: HTMLModElementAttributes; + ['details']: HTMLDetailsElementAttributes; + ['dialog']: HTMLDialogElementAttributes; + ['div']: HTMLDivElementAttributes; + ['dl']: HTMLDListElementAttributes; + ['embed']: HTMLEmbedElementAttributes; + ['fieldset']: HTMLFieldSetElementAttributes; + ['form']: HTMLFormElementAttributes; + ['h1']: HTMLHeadingElementAttributes; + ['head']: HTMLHeadElementAttributes; + ['hr']: HTMLHRElementAttributes; + ['iframe']: HTMLIFrameElementAttributes; + ['img']: HTMLImageElementAttributes; + ['input']: HTMLInputElementAttributes; + ['label']: HTMLLabelElementAttributes; + ['legend']: HTMLLegendElementAttributes; + ['li']: HTMLLIElementAttributes; + ['link']: HTMLLinkElementAttributes; + ['map']: HTMLMapElementAttributes; + ['menu']: HTMLMenuElementAttributes; + ['meta']: HTMLMetaElementAttributes; + ['meter']: HTMLMeterElementAttributes; + ['object']: HTMLObjectElementAttributes; + ['ol']: HTMLOListElementAttributes; + ['optgroup']: HTMLOptGroupElementAttributes; + ['option']: HTMLOptionElementAttributes; + ['output']: HTMLOutputElementAttributes; + ['p']: HTMLParagraphElementAttributes; + ['pre']: HTMLPreElementAttributes; + ['progress']: HTMLProgressElementAttributes; + ['script']: HTMLScriptElementAttributes; + ['select']: HTMLSelectElementAttributes; + ['slot']: HTMLSlotElementAttributes; + ['source']: HTMLSourceElementAttributes; + ['style']: HTMLStyleElementAttributes; + ['table']: HTMLTableElementAttributes; + ['tbody']: HTMLTableSectionElementAttributes; + ['td']: HTMLTableCellElementAttributes; + ['template']: HTMLTemplateElementAttributes; + ['textarea']: HTMLTextAreaElementAttributes; + ['time']: HTMLTimeElementAttributes; + ['tr']: HTMLTableRowElementAttributes; + ['track']: HTMLTrackElementAttributes; + ['ul']: HTMLUListElementAttributes; + ['video']: HTMLVideoElementAttributes; + ['a']: SVGAElementAttributes; + ['animate']: SVGAnimateElementAttributes; + ['animateMotion']: SVGAnimateMotionElementAttributes; + ['animateTransform']: SVGAnimateTransformElementAttributes; + ['circle']: SVGCircleElementAttributes; + ['clipPath']: SVGClipPathElementAttributes; + ['defs']: SVGDefsElementAttributes; + ['desc']: SVGDescElementAttributes; + ['ellipse']: SVGEllipseElementAttributes; + ['feBlend']: SVGFEBlendElementAttributes; + ['feColorMatrix']: SVGFEColorMatrixElementAttributes; + ['feComponentTransfer']: SVGFEComponentTransferElementAttributes; + ['feComposite']: SVGFECompositeElementAttributes; + ['feConvolveMatrix']: SVGFEConvolveMatrixElementAttributes; + ['feDiffuseLighting']: SVGFEDiffuseLightingElementAttributes; + ['feDisplacementMap']: SVGFEDisplacementMapElementAttributes; + ['feDistantLight']: SVGFEDistantLightElementAttributes; + ['feDropShadow']: SVGFEDropShadowElementAttributes; + ['feFlood']: SVGFEFloodElementAttributes; + ['feFuncA']: SVGFEFuncAElementAttributes; + ['feFuncB']: SVGFEFuncBElementAttributes; + ['feFuncG']: SVGFEFuncGElementAttributes; + ['feFuncR']: SVGFEFuncRElementAttributes; + ['feGaussianBlur']: SVGFEGaussianBlurElementAttributes; + ['feImage']: SVGFEImageElementAttributes; + ['feMerge']: SVGFEMergeElementAttributes; + ['feMergeNode']: SVGFEMergeNodeElementAttributes; + ['feMorphology']: SVGFEMorphologyElementAttributes; + ['feOffset']: SVGFEOffsetElementAttributes; + ['fePointLight']: SVGFEPointLightElementAttributes; + ['feSpecularLighting']: SVGFESpecularLightingElementAttributes; + ['feSpotLight']: SVGFESpotLightElementAttributes; + ['feTile']: SVGFETileElementAttributes; + ['feTurbulence']: SVGFETurbulenceElementAttributes; + ['filter']: SVGFilterElementAttributes; + ['foreignObject']: SVGForeignObjectElementAttributes; + ['g']: SVGGElementAttributes; + ['image']: SVGImageElementAttributes; + ['line']: SVGLineElementAttributes; + ['linearGradient']: SVGLinearGradientElementAttributes; + ['marker']: SVGMarkerElementAttributes; + ['mask']: SVGMaskElementAttributes; + ['metadata']: SVGMetadataElementAttributes; + ['mpath']: SVGMPathElementAttributes; + ['path']: SVGPathElementAttributes; + ['pattern']: SVGPatternElementAttributes; + ['polygon']: SVGPolygonElementAttributes; + ['polyline']: SVGPolylineElementAttributes; + ['radialGradient']: SVGRadialGradientElementAttributes; + ['rect']: SVGRectElementAttributes; + ['script']: SVGScriptElementAttributes; + ['set']: SVGSetElementAttributes; + ['stop']: SVGStopElementAttributes; + ['style']: SVGStyleElementAttributes; + ['svg']: SVGSVGElementAttributes; + ['switch']: SVGSwitchElementAttributes; + ['symbol']: SVGSymbolElementAttributes; + ['text']: SVGTextElementAttributes; + ['textPath']: SVGTextPathElementAttributes; + ['title']: SVGTitleElementAttributes; + ['tspan']: SVGTSpanElementAttributes; + ['use']: SVGUseElementAttributes; + ['view']: SVGViewElementAttributes; +} +} + diff --git a/packages/template/-private/dsl/emit.d.ts b/packages/template/-private/dsl/emit.d.ts index 2d9756835..3baffe4c8 100644 --- a/packages/template/-private/dsl/emit.d.ts +++ b/packages/template/-private/dsl/emit.d.ts @@ -1,4 +1,4 @@ -import { AttrValue, ContentValue } from '..'; +import { ContentValue } from '..'; import { ComponentReturn, AnyContext, @@ -11,6 +11,7 @@ import { } from '../integration'; import { AttributesForElement, + AttributesForTagName, ElementForTagName, MathMlElementForTagName, SVGElementForTagName, @@ -52,20 +53,22 @@ export declare function emitContent(value: ContentValue): void; export declare function emitElement( name: Name, ): { + name: Name; element: Name extends 'math' ? MathMlElementForTagName<'math'> : Name extends 'svg' ? SVGElementForTagName<'svg'> : ElementForTagName; + attributes: AttributesForTagName; }; export declare function emitSVGElement( name: Name, -): { element: SVGElementForTagName }; +): { name: Name; element: SVGElementForTagName }; export declare function emitMathMlElement( name: Name, -): { element: MathMlElementForTagName }; +): { name: Name; element: MathMlElementForTagName }; /* * Emits the given value as an entity that expects to receive blocks @@ -154,9 +157,13 @@ export declare function applySplattributes< *
* */ -export declare function applyAttributes( - element: T, - attrs: Partial>, +export declare function applyAttributes< + T extends Element | { name: string; element: unknown; attributes: unknown }, +>( + invoked: T, + attrs: T extends Element + ? { [K in string & keyof AttributesForElement]?: AttributesForElement[K] } + : { [K in string & keyof T['attributes']]?: T['attributes'][K] }, ): void; /* diff --git a/packages/template/-private/dsl/types.d.ts b/packages/template/-private/dsl/types.d.ts index d2efe7113..78e44c59e 100644 --- a/packages/template/-private/dsl/types.d.ts +++ b/packages/template/-private/dsl/types.d.ts @@ -1,9 +1,8 @@ import './elements'; +import './custom-elements'; import { AttrValue } from '../index'; import { GlintElementRegistry } from './lib.dom.augmentation'; -type Registry = GlintElementRegistry; - /** * This doesn't generate _totally_ unique mappings, but they all have the same attributes. * @@ -16,13 +15,21 @@ type Registry = GlintElementRegistry; * * And for the purposes of attribute lookup, that's good enough. */ -type Lookup = { - [K in keyof Registry]: [Registry[K]] extends [T] // check assignability in one direction - ? [T] extends [Registry[K]] // and in the other +export type Lookup = { + [K in keyof GlintElementRegistry]: [GlintElementRegistry[K]] extends [T] // check assignability in one direction + ? [T] extends [GlintElementRegistry[K]] // and in the other ? K // if both true, exact match : never : never; -}[keyof Registry]; +}[keyof GlintElementRegistry]; + +export type CustomElementLookup = { + [K in keyof GlintCustomElementMap]: [GlintCustomElementMap[K]] extends [T] + ? [T] extends [GlintCustomElementMap[K]] + ? K + : never + : never; +}[keyof GlintCustomElementMap]; /** * A utility for constructing the type of an environment's `resolveOrReturn` from @@ -36,7 +43,11 @@ export type ResolveOrReturn = T & ((item: U) => () => U); */ export type ElementForTagName = Name extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[Name] - : Element; + : // By default, the GlintCustomElementMap is empty + Name extends keyof GlintCustomElementMap + ? GlintCustomElementMap[Name] + : // If there is no match, we can fallback to the originating ancestor Element type + Element; export type SVGElementForTagName = Name extends keyof SVGElementTagNameMap ? SVGElementTagNameMap[Name] @@ -47,7 +58,20 @@ export type MathMlElementForTagName = type WithDataAttributes = T & Record<`data-${string}`, AttrValue>; -export type AttributesForElement> = +export type AttributesForKeyInMap = K extends keyof M + ? WithDataAttributes + : K extends never + ? `Invalid key passed (never)` + : `key "${K}" not found in map`; + +export type AttributesForCustomElement< + Elem extends Element, + K = CustomElementLookup, +> = keyof Elem & K extends keyof GlintCustomElementAttributesMap + ? AttributesForKeyInMap + : 'Could not find custom element'; + +export type AttributesForStandardElement> = // Is K in the HTML attributes map? K extends keyof GlintHtmlElementAttributesMap ? WithDataAttributes @@ -57,3 +81,22 @@ export type AttributesForElement> = : // If the element can't be found: fallback to just allow general AttrValue // NOTE: MathML has no attributes Record; + +export type AttributesForElement< + Elem extends Element, + K = Lookup, +> = AttributesForStandardElement; // | AttributesForCustomElement; + +export type AttributesForTagName = Name extends keyof GlintTagNameAttributesMap + ? WithDataAttributes + : WithDataAttributes; + +export type AttributeRecord = { + [K in keyof RecordType]: RecordType[K]; +}; + +export type ElementInfoForElementType = { + element: ElemType; + attributes: AttributesForElement; + name: 'unknown'; +}; diff --git a/packages/template/__tests__/attributes.test.ts b/packages/template/__tests__/attributes.test.ts index 4db4d3408..202b1f869 100644 --- a/packages/template/__tests__/attributes.test.ts +++ b/packages/template/__tests__/attributes.test.ts @@ -1,13 +1,16 @@ +import '@glint/template'; import { htmlSafe } from '@ember/template'; import { expectTypeOf } from 'expect-type'; import { applyAttributes, applyModifier, applySplattributes, + AttributesForTagName, emitComponent, emitElement, resolve, templateForBackingValue, + WithDataAttributes, } from '../-private/dsl'; import { ModifierLike } from '../-private/index'; import TestComponent from './test-component'; @@ -53,12 +56,45 @@ class MyComponent extends TestComponent<{ Element: HTMLImageElement }> { // `emitElement` type resolution { const el = emitElement('img'); - expectTypeOf(el).toEqualTypeOf<{ element: HTMLImageElement }>(); + expectTypeOf(el.element).toEqualTypeOf(); + expectTypeOf(el.name).toEqualTypeOf<'img'>(); + expectTypeOf(el.attributes).toEqualTypeOf>(); } { const el = emitElement('customelement'); - expectTypeOf(el).toEqualTypeOf<{ element: Element }>(); + expectTypeOf(el.element).toEqualTypeOf(); + expectTypeOf(el.name).toEqualTypeOf<'customelement'>(); + expectTypeOf(el.attributes).toEqualTypeOf>(); +} + +class RegisteredCustomElement extends HTMLElement { + declare propNum: number; + declare propStr: string; +} + +declare global { + interface GlintCustomElementMap { + 'registered-custom-element': RegisteredCustomElement; + 'explicit-attributes': RegisteredCustomElement; + } + interface GlintTagNameAttributesMap { + 'explicit-attributes': { propNum: number; propStr: string }; + } +} + +{ + const el = emitElement('registered-custom-element'); + expectTypeOf(el.element).toEqualTypeOf(); + expectTypeOf(el.name).toEqualTypeOf<'registered-custom-element'>(); + expectTypeOf(el.attributes).toEqualTypeOf>(); + + const el2 = emitElement('explicit-attributes'); + expectTypeOf(el2.element).toEqualTypeOf(); + expectTypeOf(el2.name).toEqualTypeOf<'explicit-attributes'>(); + expectTypeOf(el2.attributes).toEqualTypeOf< + WithDataAttributes<{ propNum: number; propStr: string }> + >(); } /** @@ -109,7 +145,9 @@ class MyComponent extends TestComponent<{ Element: HTMLImageElement }> { */ { const ctx = emitElement('a'); - expectTypeOf(ctx).toEqualTypeOf<{ element: HTMLAnchorElement }>(); + expectTypeOf(ctx.element).toEqualTypeOf(); + expectTypeOf(ctx.name).toEqualTypeOf<'a'>(); + expectTypeOf(ctx.attributes).toEqualTypeOf>(); applyModifier(resolve(anchorModifier)(ctx.element)); } diff --git a/packages/template/__tests__/augmentation.test.ts b/packages/template/__tests__/augmentation.test.ts new file mode 100644 index 000000000..f6f413af7 --- /dev/null +++ b/packages/template/__tests__/augmentation.test.ts @@ -0,0 +1,23 @@ +/** + * Tests for these things are used elsewhere, to ensure that we don't have to declaration merge in the file we use custom elements in + */ +import '@glint/template'; + +export class AugmentedCustomElement extends HTMLElement { + declare propNum: number; + declare propStr: string; +} + +export interface AugmentedCustomElementAttributes { + propNum: number; + propStr: string; +} + +declare global { + interface GlintCustomElementMap { + 'augmented-custom-element': typeof AugmentedCustomElement; + } + interface GlintTagNameAttributesMap { + 'augmented-custom-element': AugmentedCustomElementAttributes; + } +} diff --git a/packages/template/__tests__/custom-element.test.ts b/packages/template/__tests__/custom-element.test.ts new file mode 100644 index 000000000..8e705e8cb --- /dev/null +++ b/packages/template/__tests__/custom-element.test.ts @@ -0,0 +1,56 @@ +import { expectTypeOf } from 'expect-type'; +import { + emitElement, + applyAttributes, + WithDataAttributes, + AttributesForTagName, +} from '../-private/dsl'; +import { AugmentedCustomElement, AugmentedCustomElementAttributes } from './augmentation.test'; + +/** + * Baseline + */ +{ + const div = emitElement('div'); + + type Attrs = AttributesForTagName<`div`>; + expectTypeOf().toEqualTypeOf>(); + expectTypeOf(div.element).toEqualTypeOf(); + expectTypeOf(div.attributes).toEqualTypeOf>(); + + applyAttributes(div, { + 'data-foo': '123', + role: 'button', + }); +} + +/** + * Can we have typed custom-elements? + * (yes) + */ +{ + expectTypeOf().toHaveProperty('augmented-custom-element'); + + const custom = emitElement('augmented-custom-element'); + + type Attrs = AttributesForTagName<`augmented-custom-element`>; + expectTypeOf().toEqualTypeOf<'propNum' | 'propStr' | `data-${string}`>(); + expectTypeOf(custom.element).toEqualTypeOf(); + expectTypeOf(custom.attributes).toEqualTypeOf< + WithDataAttributes + >(); + + applyAttributes(custom, { + propNum: 123, + propStr: 'hello', + // @ts-expect-error propNope does not exist + propNope: 'wrong', + }); + + applyAttributes(custom, { + // @ts-expect-error propNum expects a number, and I gave it a string to test that an error occurs + propNum: 'wrong', + // @ts-expect-error propStr expects a string, and I gave it a number to test that an error occurs + propStr: 123, + }); +} diff --git a/packages/template/__tests__/emit-component.test.ts b/packages/template/__tests__/emit-component.test.ts index 3c90e692c..07df46a42 100644 --- a/packages/template/__tests__/emit-component.test.ts +++ b/packages/template/__tests__/emit-component.test.ts @@ -9,6 +9,7 @@ import { templateForBackingValue, yieldToBlock, NamedArgsMarker, + WithDataAttributes, } from '../-private/dsl'; import TestComponent, { globals } from './test-component'; @@ -46,7 +47,11 @@ class MyComponent extends TestComponent> { { const __glintY__ = emitElement('div'); - expectTypeOf(__glintY__).toEqualTypeOf<{ element: HTMLDivElement }>(); + expectTypeOf(__glintY__.element).toEqualTypeOf(); + expectTypeOf(__glintY__.name).toEqualTypeOf<'div'>(); + expectTypeOf(__glintY__.attributes).toEqualTypeOf< + WithDataAttributes + >(); applyModifier( resolve(globals.on)(__glintY__.element, 'click', __glintRef__.this.wrapperClicked), ); diff --git a/packages/template/__tests__/private/AttributesForElement.test.ts b/packages/template/__tests__/private/AttributesForElement.test.ts index d0057b2d7..f59ab8a7d 100644 --- a/packages/template/__tests__/private/AttributesForElement.test.ts +++ b/packages/template/__tests__/private/AttributesForElement.test.ts @@ -39,6 +39,11 @@ import type { AttributesForElement } from '../../-private/dsl'; expectTypeOf().toBeString(); expectTypeOf<'alt' | 'src'>().toExtend(); } +{ + type AttributeMap = AttributesForElement; + expectTypeOf().not.toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); +} { type Attributes = keyof AttributesForElement & string; diff --git a/packages/template/__tests__/private/ElementForTagName.test.ts b/packages/template/__tests__/private/ElementForTagName.test.ts new file mode 100644 index 000000000..e8af2b6cf --- /dev/null +++ b/packages/template/__tests__/private/ElementForTagName.test.ts @@ -0,0 +1,29 @@ +import '@glint/template'; + +import { expectTypeOf } from 'expect-type'; +import type { ElementForTagName } from '../../-private/dsl/types'; + +class MyCustomElement extends HTMLElement { + declare propNum: number; + declare propStr: string; + declare propBool: boolean; + + declare static readonly __brand: unique symbol; +} + +declare global { + interface GlintCustomElementMap { + 'my-custom-element-element-for-tag-name': MyCustomElement; + } +} + +{ + type X = ElementForTagName<'my-custom-element-element-for-tag-name'>; + + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + + expectTypeOf().not.toEqualTypeOf(); +} diff --git a/packages/vscode/__fixtures__/ember-app/src/custom-element/augmented.gts b/packages/vscode/__fixtures__/ember-app/src/custom-element/augmented.gts new file mode 100644 index 000000000..33fc16a8d --- /dev/null +++ b/packages/vscode/__fixtures__/ember-app/src/custom-element/augmented.gts @@ -0,0 +1,34 @@ +import '@glint/template'; + +declare global { + interface HTMLStyleElementAttributes { + scoped: ''; + inline: ''; + } +} +class MyCustomElement extends HTMLElement { + propNum!: number; + propStr!: string; +} + +declare global { + interface GlintCustomElementRegistry { + MyCustomElement: MyCustomElement; + 'my-custom-element': MyCustomElement; + } + + // interface GlintHtmlElementAttributesMap { + // MyCustomElement: { + // propNum: number; + // propStr: string; + // }; + // } +} + + + \ No newline at end of file diff --git a/packages/vscode/__fixtures__/ember-app/src/custom-element/custom-elements.gts b/packages/vscode/__fixtures__/ember-app/src/custom-element/custom-elements.gts new file mode 100644 index 000000000..e363a631a --- /dev/null +++ b/packages/vscode/__fixtures__/ember-app/src/custom-element/custom-elements.gts @@ -0,0 +1,15 @@ +const two = 2; +const str = "hello"; + +export type X = GlintCustomElementRegistry['my-custom-element']; + +export const UsesCustomElement = ; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be3e596a4..9d28eccae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: '@glimmer/component': specifier: ^2.0.0 version: 2.0.0 + '@glint/ember-tsc': + specifier: workspace:* + version: link:packages/core '@glint/tsserver-plugin': specifier: workspace:* version: link:packages/tsserver-plugin diff --git a/test-packages/package-test-core/__tests__/transform/rewrite.test.ts b/test-packages/package-test-core/__tests__/transform/rewrite.test.ts index f76bb1b8c..e932b685c 100644 --- a/test-packages/package-test-core/__tests__/transform/rewrite.test.ts +++ b/test-packages/package-test-core/__tests__/transform/rewrite.test.ts @@ -139,7 +139,7 @@ describe('Transform: rewriteModule', () => { }); }); - describe({}, () => { + describe('with loaded environment', () => { test('in class extends', () => { let customEnv = GlintEnvironment.load({}); let script = { diff --git a/test-packages/package-test-core/__tests__/transform/template-to-typescript.test.ts b/test-packages/package-test-core/__tests__/transform/template-to-typescript.test.ts index 707dea5ac..b928f2758 100644 --- a/test-packages/package-test-core/__tests__/transform/template-to-typescript.test.ts +++ b/test-packages/package-test-core/__tests__/transform/template-to-typescript.test.ts @@ -984,6 +984,49 @@ describe('Transform: rewriteTemplate', () => { }); }); + describe('custom elements', () => { + test('with programmatic contents', () => { + let template = '{{@foo}}'; + + expect(templateBody(template)).toMatchInlineSnapshot(` + "{ + const __glintY__ = __glintDSL__.emitElement("my-element"); + __glintDSL__.emitContent(__glintDSL__.resolveOrReturn(__glintRef__.args.foo)()); + }" + `); + }); + + test('with attributes', () => { + let template = stripIndent` + + `; + + expect(templateBody(template)).toMatchInlineSnapshot(` + "{ + const __glintY__ = __glintDSL__.emitElement("my-custom-element"); + __glintDSL__.applyAttributes(__glintY__, { + "prop-num": __glintDSL__.resolveOrReturn(__glintDSL__.Globals["str"])(), + + "prop-str": __glintDSL__.resolveOrReturn(__glintDSL__.Globals["two"])(), + + + }); + } + __glintRef__; __glintDSL__; + // begin directive placeholders + // @ts-expect-error expect-error + ; + // @ts-expect-error expect-error + ;" + `); + }); + }); + describe('angle bracket components', () => { test('self-closing', () => { let template = ``; diff --git a/test-packages/ts-template-imports-app/src/custom-elements.gts b/test-packages/ts-template-imports-app/src/custom-elements.gts new file mode 100644 index 000000000..ae3202876 --- /dev/null +++ b/test-packages/ts-template-imports-app/src/custom-elements.gts @@ -0,0 +1,21 @@ +const two = 2; +const str = "hello"; + +type X = GlintCustomElementsMap['my-custom-element']; + +export const UsesCustomElement = ; diff --git a/test-packages/ts-template-imports-app/types/index.d.ts b/test-packages/ts-template-imports-app/types/index.d.ts index 1ce64102a..c6c25b0fe 100644 --- a/test-packages/ts-template-imports-app/types/index.d.ts +++ b/test-packages/ts-template-imports-app/types/index.d.ts @@ -13,3 +13,21 @@ declare module '@glint/ember-tsc/globals' { }>; } } + +class MyCustomElement extends HTMLElement { + propNum!: number; + propStr!: string; +} + +declare global { + interface GlintCustomElementsMap { + 'my-custom-element': typeof MyCustomElement; + } + + interface GlintTagNameAttributesMap { + 'my-custom-element': { + 'prop-num': number; + 'prop-str': string; + }; + } +}