Skip to content

Commit 3d8a253

Browse files
committed
feat(core): add essential directives (unsafeHTML, live) with renderer integration
Only three directives in webjs — each solves a problem with no native alternative. unsafeHTML for trusted raw HTML, live for input value dirty-checking, repeat (already existed) for keyed lists. Both client and server renderers now process directive markers correctly instead of rendering [object Object]. Dropped 9 non-essential directives (classMap, styleMap, ifDefined, when, choose, guard, ref, cache, until) in favor of native patterns. Less is more — AI agents don't need syntax sugar.
1 parent 6900c85 commit 3d8a253

5 files changed

Lines changed: 496 additions & 2 deletions

File tree

packages/core/index.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
export { html, isTemplate, MARKER } from './src/html.js';
1010
export { css, isCSS, adoptStyles, stylesToString } from './src/css.js';
1111
export { WebComponent } from './src/component.js';
12-
export { register, lookup, lookupModuleUrl, allTags } from './src/registry.js';
13-
export { renderToString } from './src/render-server.js';
12+
export { register, lookup, lookupModuleUrl, isLazy, allTags } from './src/registry.js';
13+
export { renderToString, renderToStream } from './src/render-server.js';
1414
export { render } from './src/render-client.js';
1515
export { escapeText, escapeAttr } from './src/escape.js';
1616
export { notFound, redirect, isNotFound, isRedirect } from './src/nav.js';
@@ -20,3 +20,12 @@ export { Suspense, isSuspense } from './src/suspense.js';
2020
export { connectWS } from './src/websocket-client.js';
2121
export { richFetch } from './src/rich-fetch.js';
2222
export { enableClientRouter, disableClientRouter, navigate } from './src/router-client.js';
23+
24+
// Directives — also available via 'webjs/directives'
25+
export { unsafeHTML, isUnsafeHTML, live, isLive } from './src/directives.js';
26+
27+
// Context Protocol — also available via 'webjs/context'
28+
export { createContext, ContextProvider, ContextConsumer, ContextRequestEvent } from './src/context.js';
29+
30+
// Task controller — also available via 'webjs/task'
31+
export { Task, TaskStatus } from './src/task.js';

packages/core/src/directives.js

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* Built-in directives for the webjs `html` tagged template system.
3+
*
4+
* webjs follows a "less is more" philosophy: only directives that solve
5+
* problems with NO native alternative are included. AI agents don't need
6+
* syntax sugar — they can write ternaries, string concatenation, and
7+
* lifecycle hooks just fine.
8+
*
9+
* **What's here:**
10+
* - `unsafeHTML(str)` — render trusted raw HTML (no alternative in templates)
11+
*
12+
* **What's NOT here (and why):**
13+
* - classMap → use `class=${'btn ' + (active ? 'active' : '')}`
14+
* - styleMap → use `style=${'color:' + color}`
15+
* - ifDefined → use `attr=${val ?? null}` (null removes the attribute)
16+
* - when/choose → use ternary `${cond ? a : b}` or if/else before the template
17+
* - guard → memoize in `willUpdate()` lifecycle hook
18+
* - ref → use `this.query('#el')` in `firstUpdated()` or `updated()`
19+
* - cache → use CSS `display:none` to preserve DOM instead of removing
20+
* - until → use the `Task` controller for component-scoped async data
21+
* - live → set `.value` via property binding `.value=${val}` and handle
22+
* input events with `@input=${e => this.setState({val: e.target.value})}`
23+
*
24+
* `repeat()` is in its own file (`./repeat.js`) — it's essential for keyed
25+
* list reconciliation and has no native alternative.
26+
*
27+
* @module directives
28+
*/
29+
30+
/* ================================================================
31+
* unsafeHTML
32+
* ================================================================ */
33+
34+
/**
35+
* Render a raw HTML string without escaping. The string is injected
36+
* directly into the DOM as parsed HTML nodes.
37+
*
38+
* **When to use (AI hint):** Use ONLY for trusted HTML — CMS content,
39+
* markdown-to-HTML output, or sanitized rich text. NEVER use for
40+
* user-supplied input — this is an XSS vector.
41+
*
42+
* ```js
43+
* import { html } from 'webjs';
44+
* import { unsafeHTML } from 'webjs/directives';
45+
*
46+
* // Good: trusted markdown output
47+
* html`<article>${unsafeHTML(markdownToHtml(post.body))}</article>`;
48+
*
49+
* // DANGEROUS: user input — use ${text} instead (auto-escaped)
50+
* // html`<p>${unsafeHTML(userInput)}</p>`; // ← XSS!
51+
* ```
52+
*
53+
* @param {string | null | undefined} htmlString
54+
* Trusted HTML string to render without escaping.
55+
* @returns {{ _$webjs: 'unsafe-html', value: string }}
56+
*/
57+
export function unsafeHTML(htmlString) {
58+
return { _$webjs: 'unsafe-html', value: String(htmlString ?? '') };
59+
}
60+
61+
/**
62+
* Type guard: returns `true` if `x` is a marker produced by `unsafeHTML()`.
63+
* @param {unknown} x
64+
* @returns {x is { _$webjs: 'unsafe-html', value: string }}
65+
*/
66+
export function isUnsafeHTML(x) {
67+
return !!x && typeof x === 'object' && /** @type {any} */ (x)._$webjs === 'unsafe-html';
68+
}
69+
70+
/* ================================================================
71+
* live
72+
* ================================================================ */
73+
74+
/**
75+
* Dirty-check a value against the **live DOM value** instead of the
76+
* last rendered value. Essential for `<input>` two-way binding where
77+
* the user can modify the DOM value between renders.
78+
*
79+
* **When to use (AI hint):** Use `live()` on `.value` or `.checked`
80+
* bindings for `<input>`, `<textarea>`, `<select>` elements where the
81+
* user types/selects between renders. Without `live()`, the renderer
82+
* skips the update because its cached value matches — even though the
83+
* DOM value has changed.
84+
*
85+
* ```js
86+
* import { html } from 'webjs';
87+
* import { live } from 'webjs/directives';
88+
*
89+
* html`<input .value=${live(this.state.query)}
90+
* @input=${e => this.setState({ query: e.target.value })}>`;
91+
* ```
92+
*
93+
* On the server, `live()` is a no-op — it unwraps to the inner value.
94+
*
95+
* @param {unknown} value The value to set on the element.
96+
* @returns {{ _$webjs: 'live', value: unknown }}
97+
*/
98+
export function live(value) {
99+
return { _$webjs: 'live', value };
100+
}
101+
102+
/**
103+
* Type guard: returns `true` if `x` is a marker produced by `live()`.
104+
* @param {unknown} x
105+
* @returns {x is { _$webjs: 'live', value: unknown }}
106+
*/
107+
export function isLive(x) {
108+
return !!x && typeof x === 'object' && /** @type {any} */ (x)._$webjs === 'live';
109+
}

packages/core/src/render-client.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { isTemplate, MARKER } from './html.js';
22
import { escapeAttr } from './escape.js';
33
import { isRepeat } from './repeat.js';
4+
import { isUnsafeHTML, isLive } from './directives.js';
45

56
/**
67
* Client-side renderer with **fine-grained** updates.
@@ -403,6 +404,16 @@ function clearInstance(inst, container) {
403404
* @param {unknown} _prev
404405
*/
405406
function applyPart(part, value, _prev) {
407+
// Unwrap live() — dirty-check against the live DOM value, not the
408+
// last rendered value. Essential for <input> two-way binding.
409+
if (isLive(value)) {
410+
const liveVal = /** @type any */ (value).value;
411+
if (part.kind === 'prop' && /** @type any */ (part.el)[part.name] === liveVal) return;
412+
if (part.kind === 'attr' && part.el.getAttribute(part.name) === String(liveVal)) return;
413+
if (part.kind === 'bool' && part.el.hasAttribute(part.name) === !!liveVal) return;
414+
value = liveVal;
415+
}
416+
406417
switch (part.kind) {
407418
case 'child':
408419
applyChild(part, value);
@@ -439,6 +450,20 @@ function applyPart(part, value, _prev) {
439450
function applyChild(part, value) {
440451
const marker = part.marker;
441452

453+
// unsafeHTML directive — inject raw HTML string as DOM nodes.
454+
if (isUnsafeHTML(value)) {
455+
teardownChild(part);
456+
const htmlStr = String(/** @type any */ (value).value ?? '');
457+
const template = document.createElement('template');
458+
template.innerHTML = htmlStr;
459+
const nodes = [...template.content.childNodes];
460+
const frag = document.createDocumentFragment();
461+
for (const n of nodes) frag.appendChild(n);
462+
marker.parentNode?.insertBefore(frag, marker);
463+
part.child = nodes;
464+
return;
465+
}
466+
442467
// Repeat directive — keyed reconciliation. Keep previous state when both
443468
// old and new are repeats; otherwise tear down and rebuild.
444469
if (isRepeat(value)) {

0 commit comments

Comments
 (0)