@hyperfixi/components 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/scan.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * DOM scanner — finds `<template component="tag-name">` elements and
3
+ * registers them. Also supports `<script type="text/hyperscript-template"
4
+ * component="tag-name">` for upstream compatibility.
5
+ */
6
+ import type { RuntimeLike } from './types';
7
+ interface ScanOptions {
8
+ runtime?: RuntimeLike;
9
+ }
10
+ /**
11
+ * Scan the given root (defaults to `document`) for template definitions and
12
+ * register each as a custom element.
13
+ *
14
+ * Returns the number of new registrations performed.
15
+ */
16
+ export declare function scanAndRegister(root?: ParentNode, options?: ScanOptions): number;
17
+ /**
18
+ * Start watching the document for dynamically-added template definitions.
19
+ * Returns a disposer that stops the observer.
20
+ *
21
+ * Safe to call in non-DOM environments (returns a no-op disposer).
22
+ */
23
+ export declare function watchForTemplates(options?: ScanOptions): () => void;
24
+ export {};
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Scoped CSS — lift `<style>` blocks out of component templates and inject
3
+ * them into `<head>` wrapped in `@scope (tag-name) { ... }`.
4
+ *
5
+ * Mirrors upstream _hyperscript 0.9.91's component style-scoping behavior.
6
+ * Two reasons we do this:
7
+ * 1. Without lifting, every instance's innerHTML would contain a copy of
8
+ * the `<style>` block. The browser parses each one — wasteful and a
9
+ * footgun if the user uses non-`@scope` selectors.
10
+ * 2. Wrapping the contents in `@scope (tag-name) { ... }` confines them
11
+ * to that custom-element tree, so styles authored against a generic
12
+ * `.btn` class don't leak globally.
13
+ *
14
+ * Browser support: `@scope` is in Chrome 118+, Safari 17.4+, Firefox 128+.
15
+ * In older browsers, the `@scope` rule is ignored and styles leak globally
16
+ * — graceful degradation; nothing actively breaks.
17
+ *
18
+ * Idempotency: the same component may be (re)scanned multiple times via
19
+ * `componentsPlugin.scan()` followed by `watchForTemplates()`. We dedupe by
20
+ * a `data-component="${tagName}"` attribute on the injected `<style>`.
21
+ */
22
+ export interface ExtractResult {
23
+ /** The HTML with all `<style>` blocks removed. */
24
+ html: string;
25
+ /** The raw text content of each removed `<style>` block, in document order. */
26
+ styles: string[];
27
+ }
28
+ /**
29
+ * Strip `<style>...</style>` blocks from `html` and return their text content.
30
+ * Preserves the surrounding HTML otherwise. Tag-attributes on `<style>` are
31
+ * dropped (we re-build the injected element's attributes ourselves).
32
+ */
33
+ export declare function extractStyles(html: string): ExtractResult;
34
+ /**
35
+ * Inject the given style blocks into `document.head` as a single
36
+ * `<style data-component="${tagName}">` element wrapped in `@scope`. No-op if
37
+ * `styles` is empty or if the injection has already been done for this tag.
38
+ *
39
+ * Returns `true` if injection happened, `false` if it was skipped (already
40
+ * present, no styles, or no document/head available).
41
+ */
42
+ export declare function injectScopedStyles(tagName: string, styles: string[]): boolean;
43
+ /**
44
+ * Test-only helper: remove any styles previously injected by
45
+ * `injectScopedStyles`. Real usage doesn't need this — once a component is
46
+ * registered, its scoped styles persist for the page's lifetime.
47
+ */
48
+ export declare function _resetInjectedStylesForTest(): void;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Slot substitution — replace `<slot/>` and `<slot name="X"/>` placeholders
3
+ * in a template source string with content provided at instantiation.
4
+ *
5
+ * Port of upstream _hyperscript 0.9.91's `substituteSlots` (src/ext/component.js).
6
+ * Regex-based rather than DOM-based so the template source stays a plain string
7
+ * the render engine can consume.
8
+ */
9
+ /**
10
+ * Replace `<slot name="X"/>` / `<slot name="X"></slot>` with named content,
11
+ * and `<slot/>` / `<slot></slot>` with default content.
12
+ *
13
+ * Returns the template source with all `<slot>` placeholders substituted.
14
+ */
15
+ export declare function substituteSlots(templateSource: string, slotContent: string): string;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Template AST — parse + render with `#if`/`#else`/`#end`/`#for` directives.
3
+ *
4
+ * Mirrors upstream _hyperscript 0.9.91's component template syntax. Directives
5
+ * are line-oriented: each directive must occupy its own line (modulo
6
+ * surrounding whitespace). `${...}` interpolation can appear anywhere in
7
+ * static text; it's evaluated by the same path used outside directives.
8
+ *
9
+ * Supported:
10
+ * #if <expr> ... [#else] ... #end
11
+ * #for <name> in <expr> ... [#else] ... #end (#else fires on empty)
12
+ *
13
+ * Deferred (v2.1+):
14
+ * #continue — skip the current iteration of the enclosing `#for`
15
+ */
16
+ export type TemplateNode = {
17
+ kind: 'text';
18
+ content: string;
19
+ } | {
20
+ kind: 'if';
21
+ cond: string;
22
+ then: TemplateNode[];
23
+ else: TemplateNode[];
24
+ } | {
25
+ kind: 'for';
26
+ varName: string;
27
+ iterableExpr: string;
28
+ body: TemplateNode[];
29
+ elseEmpty: TemplateNode[];
30
+ };
31
+ /**
32
+ * Parse a template body into a sequence of TemplateNodes.
33
+ * Directives are recognized only when they occupy their own line.
34
+ */
35
+ export declare function parseTemplate(source: string): TemplateNode[];
36
+ /**
37
+ * Render a parsed template AST against a scope. `interpText` handles `${...}`
38
+ * interpolation in static blocks; `evalExpr` evaluates the bare expression in
39
+ * `#if <expr>` and `#for <var> in <expr>`. Both are passed in so the renderer
40
+ * stays decoupled from the components plugin's host-element / caret-var
41
+ * specifics — each callsite supplies the appropriate evaluator closure.
42
+ */
43
+ export declare function renderTemplate(nodes: TemplateNode[], scope: Record<string, unknown>, interpText: (text: string, scope: Record<string, unknown>) => string, evalExpr: (expr: string, scope: Record<string, unknown>) => unknown): string;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Lightweight local type stubs — mirror the speech/reactivity pattern of
3
+ * avoiding tight coupling to `@hyperfixi/core` internals.
4
+ */
5
+ export interface RuntimeLike {
6
+ execute(node: unknown, context: unknown): Promise<unknown>;
7
+ getCleanupRegistry(): {
8
+ registerCustom(element: Element, cleanup: () => void, description?: string): void;
9
+ };
10
+ process?: (root: Element) => void | Promise<void>;
11
+ }
12
+ export interface ASTNode {
13
+ type: string;
14
+ name?: string;
15
+ value?: unknown;
16
+ [k: string]: unknown;
17
+ }
18
+ export interface ExecutionContext {
19
+ me?: Element | null;
20
+ result?: unknown;
21
+ it?: unknown;
22
+ globals?: Map<string, unknown>;
23
+ locals?: Map<string, unknown>;
24
+ [k: string]: unknown;
25
+ }
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@hyperfixi/components",
3
+ "version": "2.4.0",
4
+ "description": "Template-component plugin for hyperfixi — register custom elements from `<template component=\"tag-name\">` definitions (upstream _hyperscript 0.9.90).",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "main": "dist/index.cjs",
8
+ "module": "dist/index.js",
9
+ "types": "dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js",
14
+ "require": "./dist/index.cjs"
15
+ }
16
+ },
17
+ "scripts": {
18
+ "build": "tsup && npm run build:types",
19
+ "build:types": "tsc --emitDeclarationOnly --outDir dist --noEmit false",
20
+ "test": "vitest",
21
+ "test:run": "vitest run",
22
+ "test:check": "vitest run --reporter=dot 2>&1 | tail -5",
23
+ "typecheck": "tsc --noEmit"
24
+ },
25
+ "dependencies": {
26
+ "@hyperfixi/core": "*",
27
+ "@hyperfixi/reactivity": "*"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^20.0.0",
31
+ "happy-dom": "^20.9.0",
32
+ "tsup": "^8.0.0",
33
+ "typescript": "^5.0.0",
34
+ "vitest": "^4.1.5"
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "src",
39
+ "LICENSE"
40
+ ],
41
+ "keywords": [
42
+ "hyperfixi",
43
+ "hyperscript",
44
+ "components",
45
+ "custom-elements",
46
+ "template",
47
+ "plugin",
48
+ "_hyperscript",
49
+ "v0.9.90"
50
+ ],
51
+ "author": "LokaScript Contributors",
52
+ "license": "MIT",
53
+ "repository": {
54
+ "type": "git",
55
+ "url": "git+https://github.com/codetalcott/hyperfixi.git",
56
+ "directory": "packages/components"
57
+ },
58
+ "engines": {
59
+ "node": ">=18.0.0"
60
+ },
61
+ "publishConfig": {
62
+ "access": "public"
63
+ }
64
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * `attrs` proxy unit tests.
3
+ */
4
+
5
+ import { describe, it, expect } from 'vitest';
6
+ import { createAttrsProxy } from './attrs';
7
+
8
+ describe('createAttrsProxy', () => {
9
+ it('reads a string attribute as-is', () => {
10
+ const el = document.createElement('my-comp');
11
+ el.setAttribute('label', 'Hello');
12
+ const attrs = createAttrsProxy(el);
13
+ expect(attrs.label).toBe('Hello');
14
+ });
15
+
16
+ it('coerces numeric-looking attributes to numbers', () => {
17
+ const el = document.createElement('my-comp');
18
+ el.setAttribute('count', '42');
19
+ el.setAttribute('rate', '1.5');
20
+ el.setAttribute('neg', '-7');
21
+ const attrs = createAttrsProxy(el);
22
+ expect(attrs.count).toBe(42);
23
+ expect(attrs.rate).toBe(1.5);
24
+ expect(attrs.neg).toBe(-7);
25
+ });
26
+
27
+ it('coerces "true" / "false" to booleans', () => {
28
+ const el = document.createElement('my-comp');
29
+ el.setAttribute('open', 'true');
30
+ el.setAttribute('closed', 'false');
31
+ const attrs = createAttrsProxy(el);
32
+ expect(attrs.open).toBe(true);
33
+ expect(attrs.closed).toBe(false);
34
+ });
35
+
36
+ it('maps camelCase prop reads to kebab-case attributes', () => {
37
+ const el = document.createElement('my-comp');
38
+ el.setAttribute('initial-count', '5');
39
+ const attrs = createAttrsProxy(el);
40
+ expect(attrs.initialCount).toBe(5);
41
+ });
42
+
43
+ it('returns undefined for missing attributes', () => {
44
+ const el = document.createElement('my-comp');
45
+ const attrs = createAttrsProxy(el);
46
+ expect(attrs.missing).toBeUndefined();
47
+ });
48
+
49
+ it('writes back as kebab-case when prop is camelCase', () => {
50
+ const el = document.createElement('my-comp');
51
+ const attrs = createAttrsProxy(el);
52
+ attrs.initialCount = 10;
53
+ expect(el.getAttribute('initial-count')).toBe('10');
54
+ });
55
+
56
+ it('does not coerce non-numeric strings that happen to start with digits', () => {
57
+ const el = document.createElement('my-comp');
58
+ el.setAttribute('id', '42px'); // common CSS-like value
59
+ const attrs = createAttrsProxy(el);
60
+ expect(attrs.id).toBe('42px'); // unchanged
61
+ });
62
+ });
package/src/attrs.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * `attrs` proxy — read component attributes as values on the component scope.
3
+ *
4
+ * Simplified from upstream's hyperscript-expression-evaluating version: for v1
5
+ * we treat attributes as plain strings with a few type coercions (number, bool)
6
+ * and a kebab-case-to-camelCase accessor.
7
+ *
8
+ * `<my-counter initial-count="5">` exposes `attrs.initialCount` = 5 (number).
9
+ */
10
+
11
+ /**
12
+ * Coerce attribute string to a typed value.
13
+ * Rules:
14
+ * - "true" → true
15
+ * - "false" → false
16
+ * - number-like strings (e.g. "5", "-3.14") → number
17
+ * - everything else → original string
18
+ */
19
+ function coerceAttrValue(raw: string | null): unknown {
20
+ if (raw == null) return undefined;
21
+ if (raw === 'true') return true;
22
+ if (raw === 'false') return false;
23
+ // Number check: not empty, fully numeric after Number()
24
+ if (raw !== '' && !Number.isNaN(Number(raw)) && /^-?(\d+\.?\d*|\.\d+)$/.test(raw)) {
25
+ return Number(raw);
26
+ }
27
+ return raw;
28
+ }
29
+
30
+ /**
31
+ * Convert camelCase prop name to kebab-case attribute name. Used so that
32
+ * reading `attrs.initialCount` finds the `initial-count="..."` attribute.
33
+ */
34
+ function camelToKebab(name: string): string {
35
+ return name.replace(/[A-Z]/g, m => '-' + m.toLowerCase());
36
+ }
37
+
38
+ /**
39
+ * Create a proxy over a component element's attributes. Getting a property
40
+ * reads and coerces the matching kebab-case attribute. Setting a property
41
+ * writes it back as a string (simple stringification for v1).
42
+ */
43
+ export function createAttrsProxy(componentEl: Element): Record<string, unknown> {
44
+ return new Proxy({} as Record<string, unknown>, {
45
+ get(_target, prop) {
46
+ if (typeof prop !== 'string' || prop.startsWith('_')) return undefined;
47
+ // Try exact match first (e.g. "role", "data-x"), then kebab-cased form.
48
+ const exact = componentEl.getAttribute(prop);
49
+ if (exact != null) return coerceAttrValue(exact);
50
+ const kebab = componentEl.getAttribute(camelToKebab(prop));
51
+ if (kebab != null) return coerceAttrValue(kebab);
52
+ return undefined;
53
+ },
54
+ set(_target, prop, value) {
55
+ if (typeof prop !== 'string') return false;
56
+ const attrName = componentEl.hasAttribute(prop) ? prop : camelToKebab(prop);
57
+ componentEl.setAttribute(attrName, value == null ? '' : String(value));
58
+ return true;
59
+ },
60
+ has(_target, prop) {
61
+ if (typeof prop !== 'string') return false;
62
+ return componentEl.hasAttribute(prop) || componentEl.hasAttribute(camelToKebab(prop));
63
+ },
64
+ ownKeys(_target) {
65
+ return Array.from(componentEl.attributes).map(a => a.name);
66
+ },
67
+ getOwnPropertyDescriptor(_target, prop) {
68
+ if (typeof prop !== 'string') return undefined;
69
+ const kebab = camelToKebab(prop);
70
+ if (componentEl.hasAttribute(prop) || componentEl.hasAttribute(kebab)) {
71
+ return {
72
+ enumerable: true,
73
+ configurable: true,
74
+ value: coerceAttrValue(componentEl.getAttribute(prop) ?? componentEl.getAttribute(kebab)),
75
+ };
76
+ }
77
+ return undefined;
78
+ },
79
+ });
80
+ }
package/src/index.ts ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * @hyperfixi/components — template-component plugin for hyperfixi.
3
+ *
4
+ * Registers custom elements from `<template component="tag-name">` definitions
5
+ * (mirrors upstream _hyperscript 0.9.91's component extension). Users write:
6
+ *
7
+ * ```html
8
+ * <template component="my-counter">
9
+ * <button>Count: ${attrs.initialCount ?? 0}</button>
10
+ * </template>
11
+ *
12
+ * <my-counter initial-count="5"></my-counter>
13
+ * ```
14
+ *
15
+ * Install at app startup:
16
+ *
17
+ * ```ts
18
+ * import { createRuntime, installPlugin } from '@hyperfixi/core';
19
+ * import { componentsPlugin } from '@hyperfixi/components';
20
+ *
21
+ * const runtime = createRuntime();
22
+ * installPlugin(runtime, componentsPlugin);
23
+ * // Then scan (typically after DOMContentLoaded):
24
+ * componentsPlugin.scan(document);
25
+ * ```
26
+ *
27
+ * v2.1 scope:
28
+ * - `<template component="tag-name">` scan + customElements.define
29
+ * - `${attrs.name}` and `${^var}` interpolation (kebab-case attribute →
30
+ * camelCase prop, with Number/Boolean coercion)
31
+ * - `^var` reads tracked via @hyperfixi/reactivity; the template re-stamps
32
+ * when any tracked `^var` changes
33
+ * - Per-instance init script — `<template _="set ^count to 0">` (or `_=`
34
+ * on the upstream `<script type="text/hyperscript-template">` form) runs
35
+ * once on each instance via the runtime's standard init mechanism
36
+ * - `attrs` available as a hyperscript local inside the init script — so
37
+ * `_="set ^user to attrs.data as JSON"` works (descendants don't see
38
+ * attrs; copy via ^vars during init if needed)
39
+ * - `<slot/>` + `<slot name="X"/>` substitution from instantiation children
40
+ * - `#if` / `#for` / `#else` / `#end` template directives
41
+ * - `dom-scope="isolated"` boundary auto-set on each instance — nested
42
+ * components don't leak `^var` reads/writes through each other
43
+ * - `<style>` blocks lifted into <head> wrapped in `@scope (tag-name)` so
44
+ * styles only apply within instances of that tag
45
+ * - disconnectedCallback fires CleanupRegistry teardown
46
+ * - MutationObserver watches for dynamically-added templates
47
+ *
48
+ * v2.2+ deferred:
49
+ * - `#continue` directive in `#for` loops
50
+ * - Reactive array mutation auto-tracking (matches upstream's known limit)
51
+ * - Full parent-scope hyperscript evaluation of attribute values (today
52
+ * `attrs.X` returns the raw string; users `as JSON` to parse)
53
+ */
54
+
55
+ import type { HyperfixiPlugin, HyperfixiPluginContext } from '@hyperfixi/core';
56
+ import type { RuntimeLike } from './types';
57
+ import { scanAndRegister, watchForTemplates } from './scan';
58
+ import { registerTemplateComponent } from './register';
59
+
60
+ export { registerTemplateComponent, scanAndRegister, watchForTemplates };
61
+
62
+ /**
63
+ * Module-level handle to the runtime captured at install time. Used by `scan`
64
+ * and `watch` to thread the runtime into each registered component for
65
+ * cleanup-registry access and child-processing.
66
+ */
67
+ let INSTALLED_RUNTIME: RuntimeLike | null = null;
68
+ let STOP_WATCH: (() => void) | null = null;
69
+
70
+ /**
71
+ * Plugin object. `install()` captures the runtime; `scan()` / `watch()` can
72
+ * be called any time after install.
73
+ */
74
+ export const componentsPlugin: HyperfixiPlugin & {
75
+ scan(root?: ParentNode): number;
76
+ watch(): () => void;
77
+ unwatch(): void;
78
+ } = {
79
+ name: '@hyperfixi/components',
80
+ install(ctx: HyperfixiPluginContext) {
81
+ INSTALLED_RUNTIME = ctx.runtime as unknown as RuntimeLike;
82
+ },
83
+ /**
84
+ * Scan `root` (defaults to `document`) for template components and register
85
+ * each. Safe to call before or after install (but install is needed for
86
+ * cleanup-registry hookup).
87
+ */
88
+ scan(root?: ParentNode): number {
89
+ return scanAndRegister(root, { runtime: INSTALLED_RUNTIME ?? undefined });
90
+ },
91
+ /**
92
+ * Start watching the document for dynamically-added template components.
93
+ * Idempotent — calling twice is a no-op on the second call.
94
+ * Returns a disposer that also clears the module-level handle.
95
+ */
96
+ watch(): () => void {
97
+ if (STOP_WATCH) return STOP_WATCH;
98
+ const stop = watchForTemplates({ runtime: INSTALLED_RUNTIME ?? undefined });
99
+ STOP_WATCH = () => {
100
+ stop();
101
+ STOP_WATCH = null;
102
+ };
103
+ return STOP_WATCH;
104
+ },
105
+ unwatch(): void {
106
+ if (STOP_WATCH) STOP_WATCH();
107
+ },
108
+ };
109
+
110
+ export default componentsPlugin;