Skip to content

Commit 9f0b09a

Browse files
committed
feat(core): typed component props via defineComponent + .d.ts overlay
The existing static-properties pattern carried no compile-time type information, so authors had to duplicate every field as `declare x: T` for editor intellisense. This adds a TypeScript overlay plus a tiny runtime helper so the descriptor map is the single source of truth for both runtime behaviour and instance-level field types. runtime ------- • `defineComponent(descriptors)` — thin factory that returns a WebComponent subclass with `static properties` pre-set. At runtime it's equivalent to the manual pattern; the value is all in the types. • `defineProp<T>(decl)` — identity at runtime; carries a phantom type parameter so the compiler recovers a caller-supplied value type when the constructor alone doesn't give enough information (custom classes, discriminated unions via converters). types ----- • packages/core/src/component.d.ts — typed WebComponent base, PropertyDeclaration<T>, PropertyValue<D>, PropertyValues<P>, defineComponent<P>, defineProp<T>. • packages/core/index.d.ts — re-exports the overlay so `import { ... } from 'webjs'` resolves typed identifiers. • package.json `exports` map now advertises `types` sibling entries so tsserver (VS Code, Neovim, Zed, WebStorm) picks the overlay up without any project configuration. editor setup ------------ • new doc: docs/app/docs/editor-setup — Neovim (nvim-lspconfig + typescript-tools.nvim) and VS Code walkthrough, tsconfig baseline, optional lit-plugin for template-literal autocomplete, verification steps. • sidebar updated to link the new page. example + scaffold ------------------ • blog: components/counter.ts migrated to `defineComponent`, dropping the `declare count` line. • packages/cli/templates/CONVENTIONS.md: Components section now leads with `defineComponent` and notes when to fall back to plain WebComponent. • AGENTS.md: `WebComponent` section documents `defineComponent` / `defineProp` with before/after snippets, including the `declare`-based legacy pattern for pure-JS users. tests ----- • test/define-component.test.js — 7 runtime tests covering factory behaviour, property passthrough, converter wiring, and defineProp identity. • test/types/component-types.test-d.ts — compile-time assertion fixtures (constructor inference, converter inference, class-typed properties, defineProp phantom type, backwards-compat with manual declare). Not executed by node:test; consumed by tsserver / a manual tsc run. Tests: 255 → 262.
1 parent 30ecf7e commit 9f0b09a

12 files changed

Lines changed: 666 additions & 11 deletions

File tree

AGENTS.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,62 @@ Mutate state with `this.setState({...})` — it batches a re-render via microtas
439439
Attribute changes auto-trigger re-render when the attribute is declared in
440440
`static properties`.
441441

442+
#### Typed props with `defineComponent` (TypeScript — recommended)
443+
444+
For full type inference on `this.<prop>` without writing a `declare`
445+
field for every property, use the `defineComponent()` factory. It's a
446+
thin wrapper that sets `static properties` and threads the descriptor
447+
map through to TypeScript's type system:
448+
449+
```ts
450+
import { defineComponent, defineProp, html } from 'webjs';
451+
452+
class Counter extends defineComponent({
453+
count: { type: Number, reflect: true },
454+
label: { type: String },
455+
active: { type: Boolean },
456+
}) {
457+
static tag = 'my-counter';
458+
// this.count: number, this.label: string, this.active: boolean — inferred
459+
render() {
460+
return html`<p>${this.label}: ${this.count} (${this.active ? 'on' : 'off'})</p>`;
461+
}
462+
}
463+
Counter.register(import.meta.url);
464+
```
465+
466+
For a property whose value type can't be derived from the constructor
467+
alone (e.g. `type: Object` with a specific shape, or a converter
468+
returning a user-defined class), use `defineProp<T>()`:
469+
470+
```ts
471+
class Profile extends defineComponent({
472+
user: defineProp<User>({ type: Object }),
473+
}) {
474+
static tag = 'user-profile';
475+
// this.user: User
476+
render() { return html`${this.user.name}`; }
477+
}
478+
```
479+
480+
Plain-JS classes still work — the legacy pattern is identical to what
481+
the snippet above shows. Use `declare <name>: <Type>` if you want
482+
type-only fields for the editor without duplicating at runtime:
483+
484+
```ts
485+
class Legacy extends WebComponent {
486+
static tag = 'x-legacy';
487+
static properties = { count: { type: Number } };
488+
declare count: number; // compile-time only
489+
render() { return html`${this.count}`; }
490+
}
491+
```
492+
493+
Editor intellisense (VS Code + Neovim + any tsserver client) works
494+
out-of-the-box from the `.d.ts` overlay shipped with the package. See
495+
the [Editor Setup](docs/app/docs/editor-setup/page.ts) doc page for
496+
<code>tsconfig</code>, Neovim LSP, and tagged-template autocomplete.
497+
442498
#### Lifecycle hooks
443499
444500
The update cycle runs in this order when `setState()` or a property change triggers a re-render:

docs/app/docs/editor-setup/page.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { html } from 'webjs';
2+
3+
export const metadata = { title: 'Editor Setup — webjs' };
4+
5+
export default function EditorSetup() {
6+
return html`
7+
<h1>Editor Setup — Neovim &amp; VS Code</h1>
8+
<p>webjs ships a TypeScript overlay (<code>packages/core/index.d.ts</code> and <code>packages/core/src/component.d.ts</code>) so any editor that speaks the TypeScript Language Server (<code>tsserver</code>) gets full autocomplete, hover documentation, and type-checking for framework APIs — component properties, template results, server actions — with zero build step.</p>
9+
10+
<h2>Prerequisites</h2>
11+
<ul>
12+
<li><strong>Node 23.6+</strong> for native TypeScript type-stripping at runtime.</li>
13+
<li><strong>TypeScript 5.6+</strong> as a dev dependency in your app (<code>npm i -D typescript</code>). The framework itself has no TS dependency — you only need it for editor intellisense.</li>
14+
<li>A <code>tsconfig.json</code> in your app. The scaffold generates one.</li>
15+
</ul>
16+
17+
<h2><code>tsconfig.json</code> — recommended baseline</h2>
18+
<p>The scaffold writes this file for you. Manual apps should match:</p>
19+
<pre>{
20+
"compilerOptions": {
21+
"target": "ES2022",
22+
"module": "NodeNext",
23+
"moduleResolution": "NodeNext",
24+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
25+
"strict": true,
26+
"noEmit": true,
27+
"allowImportingTsExtensions": true,
28+
"skipLibCheck": true
29+
}
30+
}</pre>
31+
<p>Key points:</p>
32+
<ul>
33+
<li><code>moduleResolution: "NodeNext"</code> — required for the framework's <code>exports</code> map to resolve correctly.</li>
34+
<li><code>allowImportingTsExtensions: true</code> — lets you write <code>import { x } from './foo.ts'</code> in pages and components, matching how webjs actually serves them.</li>
35+
<li><code>noEmit: true</code> — TypeScript is used for type-checking only; the webjs dev server strips types via Node / esbuild at request time.</li>
36+
</ul>
37+
38+
<h2>VS Code</h2>
39+
<p>Works out of the box. The bundled TypeScript extension picks up your <code>tsconfig.json</code> and the framework's <code>.d.ts</code> overlay automatically. Optional extras:</p>
40+
<ul>
41+
<li><strong><a href="https://marketplace.visualstudio.com/items?itemName=runem.lit-plugin" target="_blank">lit-plugin</a></strong> — extends syntax highlighting and offers autocomplete inside <code>html\`\`</code> tagged templates. webjs's template dialect is compatible with Lit's; this plugin works with no configuration.</li>
42+
<li><strong>Tailwind CSS IntelliSense</strong> — suggests utility classes inside <code>class="..."</code> attributes.</li>
43+
</ul>
44+
45+
<h2>Neovim</h2>
46+
<p>Any TypeScript LSP client will work — the configuration is identical to any other TypeScript project.</p>
47+
48+
<h3>Option A — <code>nvim-lspconfig</code></h3>
49+
<pre>-- lua/plugins/tsserver.lua (lazy.nvim)
50+
return {
51+
'neovim/nvim-lspconfig',
52+
config = function()
53+
local lspconfig = require('lspconfig')
54+
lspconfig.ts_ls.setup({
55+
settings = {
56+
typescript = { preferences = { importModuleSpecifier = 'non-relative' } },
57+
javascript = { preferences = { importModuleSpecifier = 'non-relative' } },
58+
},
59+
})
60+
end,
61+
}</pre>
62+
63+
<h3>Option B — <code>typescript-tools.nvim</code> (recommended)</h3>
64+
<p>Faster for large projects because it talks to <code>tsserver</code> directly instead of going through <code>tsserver.js</code> over stdin. Setup:</p>
65+
<pre>return {
66+
'pmizio/typescript-tools.nvim',
67+
dependencies = { 'nvim-lua/plenary.nvim', 'neovim/nvim-lspconfig' },
68+
opts = {
69+
settings = {
70+
tsserver_file_preferences = {
71+
importModuleSpecifier = 'non-relative',
72+
includeCompletionsForModuleExports = true,
73+
},
74+
},
75+
},
76+
}</pre>
77+
78+
<h3>Autocomplete + hover bindings</h3>
79+
<p>Once the LSP is attached (check with <code>:LspInfo</code>), the standard keymaps from your LSP setup apply — commonly:</p>
80+
<pre>K -- show hover doc (types + JSDoc)
81+
gd -- go to definition
82+
gr -- list references
83+
&lt;leader&gt;ca -- code actions
84+
&lt;leader&gt;rn -- rename symbol</pre>
85+
86+
<h3>Completing in <code>html\`\`</code> templates</h3>
87+
<p>Standard <code>tsserver</code> doesn't look inside tagged template literals. For element/attribute autocomplete inside <code>html\`\`</code> strings, install the <strong>lit-plugin</strong> TypeScript plugin, which works in any tsserver-backed editor (VS Code, Neovim, etc.):</p>
88+
<pre># In your app root
89+
npm i -D typescript ts-lit-plugin
90+
91+
# Add to tsconfig.json compilerOptions:
92+
"plugins": [{ "name": "ts-lit-plugin", "strict": true }]</pre>
93+
<p>Neovim users also need to tell <code>tsserver</code> to load plugins from the workspace — <code>nvim-lspconfig</code> does this by default when you have a local <code>node_modules/typescript</code>.</p>
94+
95+
<h2>Verifying your setup</h2>
96+
<p>Create <code>components/hello.ts</code>:</p>
97+
<pre>import { defineComponent, html } from 'webjs';
98+
99+
class Hello extends defineComponent({
100+
name: { type: String },
101+
times: { type: Number },
102+
}) {
103+
static tag = 'hello-card';
104+
render() {
105+
return html\`&lt;p&gt;Hello \${this.name} — \${this.times} times&lt;/p&gt;\`;
106+
}
107+
}
108+
Hello.register(import.meta.url);</pre>
109+
110+
<p>In your editor:</p>
111+
<ul>
112+
<li>Hover <code>this.name</code> → should show <code>(property) name: string</code>.</li>
113+
<li>Hover <code>this.times</code> → should show <code>(property) times: number</code>.</li>
114+
<li>Type <code>this.</code> → autocomplete lists <code>name</code>, <code>times</code>, <code>setState</code>, <code>requestUpdate</code>, <code>state</code>, etc.</li>
115+
<li>Change a property usage to the wrong type (e.g. <code>this.name.toFixed(2)</code>) → red underline with <code>Property 'toFixed' does not exist on type 'string'.</code></li>
116+
</ul>
117+
<p>If any of these don't work, check <code>:checkhealth</code> (Neovim) or the TypeScript status bar (VS Code) — the most common issue is <code>tsserver</code> picking up a different <code>tsconfig.json</code> than the app's.</p>
118+
119+
<h2>See also</h2>
120+
<ul>
121+
<li><a href="/docs/components">Components</a><code>defineComponent</code> API details.</li>
122+
<li><a href="/docs/typescript">TypeScript</a> — type safety end-to-end.</li>
123+
<li><a href="/docs/conventions">Conventions</a> — project layout + AI-agent workflow.</li>
124+
</ul>
125+
`;
126+
}

docs/components/doc-shell.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ const NAV_SECTIONS = [
5757
{ href: '/docs/task', label: 'Task (Async Data)' },
5858
{ href: '/docs/lazy-loading', label: 'Lazy Loading' },
5959
{ href: '/docs/typescript', label: 'TypeScript' },
60+
{ href: '/docs/editor-setup', label: 'Editor Setup (Neovim, VS Code)' },
6061
{ href: '/docs/middleware', label: 'Middleware' },
6162
{ href: '/docs/deployment', label: 'Deployment' },
6263
{ href: '/docs/testing', label: 'Testing' },

examples/blog/components/counter.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
1-
import { WebComponent, html } from 'webjs';
1+
import { defineComponent, html } from 'webjs';
22

33
/**
44
* `<my-counter>` — demo counter with the current design system.
55
* Tabular monospace output; warm-accent focus ring.
6+
*
7+
* Uses `defineComponent` so `this.count` is typed as `number` without
8+
* a `declare` line — the descriptor is the single source of truth for
9+
* both runtime and compile-time types.
610
*/
7-
export class Counter extends WebComponent {
11+
export class Counter extends defineComponent({
12+
count: { type: Number },
13+
}) {
814
static tag = 'my-counter';
9-
static properties = { count: { type: Number } };
10-
count = 0;
1115
_bump(d: number) { this.count = (Number(this.count) || 0) + d; this.requestUpdate(); }
1216
render() {
1317
const v = Number(this.count) || 0;

packages/cli/templates/CONVENTIONS.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -212,23 +212,33 @@ with puppeteer or playwright imports.
212212
<!-- OVERRIDE -->
213213

214214
```ts
215-
import { WebComponent, html } from 'webjs';
215+
import { defineComponent, html } from 'webjs';
216216

217-
export class MyWidget extends WebComponent {
217+
export class MyWidget extends defineComponent({
218+
label: { type: String },
219+
count: { type: Number },
220+
}) {
218221
static tag = 'my-widget';
219-
// Light DOM is the default — Tailwind utility classes apply directly.
222+
// `this.label: string` and `this.count: number` are inferred — no
223+
// `declare` boilerplate. Light DOM is the default; Tailwind classes
224+
// apply directly.
220225

221226
render() {
222227
return html`
223228
<div class="p-4 border border-border rounded-lg">
224-
<p class="font-serif text-fg">Hello</p>
229+
<p class="font-serif text-fg">${this.label}: ${this.count}</p>
225230
</div>
226231
`;
227232
}
228233
}
229234
MyWidget.register(import.meta.url);
230235
```
231236

237+
Prefer `defineComponent(...)` over plain `WebComponent` when you have
238+
properties — it's the recommended pattern for TypeScript apps. Pure JS
239+
consumers can still subclass `WebComponent` directly and set
240+
`static properties` manually.
241+
232242
**Rules:**
233243
- One component per file
234244
- **Light DOM by default.** Opt in to shadow DOM with `static shadow = true` when you need scoped styles, `<slot>` projection, or third-party-embed isolation.

packages/core/index.d.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Public type surface for `webjs`.
3+
*
4+
* The runtime is packages/core/index.js (JSDoc-annotated JavaScript); this
5+
* overlay exists so TypeScript-based editors (tsserver under VS Code,
6+
* Neovim, Zed, WebStorm) resolve richer types than JSDoc alone can express
7+
* — specifically the generic component factory and property-descriptor
8+
* inference helpers. Zero runtime cost.
9+
*/
10+
11+
export * from './src/component.d.ts';
12+
13+
export { html, isTemplate, MARKER } from './src/html.js';
14+
export { css, isCSS, adoptStyles, stylesToString } from './src/css.js';
15+
export { register, lookup, lookupModuleUrl, isLazy, allTags } from './src/registry.js';
16+
export { renderToString, renderToStream } from './src/render-server.js';
17+
export { render } from './src/render-client.js';
18+
export { escapeText, escapeAttr } from './src/escape.js';
19+
export { notFound, redirect, isNotFound, isRedirect } from './src/nav.js';
20+
export { expose, getExposed } from './src/expose.js';
21+
export { repeat, isRepeat } from './src/repeat.js';
22+
export { Suspense, isSuspense } from './src/suspense.js';
23+
export { connectWS } from './src/websocket-client.js';
24+
export { richFetch } from './src/rich-fetch.js';
25+
export { enableClientRouter, disableClientRouter, navigate } from './src/router-client.js';
26+
export { unsafeHTML, isUnsafeHTML, live, isLive } from './src/directives.js';
27+
export { createContext, ContextProvider, ContextConsumer, ContextRequestEvent } from './src/context.js';
28+
export { Task, TaskStatus } from './src/task.js';

packages/core/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
export { html, isTemplate, MARKER } from './src/html.js';
1010
export { css, isCSS, adoptStyles, stylesToString } from './src/css.js';
11-
export { WebComponent } from './src/component.js';
11+
export { WebComponent, defineComponent, defineProp } from './src/component.js';
1212
export { register, lookup, lookupModuleUrl, isLazy, allTags } from './src/registry.js';
1313
export { renderToString, renderToStream } from './src/render-server.js';
1414
export { render } from './src/render-client.js';

packages/core/package.json

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,24 @@
33
"version": "0.1.0",
44
"type": "module",
55
"description": "webjs core runtime — html/css tags, WebComponent base, isomorphic renderers",
6+
"types": "./index.d.ts",
67
"exports": {
7-
".": "./index.js",
8+
".": {
9+
"types": "./index.d.ts",
10+
"default": "./index.js"
11+
},
812
"./client": "./src/render-client.js",
913
"./server": "./src/render-server.js",
10-
"./component": "./src/component.js",
14+
"./component": {
15+
"types": "./src/component.d.ts",
16+
"default": "./src/component.js"
17+
},
1118
"./registry": "./src/registry.js",
1219
"./client-router": "./src/router-client.js"
1320
},
1421
"files": [
1522
"index.js",
23+
"index.d.ts",
1624
"src"
1725
]
1826
}

0 commit comments

Comments
 (0)