@zoijs/core 1.0.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/CHANGELOG.md +36 -0
- package/LICENSE +21 -0
- package/README.md +154 -0
- package/package.json +63 -0
- package/src/core/each.js +24 -0
- package/src/core/html.js +243 -0
- package/src/core/mount.js +31 -0
- package/src/core/renderer.js +364 -0
- package/src/index.d.ts +113 -0
- package/src/index.js +14 -0
- package/src/reactivity/computed.js +10 -0
- package/src/reactivity/core.js +205 -0
- package/src/reactivity/effect.js +7 -0
- package/src/reactivity/env.js +24 -0
- package/src/reactivity/owner.js +61 -0
- package/src/reactivity/scheduler.js +7 -0
- package/src/reactivity/state.js +10 -0
- package/src/utils/dom.js +51 -0
- package/src/utils/security.js +55 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// effect() + untrack() — reactive side effects (used internally by the renderer
|
|
2
|
+
// to wire bindings). An effect re-runs when something it read changes; errors it
|
|
3
|
+
// throws are contained so other bindings keep working.
|
|
4
|
+
//
|
|
5
|
+
// Implemented by the shared reactive core (see core.js).
|
|
6
|
+
|
|
7
|
+
export { effect, untrack } from "./core.js";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// env.js — development / production mode (Task 4).
|
|
2
|
+
//
|
|
3
|
+
// Development (the default) shows helpful warnings: duplicate `each` keys,
|
|
4
|
+
// self-triggering effects, runaway loops. Production silences them for less
|
|
5
|
+
// noise and a touch less work. No build step required — just call configure().
|
|
6
|
+
//
|
|
7
|
+
// import { configure } from "@zoijs/core";
|
|
8
|
+
// configure({ dev: false }); // production
|
|
9
|
+
|
|
10
|
+
let dev = true;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {{ dev?: boolean }} options
|
|
14
|
+
*/
|
|
15
|
+
export function configure(options) {
|
|
16
|
+
if (options && typeof options.dev === "boolean") {
|
|
17
|
+
dev = options.dev;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** @returns {boolean} true in development mode */
|
|
22
|
+
export function isDev() {
|
|
23
|
+
return dev;
|
|
24
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// owner.js — ownership scopes for deterministic cleanup (Task 2).
|
|
2
|
+
//
|
|
3
|
+
// An owner collects "disposers" — functions that tear down whatever was created
|
|
4
|
+
// inside its scope (effects, computeds, event listeners, list-item subtrees).
|
|
5
|
+
// Owners nest: disposing a parent disposes its children first. This is how
|
|
6
|
+
// unmount() and removed list items free their reactive subscriptions instead of
|
|
7
|
+
// leaving them attached to long-lived state.
|
|
8
|
+
//
|
|
9
|
+
// These are internal helpers (not part of the public API).
|
|
10
|
+
|
|
11
|
+
let currentOwner = null;
|
|
12
|
+
|
|
13
|
+
/** The active owner, or null outside any scope. */
|
|
14
|
+
export function getOwner() {
|
|
15
|
+
return currentOwner;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Create a scope, nested under the currently active owner. */
|
|
19
|
+
export function createOwner() {
|
|
20
|
+
const owner = {
|
|
21
|
+
disposers: [],
|
|
22
|
+
children: new Set(),
|
|
23
|
+
parent: currentOwner,
|
|
24
|
+
disposed: false,
|
|
25
|
+
};
|
|
26
|
+
if (currentOwner) currentOwner.children.add(owner);
|
|
27
|
+
return owner;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Run `fn` with `owner` active, so things it creates register into `owner`. */
|
|
31
|
+
export function runWithOwner(owner, fn) {
|
|
32
|
+
const previous = currentOwner;
|
|
33
|
+
currentOwner = owner;
|
|
34
|
+
try {
|
|
35
|
+
return fn();
|
|
36
|
+
} finally {
|
|
37
|
+
currentOwner = previous;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Register a cleanup function in the active owner (no-op outside a scope). */
|
|
42
|
+
export function onCleanup(fn) {
|
|
43
|
+
if (currentOwner) currentOwner.disposers.push(fn);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Dispose a scope: tear down child scopes first, then run its disposers. */
|
|
47
|
+
export function disposeOwner(owner) {
|
|
48
|
+
if (owner.disposed) return;
|
|
49
|
+
owner.disposed = true;
|
|
50
|
+
for (const child of [...owner.children]) disposeOwner(child);
|
|
51
|
+
owner.children.clear();
|
|
52
|
+
for (let i = owner.disposers.length - 1; i >= 0; i--) {
|
|
53
|
+
try {
|
|
54
|
+
owner.disposers[i]();
|
|
55
|
+
} catch (err) {
|
|
56
|
+
console.error("Zoijs: a cleanup handler threw:", err);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
owner.disposers.length = 0;
|
|
60
|
+
if (owner.parent) owner.parent.children.delete(owner);
|
|
61
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// flush() — force the batched effect queue to run now (used by tests and by code
|
|
2
|
+
// that needs a synchronous DOM update before measuring). Updates normally flush
|
|
3
|
+
// automatically on a microtask.
|
|
4
|
+
//
|
|
5
|
+
// Implemented by the shared reactive core (see core.js).
|
|
6
|
+
|
|
7
|
+
export { flush } from "./core.js";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// createState() — the reactive value primitive.
|
|
2
|
+
//
|
|
3
|
+
// const count = createState(0);
|
|
4
|
+
// count.get(); // read (subscribes the running effect/computed)
|
|
5
|
+
// count.set(1); // write (wakes subscribers, only if the value changed)
|
|
6
|
+
// count.peek(); // read without subscribing
|
|
7
|
+
//
|
|
8
|
+
// Implemented by the shared reactive core (see core.js).
|
|
9
|
+
|
|
10
|
+
export { createState } from "./core.js";
|
package/src/utils/dom.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// dom.js — small helpers over native DOM APIs.
|
|
2
|
+
//
|
|
3
|
+
// Intentionally thin: the framework prefers using the platform directly.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolve a target that may be an element or a CSS selector string.
|
|
7
|
+
* @param {Element|string} target
|
|
8
|
+
* @returns {Element}
|
|
9
|
+
*/
|
|
10
|
+
export function resolveTarget(target) {
|
|
11
|
+
const el = typeof target === "string" ? document.querySelector(target) : target;
|
|
12
|
+
if (!el) {
|
|
13
|
+
throw new Error(`Zoijs: mount target not found: ${String(target)}`);
|
|
14
|
+
}
|
|
15
|
+
return el;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Trusted Types support. `template.innerHTML = string` is a Trusted-Types sink,
|
|
19
|
+
// so under a strict `require-trusted-types-for 'script'` CSP it would throw. The
|
|
20
|
+
// htmlText here is ALWAYS framework-generated from the author's static template
|
|
21
|
+
// strings + markers — dynamic values never reach it (the scanner forbids that) —
|
|
22
|
+
// so a pass-through policy is safe by construction. Pages enforcing Trusted Types
|
|
23
|
+
// must allow the `zoijs` policy (e.g. `trusted-types zoijs`).
|
|
24
|
+
let ttPolicy;
|
|
25
|
+
let ttChecked = false;
|
|
26
|
+
function trustedHTML(htmlText) {
|
|
27
|
+
if (!ttChecked) {
|
|
28
|
+
ttChecked = true;
|
|
29
|
+
try {
|
|
30
|
+
const tt = typeof window !== "undefined" ? window.trustedTypes : undefined;
|
|
31
|
+
if (tt && tt.createPolicy) {
|
|
32
|
+
ttPolicy = tt.createPolicy("zoijs", { createHTML: (s) => s });
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
ttPolicy = undefined; // policy name unavailable; fall back
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return ttPolicy ? ttPolicy.createHTML(htmlText) : htmlText;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build an inert <template> from an HTML string and return the element.
|
|
43
|
+
* Parsing happens inside <template>, so no scripts run and no resources load.
|
|
44
|
+
* @param {string} htmlText
|
|
45
|
+
* @returns {HTMLTemplateElement}
|
|
46
|
+
*/
|
|
47
|
+
export function createTemplate(htmlText) {
|
|
48
|
+
const template = document.createElement("template");
|
|
49
|
+
template.innerHTML = trustedHTML(htmlText);
|
|
50
|
+
return template;
|
|
51
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// security.js — secure-by-default helpers.
|
|
2
|
+
//
|
|
3
|
+
// These are NOT optional add-ons; the renderer routes every dynamic value
|
|
4
|
+
// through them so the safe path is the default path. See docs/security.md.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Coerce a value to a string for safe insertion as TEXT.
|
|
8
|
+
* Text slots are written via a Text node (inert), so this is just predictable
|
|
9
|
+
* coercion: null/undefined become "".
|
|
10
|
+
* @param {any} value
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
13
|
+
export function toText(value) {
|
|
14
|
+
return value === null || value === undefined ? "" : String(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Allowlisted URL schemes for URL-bearing attributes (href, src, ...).
|
|
18
|
+
const SAFE_SCHEMES = new Set(["http", "https", "mailto", "tel"]);
|
|
19
|
+
// data: is allowed only for raster image MIME types (never text/html, never SVG,
|
|
20
|
+
// which can carry script when navigated to).
|
|
21
|
+
const SAFE_DATA_IMAGE = /^data:image\/(png|jpe?g|gif|webp|avif|bmp|x-icon)[;,]/;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Is this URL safe for a URL-bearing attribute (href, src, action, ...)?
|
|
25
|
+
* Relative URLs (no scheme) are allowed; otherwise only an allowlist of schemes.
|
|
26
|
+
* @param {string} url
|
|
27
|
+
* @returns {boolean}
|
|
28
|
+
*/
|
|
29
|
+
export function isSafeUrl(url) {
|
|
30
|
+
// Browsers strip ASCII control characters (incl. TAB/CR/LF) before parsing a
|
|
31
|
+
// URL, so "java\tscript:alert(1)" becomes "javascript:". Strip them first, or
|
|
32
|
+
// the scheme check can be bypassed.
|
|
33
|
+
const cleaned = String(url).replace(/[\x00-\x1F]/g, "").trim();
|
|
34
|
+
const match = cleaned.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/);
|
|
35
|
+
if (!match) return true; // relative URL (no scheme) — safe
|
|
36
|
+
const scheme = match[1].toLowerCase();
|
|
37
|
+
if (scheme === "data") return SAFE_DATA_IMAGE.test(cleaned.toLowerCase());
|
|
38
|
+
return SAFE_SCHEMES.has(scheme);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Attribute names that must never be bound from data.
|
|
42
|
+
const DANGEROUS_ATTRS = new Set(["srcdoc"]); // iframe srcdoc = raw-HTML sink
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Reject attribute names that should never be bound from data: inline event
|
|
46
|
+
* handlers (`on*`, which have their own safe path) and raw-HTML sinks (`srcdoc`).
|
|
47
|
+
* @param {string} name
|
|
48
|
+
* @returns {boolean} true if the attribute name is allowed
|
|
49
|
+
*/
|
|
50
|
+
export function isSafeAttributeName(name) {
|
|
51
|
+
const lower = name.toLowerCase();
|
|
52
|
+
if (lower.startsWith("on")) return false;
|
|
53
|
+
if (DANGEROUS_ATTRS.has(lower)) return false;
|
|
54
|
+
return true;
|
|
55
|
+
}
|