diff --git a/@mizu/mizu/core/engine/directive.ts b/@mizu/mizu/core/engine/directive.ts new file mode 100644 index 0000000..2c7f53b --- /dev/null +++ b/@mizu/mizu/core/engine/directive.ts @@ -0,0 +1,248 @@ +// Imports +import type { DeepReadonly, Promisable } from "@libs/typing/types" +import type { AttrTypings, Context, InferAttrTypings, InitialContextState, Renderer, State } from "./renderer.ts" +import { Phase } from "./phase.ts" +export { Phase } +export type { DeepReadonly, Promisable } + +/** + * A directive implements a custom behaviour for a matching {@link https://developer.mozilla.org/docs/Web/HTML/Attributes | HTML attribute}. + * + * For more information, see the {@link https://mizu.sh/#concept-directive | mizu.sh documentation}. + */ +// deno-lint-ignore no-explicit-any +export interface Directive { + /** + * Directive name. + * + * The {@linkcode Renderer.render()} method uses this value to determine whether {@linkcode Directive.execute()} should be called for the processed node. + * + * The name should be prefixed to avoid conflicts with regular attribute names and must be unique among other {@linkcode Renderer.directives}. + * {@linkcode Renderer.load()} will use this value to check whether the directive is already loaded. + * + * If the directive name is dynamic, a `RegExp` may be used instead of a `string`. + * In this case, {@linkcode Directive.prefix} should be specified. + * + * @example + * ```ts + * const foo = { + * name: "*foo", + * phase: Phase.UNKNOWN, + * } as Directive & { name: string } + * ``` + */ + readonly name: string | RegExp + /** + * Directive prefix. + * + * It is used as a hint for {@linkcode Renderer.parseAttribute()} to strip prefix from {@linkcode https://developer.mozilla.org/en-US/docs/Web/API/Attr/name | Attr.name} when parsing the directive. + * + * If {@linkcode Directive.name} is a `RegExp`, this property shoud be specified. + * + * @example + * ```ts + * const foo = { + * name: /^~(?)/, + * prefix: "~", + * phase: Phase.UNKNOWN, + * } as Directive & { name: RegExp, prefix: string } + * ``` + */ + readonly prefix?: string + /** + * Directive import list. + * + * This list contains a record of all libraries that the directive may dynamically {@linkcode https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import | import()}. + * + * @example + * ```ts + * const foo = { + * name: "*foo", + * phase: Phase.UNKNOWN, + * import: { + * testing: import.meta.resolve("@libs/testing") + * } + * } as Directive & { name: string, import: Record } + * ``` + */ + readonly import?: Record + /** + * Directive phase. + * + * Directives are executed in ascending order based on their {@linkcode Phase}, which serves as a priority level. + * + * > [!IMPORTANT] + * > Directives with {@linkcode Phase.UNKNOWN} and {@linkcode Phase.META} are ignored by {@linkcode Renderer.load()}. + * > + * > {@linkcode Phase.TESTING} is intended for testing purposes only. + * + * For more information, see the {@link https://mizu.sh/#concept-phase | mizu.sh documentation}. + * + * @example + * ```ts + * const foo = { + * name: "*foo", + * phase: Phase.CONTENT, + * } as Directive & { name: string } + * ``` + */ + readonly phase: Phase + /** + * Indicates whether the directive can be specified multiple times on the same node. + * + * If set to `false`, a warning will be issued to users attempting to apply it more than once. + * + * @example + * ```ts + * const foo = { + * name: /^\/(?)/, + * prefix: "/", + * phase: Phase.UNKNOWN, + * multiple: true + * } as Directive & { name: RegExp; prefix: string } + * ``` + */ + readonly multiple?: boolean + /** + * Typings for directive parsing. + * + * For more information, see {@linkcode Renderer.parseAttribute()}. + * + * @example + * ```ts + * const typings = { + * type: Boolean, + * modifiers: { + * foo: { type: Boolean, default: false }, + * } + * } + * + * const foo = { + * name: "*foo", + * phase: Phase.UNKNOWN, + * typings, + * async execute(renderer, element, { attributes: [ attribute ], ...options }) { + * console.log(renderer.parseAttribute(attribute, this.typings, { modifiers: true })) + * } + * } as Directive & { name: string } + * ``` + */ + readonly typings?: Typings + /** + * Default value. + * + * This value should be used by directive callbacks when the {@linkcode https://developer.mozilla.org/docs/Web/API/Attr/value | Attr.value} is empty. + * + * @example + * ```ts + * const foo = { + * name: "*foo", + * phase: Phase.UNKNOWN, + * default: "bar", + * async execute(renderer, element, { attributes: [ attribute ], ...options }) { + * console.log(attribute.value || this.default) + * } + * } as Directive & { name: string; default: string } + * ``` + */ + readonly default?: string + /** + * Directive initialization callback. + * + * This callback is executed once during when {@linkcode Renderer.load()} loads the directive. + * It should be used to set up dependencies, instantiate directive-specific caches (via {@linkcode Renderer.cache()}), and perform other initialization tasks. + * + * If a cache is instantiated, it is recommended to use the `Directive` generic type to ensure type safety when accessing it in {@linkcode Directive.setup()}, {@linkcode Directive.execute()}, and {@linkcode Directive.cleanup()}. + * + * @example + * ```ts + * const foo = { + * name: "*foo", + * phase: Phase.UNKNOWN, + * async init(renderer) { + * renderer.cache(this.name, new WeakSet()) + * }, + * } as Directive> & { name: string } + * ``` + */ + readonly init?: (renderer: Renderer) => Promisable + /** + * Directive setup callback. + * + * This callback is executed during {@linkcode Renderer.render()} before any {@linkcode Directive.execute()} calls. + * + * If `false` is returned, the entire rendering process for this node is halted. + * + * A partial object can be returned to update the rendering {@linkcode State}. + * + * > [!IMPORTANT] + * > This method is executed regardless of the directive's presence on the node. + * + * @example + * ```ts + * const foo = { + * name: "*foo", + * phase: Phase.UNKNOWN, + * async setup(renderer, element, { cache, context, state }) { + * if ((!renderer.isHtmlElement(element)) || (element.hasAttribute("no-render"))) { + * return false + * } + * }, + * } as Directive & { name: string } + * ``` + */ + readonly setup?: (renderer: Renderer, element: HTMLElement | Comment, _: { cache: Cache; context: Context; state: DeepReadonly; root: InitialContextState }) => Promisable> + /** + * Directive execution callback. + * + * This callback is executed during {@linkcode Renderer.render()} if the rendered node has been marked as eligible. + * + * If `final: true` is returned, the rendering process for this node is stopped (all {@linkcode Directive.cleanup()} will still be called). + * + * A partial object can be returned to update the rendering {@linkcode Context}, {@linkcode State}, and the rendered {@linkcode https://developer.mozilla.org/docs/Web/API/HTMLElement | HTMLElement} (or {@linkcode https://developer.mozilla.org/docs/Web/API/Comment | Comment}). + * + * @example + * ```ts + * const foo = { + * name: "*foo", + * phase: Phase.UNKNOWN, + * async execute(renderer, element, { attributes: [ attribute ], ...options }) { + * console.log(`${await renderer.evaluate(element, attribute.value || "''", options)}`) + * return { state: { $foo: true } } + * }, + * } as Directive & { name: string } + * ``` + */ + readonly execute?: ( + renderer: Renderer, + element: HTMLElement | Comment, + _: { cache: Cache; context: Context; state: DeepReadonly; attributes: Readonly; root: InitialContextState }, + ) => Promisable> + /** + * Directive cleanup callback. + * + * This callback is executed during {@linkcode Renderer.render()} after all {@linkcode Directive.execute()} have been applied and all {@linkcode https://developer.mozilla.org/docs/Web/API/Node/childNodes | Element.childNodes} have been processed. + * + * > [!IMPORTANT] + * > This method is executed regardless of the directive's presence on the node, and regardless of whether a {@linkcode Directive.execute()} returned with `final: true`. + * + * @example + * ```ts + * const foo = { + * name: "*foo", + * phase: Phase.UNKNOWN, + * async cleanup(renderer, element, { cache, context, state }) { + * console.log("Cleaning up") + * } + * } as Directive & { name: string } + * ``` + */ + readonly cleanup?: (renderer: Renderer, element: HTMLElement | Comment, _: { cache: Cache; context: Context; state: DeepReadonly; root: InitialContextState }) => Promisable +} + +/** Extracts the cache type from a {@linkcode Directive}. */ +export type Cache = T extends Directive ? U : never + +/** Extracts the typings values from a {@linkcode Directive}. */ +// deno-lint-ignore no-explicit-any +export type Modifiers = T extends Directive ? InferAttrTypings["modifiers"] : never diff --git a/@mizu/mizu/core/engine/directive_test.ts b/@mizu/mizu/core/engine/directive_test.ts new file mode 100644 index 0000000..0ab47fc --- /dev/null +++ b/@mizu/mizu/core/engine/directive_test.ts @@ -0,0 +1 @@ +import "./directive.ts" diff --git a/@mizu/mizu/core/engine/mod.ts b/@mizu/mizu/core/engine/mod.ts new file mode 100644 index 0000000..59ec9c5 --- /dev/null +++ b/@mizu/mizu/core/engine/mod.ts @@ -0,0 +1,7 @@ +/** + * Mizu engine. + * @module + */ +export * from "./phase.ts" +export * from "./directive.ts" +export * from "./renderer.ts" diff --git a/@mizu/mizu/core/engine/mod_test.ts b/@mizu/mizu/core/engine/mod_test.ts new file mode 100644 index 0000000..1e6ff7d --- /dev/null +++ b/@mizu/mizu/core/engine/mod_test.ts @@ -0,0 +1 @@ +import "./mod_test.ts" diff --git a/@mizu/mizu/core/engine/phase.ts b/@mizu/mizu/core/engine/phase.ts new file mode 100644 index 0000000..dc5fa5a --- /dev/null +++ b/@mizu/mizu/core/engine/phase.ts @@ -0,0 +1,98 @@ +// Imports +import type { Directive as _Directive } from "./directive.ts" + +/** + * Enum representing all possible value that a {@linkcode _Directive | Directive.phase} can have. + * + * For more information, see the {@link https://mizu.sh/#concept-phase | mizu.sh documentation}. + */ +export enum Phase { + /** Placeholder value (intended for internal use only). @internal */ + UNKNOWN = NaN, + + // 0X - Preprocessing ———————————————————————————————————————————————————————————————————————————————— + + /** Directives that contain only metadata. */ + META = 0, + + /** Directives that determine the rendering eligibility of an element. */ + ELIGIBILITY = 1, + + /** Directives that must be executed first as they influence the rendering process. */ + PREPROCESSING = 2, + + // 10 - Testing —————————————————————————————————————————————————————————————————————————————————————— + + /** Placeholder value (intended for testing use only). @internal */ + TESTING = 10, + + // 1X - Context —————————————————————————————————————————————————————————————————————————————————————— + + /** Directives that alter the rendering context. */ + CONTEXT = 11, + + // 2X - Transforms ——————————————————————————————————————————————————————————————————————————————————— + + /** Directives that expand elements. */ + EXPAND = 21, + /** Directives that morph elements. */ + MORPHING = 22, + /** Directives that toggle elements. */ + TOGGLE = 23, + + // 3X - HTTP ————————————————————————————————————————————————————————————————————————————————————————— + + /** HTTP directives that set headers. */ + HTTP_HEADER = 31, + /** HTTP directives that set the body. */ + HTTP_BODY = 32, + /** HTTP directives that perform requests. */ + HTTP_REQUEST = 33, + /** HTTP directives that set content. */ + HTTP_CONTENT = 34, + /** HTTP directives that add interactivity. */ + HTTP_INTERACTIVITY = 35, + + // 4X - Content —————————————————————————————————————————————————————————————————————————————————————— + + /** Directives that set content. */ + CONTENT = 41, + + /** Directives that clean content. */ + CONTENT_CLEANING = 49, + + // 5X - Attributes ——————————————————————————————————————————————————————————————————————————————————— + + /** Directives that set attributes. */ + ATTRIBUTE = 51, + /** Directives that model value attributes. */ + ATTRIBUTE_MODEL_VALUE = 52, + + /** Directives that clean attributes. */ + ATTRIBUTE_CLEANING = 59, + + // 6X - Interactivity ———————————————————————————————————————————————————————————————————————————————— + + /** Directives that enhance element interactivity. */ + INTERACTIVITY = 61, + + // 7X - Styling —————————————————————————————————————————————————————————————————————————————————————— + + /** Directives that affect display. */ + DISPLAY = 71, + + // 8X - Others ——————————————————————————————————————————————————————————————————————————————————————— + + /** Directives that register custom elements. */ + CUSTOM_ELEMENT = 81, + /** Directives that register references. */ + REFERENCE = 82, + + /** Directives that apply custom processing logic. */ + CUSTOM_PROCESSING = 89, + + // 9X - Postprocessing ——————————————————————————————————————————————————————————————————————————————— + + /** Directives that must be executed last as they influence the rendering process. */ + POSTPROCESSING = 99, +} diff --git a/@mizu/mizu/core/engine/phase_test.ts b/@mizu/mizu/core/engine/phase_test.ts new file mode 100644 index 0000000..09ab218 --- /dev/null +++ b/@mizu/mizu/core/engine/phase_test.ts @@ -0,0 +1 @@ +import "./phase.ts" diff --git a/@mizu/mizu/core/engine/renderer.ts b/@mizu/mizu/core/engine/renderer.ts new file mode 100644 index 0000000..b63f2fe --- /dev/null +++ b/@mizu/mizu/core/engine/renderer.ts @@ -0,0 +1,1086 @@ +// Imports +import type { Arg, Arrayable, callback, DeepReadonly, NonVoid, Nullable, Optional } from "@libs/typing/types" +import type { Cache, Directive } from "./directive.ts" +import { escape } from "@std/regexp" +import { AsyncFunction } from "@libs/typing/func" +import { Context } from "@libs/reactive" +import { Phase } from "./phase.ts" +import _mizu from "@mizu/mizu" +export { Context, Phase } +export type { Arg, Arrayable, Cache, callback, Directive, NonVoid, Nullable, Optional } +export type * from "./directive.ts" + +/** + * Mizu directive renderer. + */ +export class Renderer { + /** {@linkcode Renderer} constructor. */ + constructor(window: Window, { directives = [] } = {} as { directives?: Arg }) { + this.window = window as Renderer["window"] + this.cache("*", new WeakMap()) + this.#directives = [] as Directive[] + this.ready = this.load(directives) + } + + /** + * Whether the {@linkcode Renderer} is ready to be used. + * + * This promise resolves once all {@linkcode Directive.init()} methods have been executed for the first time. + */ + readonly ready: Promise + + /** Linked {@linkcode https://developer.mozilla.org/docs/Web/API/Window | Window}. */ + readonly window: Window & VirtualWindow + + /** Linked {@linkcode https://developer.mozilla.org/docs/Web/API/Document | Document}. */ + get document(): Document { + return this.window.document + } + + /** Internal cache registries. */ + readonly #cache = new Map() + + /** + * Retrieve {@linkcode Directive}-specific cache registry. + * + * Directive-specific caches can be used to store related data. + * These are automatically exposed by {@linkcode Renderer.render()} during {@linkcode Directive.setup()}, {@linkcode Directive.execute()} and {@linkcode Directive.cleanup()} executions. + * + * @example + * ```ts + * import { Window } from "@mizu/mizu/core/vdom" + * + * const directive = { + * name: "*foo", + * phase: Phase.TESTING, + * init(renderer) { + * if (!renderer.cache(this.name)) { + * renderer.cache>(this.name, new WeakSet()) + * } + * }, + * setup(renderer, element, { cache }) { + * console.assert(cache instanceof WeakSet) + * console.assert(renderer.cache(directive.name) instanceof WeakSet) + * cache.add(element) + * } + * } as Directive> & { name: string } + * + * const renderer = await new Renderer(new Window(), { directives: [directive] }).ready + * const element = renderer.createElement("div", { attributes: { "*foo": "" } }) + * await renderer.render(element) + * console.assert(renderer.cache>(directive.name).has(element)) + * ``` + */ + cache(directive: Directive["name"]): T + /** + * Set {@linkcode Directive}-specific cache registry. + * + * These are expected to be initialized by {@linkcode Renderer.load()} during {@linkcode Directive.init()} execution if a cache is needed. + * + * @example + * ```ts + * import { Window } from "@mizu/mizu/core/vdom" + * + * const directive = { + * name: "*foo", + * phase: Phase.TESTING, + * init(renderer) { + * renderer.cache(this.name, new WeakSet()) + * }, + * } as Directive> & { name: string } + * + * const renderer = await new Renderer(new Window(), { directives: [directive] }).ready + * console.assert(renderer.cache(directive.name) instanceof WeakSet) + * ``` + */ + cache(directive: Directive["name"], cache: T): T + /** + * Retrieve generic cache registry. + * + * This cache is automatically created upon {@linkcode Renderer} instantiation. + * It is shared between all {@linkcode Renderer.directives} and is mostly used to check whether a node was already processed, + * and to map back {@linkcode https://developer.mozilla.org/docs/Web/API/Comment | Comment} nodes to their original {@linkcode https://developer.mozilla.org/docs/Web/API/HTMLElement | HTMLElement} if they were replaced by {@linkcode Renderer.comment()}. + * + * This cache should not be used to store {@linkcode Directive}-specific data. + * + * @example + * ```ts + * import { Window } from "@mizu/mizu/core/vdom" + * const renderer = await new Renderer(new Window()).ready + * + * console.assert(renderer.cache("*") instanceof WeakMap) + * ``` + */ + cache(directive: "*"): WeakMap + cache(directive: Directive["name"], cache?: T): Nullable { + if (cache && (!this.#cache.has(directive))) { + this.#cache.set(directive, cache) + } + return this.#cache.get(directive) as Optional ?? null + } + + /** + * {@linkcode Directive} list. + * + * It contains any `Directive` that was registered during {@linkcode Renderer} instantiation. + */ + #directives + + /** {@linkcode Directive} list. */ + get directives(): Readonly { + return [...this.#directives] + } + + /** + * Load additional {@linkcode Directive}s. + * + * A `Directive` needs to have both a valid {@linkcode Directive.phase} and {@linkcode Directive.name} to be valid. + * If a `Directive` with the same name already exists, it is ignored. + * + * It is possible to dynamically {@linkcode https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import | import()} a `Directive` by passing a `string` instead. + * Note that in this case the resolved module must have an {@linkcode https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export#using_the_default_export | export default} statement. + * + * @example + * ```ts + * import { Window } from "@mizu/mizu/core/vdom" + * const renderer = await new Renderer(new Window()).ready + * + * const directive = { + * name: "*foo", + * phase: Phase.TESTING + * } as Directive & { name: string } + * + * await renderer.load([directive, import.meta.resolve("@mizu/test")]) + * console.assert(renderer.directives.includes(directive)) + * ``` + */ + async load(directives: Arrayable | string>>): Promise { + const loaded = (await Promise.all>(([directives].flat(Infinity) as Array) + .map(async (directive) => typeof directive === "string" ? (await import(directive)).default : directive))) + .flat(Infinity) as Array + for (const directive of loaded) { + if ((!directive?.name) || (!Number.isFinite(directive?.phase)) || (Number(directive?.phase) < 0)) { + const object = JSON.stringify(directive, (_, value) => (value instanceof Function) ? `[[Function]]` : value) + throw new SyntaxError(`Failed to load directive: Malformed directive: ${object}`) + } + if (directive.phase === Phase.META) { + continue + } + if (this.#directives.some((existing) => `${existing.name}` === `${directive.name}`)) { + this.warn(`Directive "${directive.name}" is already loaded, skipping`) + continue + } + await directive.init?.(this) + this.#directives.push(directive as Directive) + } + this.#directives.sort((a, b) => a.phase - b.phase) + return this + } + + /** + * Internal identifier prefix. + * + * This is used to avoid conflicts with user-defined variables in expressions. + */ + static readonly internal = "__mizu_internal" as const + + /** Alias to {@linkcode Renderer.internal}. */ + get #internal() { + return (this.constructor as typeof Renderer).internal + } + + /** + * Generate an internal identifier for specified name by prefixing it with {@linkcode Renderer.internal}. + * + * When creating internal variables or functions in expressions, this method should always be used to name them. + * It ensures that they won't collide with end-user-defined variables or functions. + * + * @example + * ```ts + * import { Window } from "@mizu/mizu/core/vdom" + * const renderer = await new Renderer(new Window()).ready + * + * console.assert(renderer.internal("foo").startsWith(`${Renderer.internal}_`)) + * ``` + */ + internal(name: string): `${typeof Renderer.internal}_${string}` + /** + * Retrieve {@linkcode Renderer.internal} prefix. + * + * @example + * ```ts + * import { Window } from "@mizu/mizu/core/vdom" + * const renderer = await new Renderer(new Window()).ready + * + * console.assert(renderer.internal() === Renderer.internal) + * ``` + */ + internal(): typeof Renderer.internal + internal(name = "") { + return `${this.#internal}${name ? `_${name}` : ""}` + } + + /** + * Internal expressions cache. + * + * This is used to store compiled expressions for faster evaluation. + */ + readonly #expressions = new WeakMap>>() + + /** + * Evaluate an expression with given {@linkcode https://developer.mozilla.org/docs/Web/API/HTMLElement | HTMLElement} (or {@linkcode https://developer.mozilla.org/docs/Web/API/Comment | Comment}), {@linkcode Context}, {@linkcode State} and arguments. + * + * Passed `HTMLElement` or `Comment` can be accessed through the `this` keyword in the expression. + * + * Both `context` and `state` are exposed through {@linkcode https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/with | with} statements, + * meaning that their properties can be accessed directly in the expression without prefixing them. + * The difference between both is that the latter is not reactive and is intended to be used for specific stateful data added by a {@linkcode Directive}. + * + * If `args` is provided and the evaluated expression is {@link https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Function/call | callable}, + * it will be called with them and its result is returned instead. + * + * > [!NOTE] + * > The root {@linkcode Renderer.internal} prefix is used internally to manage evaluation state, and thus cannot be used as a variable name. + * + * @example + * ```ts + * import { Window } from "@mizu/mizu/core/vdom" + * const renderer = await new Renderer(new Window()).ready + * + * console.assert(await renderer.evaluate(null, "1 + 1") === 2) + * console.assert(await renderer.evaluate(null, "foo => foo", { args: ["bar"] }) === "bar") + * console.assert(await renderer.evaluate(null, "$foo", { state: { $foo: "bar" } }) === "bar") + * ``` + */ + async evaluate(that: Nullable, expression: string, { context = new Context(), state = {}, args } = {} as { context?: Context; state?: State; args?: unknown[] }): Promise { + if (this.#internal in context.target) { + throw new TypeError(`"${this.#internal}" is a reserved variable name`) + } + const these = that ?? this + if (!this.#expressions.get(these)?.[expression]) { + const cache = (!this.#expressions.has(these) ? this.#expressions.set(these, {}) : this.#expressions).get(these)! + cache[expression] = new AsyncFunction( + this.#internal, + `with(${this.#internal}.state){with(${this.#internal}.context){${this.#internal}.result=${expression};if(${this.#internal}.args)${this.#internal}.result=${this.#internal}.result?.call?.(this,...${this.#internal}.args)}}return ${this.#internal}.result`, + ) + } + const compiled = this.#expressions.get(these)![expression] + const internal = { this: that, context: context.target, state, args, result: undefined } + return await compiled.call(that, internal) + } + + /** + * Render {@linkcode https://developer.mozilla.org/docs/Web/API/Element | Element} and its subtree with specified {@linkcode Context} and {@linkcode State} against {@linkcode Renderer.directives}. + * + * Set `implicit: false` to to filter out subtrees that do not possess the explicit rendering attribute {@linkcode _mizu.name}. + * + * @example + * ```ts + * import { Window } from "@mizu/mizu/core/vdom" + * import _test from "@mizu/test" + * const renderer = await new Renderer(new Window(), { directives: [ _test ] }).ready + * const element = renderer.createElement("div", { attributes: { "~test.text": "foo" } }) + * + * const result = await renderer.render(element, { context: new Context({ foo: "bar" }) }) + * console.assert(result.textContent === "bar") + * ``` + */ + async render(element: T, options?: { context?: Context; state?: State; implicit?: boolean }): Promise + /** + * Render {@linkcode https://developer.mozilla.org/docs/Web/API/Element | Element} and its subtree with specified {@linkcode Context} and {@linkcode State} against {@linkcode Renderer.directives} and {@link https://developer.mozilla.org/docs/Web/API/Document/querySelector | query select} the return using a {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_selectors | CSS selector}. + * + * Set `implicit: false` to to filter out subtrees that do not possess the explicit rendering attribute {@linkcode _mizu.name}. + * + * @example + * ```ts + * import { Window } from "@mizu/mizu/core/vdom" + * import _test from "@mizu/test" + * const renderer = await new Renderer(new Window(), { directives: [ _test ] }).ready + * const element = renderer.createElement("div", { innerHTML: renderer.createElement("span", { attributes: { "~test.text": "foo" } }).outerHTML }) + * + * const result = await renderer.render(element, { context: new Context({ foo: "bar" }), select: "span" }) + * console.assert(result?.tagName === "SPAN") + * console.assert(result?.textContent === "bar") + * ``` + */ + async render(element: HTMLElement, options?: { context?: Context; state?: State; implicit?: boolean; select: string }): Promise> + /** + * Render {@linkcode https://developer.mozilla.org/docs/Web/API/Element | Element} and its subtree with specified {@linkcode Context} and {@linkcode State} against {@linkcode Renderer.directives} and returns it as an HTML string. + * + * Set `implicit: false` to to filter out subtrees that do not possess the explicit rendering attribute {@linkcode _mizu.name}. + * + * Set `select` to {@link https://developer.mozilla.org/docs/Web/API/Document/querySelector | query select} the return using a {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_selectors | CSS selector} + * + * @example + * ```ts + * import { Window } from "@mizu/mizu/core/vdom" + * import _test from "@mizu/test" + * const renderer = await new Renderer(new Window(), { directives: [ _test ] }).ready + * const element = renderer.createElement("div", { attributes: { "~test.text": "foo" } }) + * + * const result = await renderer.render(element, { context: new Context({ foo: "bar" }), stringify: true }) + * console.assert(result.startsWith("")) + * ``` + */ + async render(element: HTMLElement, options?: { context?: Context; state?: State; implicit?: boolean; select?: string; stringify?: boolean }): Promise + async render(element = this.document.documentElement, { context = new Context(), state = {}, implicit = true, select = "", stringify = false } = {} as { context?: Context; state?: State; implicit?: boolean; select?: string; stringify?: boolean }) { + await this.ready + let subtrees = implicit || (element.hasAttribute(_mizu.name)) ? [element] : Array.from(element.querySelectorAll(`[${escape(_mizu.name)}]`)) + subtrees = subtrees.filter((element) => subtrees.every((parent) => (parent === element) || (!parent.contains(element)))) + await Promise.allSettled(subtrees.map((element) => this.#render(element, { context, state, root: { context, state } }))) + const result = select ? element.querySelector(select) : element + if (stringify) { + const html = result?.outerHTML ?? "" + return select ? html : `${html}` + } + return result as T + } + + /** + * Used by {@linkcode Renderer.render()} to recursively render an {@linkcode https://developer.mozilla.org/docs/Web/API/Element | Element} and its subtree. + * + * Rendering process is defined as follows: + * - 1. Ensure `element` is an {@linkcode https://developer.mozilla.org/docs/Web/API/Element | Element} node (or a {@linkcode https://developer.mozilla.org/docs/Web/API/Comment | Comment} node created by {@linkcode Renderer.comment()}). + * - 1.1 If not, end the process. + * - 2. For each {@linkcode Renderer.directives}: + * - 2.1 Call {@linkcode Directive.setup()}. + * - 2.1.1 If `false` is returned, end the process. + * - 2.1.2 If `state` is returned, update accordingly. + * - 3. Retrieve source {@linkcode https://developer.mozilla.org/docs/Web/API/Element | Element} node from {@linkcode Renderer.cache()} (if applicable). + * - 3.1 This occurs when `element` is a {@linkcode https://developer.mozilla.org/docs/Web/API/Comment | Comment} node. + * - 4. For each {@linkcode Renderer.directives}: + * - 4.1 Check if source node is elligible and has at least one matching {@linkcode https://developer.mozilla.org/docs/Web/API/Attr | Attr}. + * - 4.1.1 If not, continue to the next directive. + * - 4.2 Notify any misuses: + * - 4.2.1 If current {@linkcode Directive.phase} has already been processed (conflicts). + * - 4.2.2 If current {@linkcode Directive.multiple} is not set but more than one matching {@linkcode https://developer.mozilla.org/docs/Web/API/Attr | Attr} is found (duplicates). + * - 4.3 Call {@linkcode Directive.execute()} with `element` + * - 4.3.1 If `element` is returned, update accordingly. + * - 4.3.2 If `final: true` is returned, end the process (it occurs after `element` update to ensure that {@linkcode Directive.cleanup()} is called with correct target). + * - 4.3.3 If `context` or `state` is returned, update accordingly. + * - 5. Recurse on {@linkcode https://developer.mozilla.org/docs/Web/API/Node/childNodes | Element.childNodes}. + * - 6. For each {@linkcode Renderer.directives}: + * - 6.1 Call {@linkcode Directive.cleanup()}. + */ + async #render(element: HTMLElement | Comment, { context, state, root }: { context: Context; state: State; root: InitialContextState }) { + // 1. Ignore non-element nodes unless they were processed before and put into cache + if ((element.nodeType !== this.window.Node.ELEMENT_NODE) && (!this.cache("*").has(element))) { + return + } + + // 2. Setup directives + for (const directive of this.#directives) { + const changes = await directive.setup?.(this, element, { cache: this.cache(directive.name), context, state, root }) + if (changes === false) { + return + } + if (changes?.state) { + state = { ...state, ...changes.state } + } + } + + // 3. Retrieve source element + const source = this.cache("*").get(element) ?? element + + // 4. Execute directives + try { + const phases = new Map() + for (const directive of this.#directives) { + // 4.1 Check eligibility + const attributes = this.getAttributes(source, directive.name) + if (!attributes.length) { + continue + } + // 4.2 Notify misuses + if (phases.has(directive.phase)) { + this.warn(`Using [${directive.name}] and [${phases.get(directive.phase)}] directives together might result in unexpected behaviour`, element) + } + if ((attributes.length > 1) && (!directive.multiple)) { + this.warn(`Using multiple [${directive.name}] directives might result in unexpected behaviour`, element) + } + // 4.3 Execute directive + phases.set(directive.phase, directive.name) + const changes = await directive.execute?.(this, element, { cache: this.cache(directive.name), context, state, attributes, root }) + if (changes?.element) { + element = changes.element + } + if (changes?.final) { + return + } + if (changes?.context) { + context = changes.context + } + if (changes?.state) { + state = { ...state, ...changes.state } + } + } + // 5. Recurse on child nodes + for (const child of Array.from(element.childNodes) as Array) { + await this.#render(child, { context, state, root }) + } + } finally { + // 6. Cleanup directives + for (const directive of this.#directives) { + await directive.cleanup?.(this, element, { cache: this.cache(directive.name), context, state, root }) + } + } + } + + /** + * Create a new {@linkcode https://developer.mozilla.org/docs/Web/API/HTMLElement | HTMLElement} within {@linkcode Renderer.document}. + * + * It is possible to specify additional properties that will be assigned to the element. + * The `attributes` property is handled by {@linkcode Renderer.setAttribute()} which allows to set attributes with non-standard characters. + * + * @example + * ```ts + * import { Window } from "@mizu/mizu/core/vdom" + * const renderer = await new Renderer(new Window()).ready + * + * const element = renderer.createElement("div", { innerHTML: "foo", attributes: { "*foo": "bar" } }) + * console.assert(element.tagName === "DIV") + * console.assert(element.innerHTML === "foo") + * console.assert(element.getAttribute("*foo") === "bar") + * ``` + */ + createElement(tagname: string, properties = {} as Record): T { + const { attributes = {}, ...rest } = properties + const element = Object.assign(this.document.createElement(tagname), rest) + Object.entries(attributes as Record).forEach(([name, value]) => this.setAttribute(element, name, `${value}`)) + return element as unknown as T + } + + /** + * Replace a {@linkcode https://developer.mozilla.org/docs/Web/API/HTMLElement | HTMLElement} with another {@linkcode https://developer.mozilla.org/fr/docs/Web/API/Node/childNodes | HTMLElement.childNodes}. + * + * Note that the `HTMLElement` is entirely replaced, meaning that is is actually removed from the DOM. + * + * @example + * ```ts + * import { Window } from "@mizu/mizu/core/vdom" + * const renderer = await new Renderer(new Window()).ready + * const parent = renderer.createElement("div") + * const slot = parent.appendChild(renderer.createElement("slot")) as HTMLSlotElement + * const content = renderer.createElement("div", { innerHTML: "foobar" }) + * + * renderer.replaceElementWithChildNodes(slot, content) + * console.assert(parent.innerHTML === "foobar") + * ``` + */ + replaceElementWithChildNodes(a: HTMLElement, b: HTMLElement) { + let position = a as HTMLElement + for (const child of Array.from(b.cloneNode(true).childNodes) as HTMLElement[]) { + a.parentNode?.insertBefore(child, position.nextSibling) + position = child + } + a.remove() + } + + /** + * Internal {@linkcode https://developer.mozilla.org/en-US/docs/Web/API/Comment | Comment} registry. + * + * It is used to map {@linkcode https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement | HTMLElement} to their `Comment` replacement. + */ + readonly #comments = new WeakMap() + + /** + * Replace a {@linkcode https://developer.mozilla.org/docs/Web/API/HTMLElement | HTMLElement} by a {@linkcode https://developer.mozilla.org/en-US/docs/Web/API/Comment | Comment}. + * + * Specified `directive` and `expression` are used to set {@linkcode https://developer.mozilla.org/docs/Web/API/Node/nodeValue | Comment.nodeValue} and help identify which {@linkcode Directive} performed the replacement. + * + * Use {@linkcode Renderer.uncomment()} to restore the original `HTMLElement`. + * Original `HTMLElement` can be retrieved through the generic {@linkcode Renderer.cache()}. + * If you hold a reference to a replaced `HTMLElement`, use {@linkcode Renderer.getComment()} to retrieve the replacement `Comment`. + * + * @example + * ```ts + * import { Window } from "@mizu/mizu/core/vdom" + * const renderer = await new Renderer(new Window()).ready + * const parent = renderer.createElement("div") + * const element = parent.appendChild(renderer.createElement("div")) + * + * const comment = renderer.comment(element, { directive: "foo", expression: "bar" }) + * console.assert(!parent.contains(element)) + * console.assert(parent.contains(comment)) + * console.assert(renderer.cache("*").get(comment) === element) + * ``` + */ + comment(element: HTMLElement, { directive, expression }: { directive: string; expression: string }): Comment { + const attributes = this.createNamedNodeMap() + attributes.setNamedItem(this.createAttribute(directive, expression)) + const comment = Object.assign(this.document.createComment(`[${directive}="${expression}"]`), { attributes }) + this.cache("*").set(comment, element) + this.#comments.set(element, comment) + element.parentNode?.replaceChild(comment, element) + return comment + } + + /** + * Replace {@linkcode https://developer.mozilla.org/en-US/docs/Web/API/Comment | Comment} by restoring its original {@linkcode https://developer.mozilla.org/docs/Web/API/HTMLElement | HTMLElement}. + * + * Calling this method on a `Comment`that was not created by {@linkcode Renderer.comment()} will throw a {@linkcode https://developer.mozilla.org/docs/Web/API/ReferenceError | ReferenceError}. + * + * @example + * ```ts + * import { Window } from "@mizu/mizu/core/vdom" + * const renderer = await new Renderer(new Window()).ready + * const parent = renderer.createElement("div") + * const element = parent.appendChild(renderer.createElement("div")) + * const comment = renderer.comment(element, { directive: "foo", expression: "bar" }) + * + * renderer.uncomment(comment) + * console.assert(!parent.contains(comment)) + * console.assert(parent.contains(element)) + * console.assert(!renderer.cache("*").has(comment)) + * ``` + */ + uncomment(comment: Comment): HTMLElement { + if (!this.cache("*").has(comment)) { + throw new ReferenceError(`Tried to uncomment an element that is not present in cache`) + } + const element = this.cache("*").get(comment)! + comment.parentNode?.replaceChild(element, comment) + this.cache("*").delete(comment) + this.#comments.delete(element) + return element + } + + /** + * Retrieve the {@linkcode https://developer.mozilla.org/docs/Web/API/Comment | Comment} associated with an {@linkcode https://developer.mozilla.org/docs/Web/API/HTMLElement | HTMLElement} replaced by {@linkcode Renderer.comment()}. + * + * @example + * ```ts + * import { Window } from "@mizu/mizu/core/vdom" + * const renderer = await new Renderer(new Window()).ready + * + * const element = renderer.document.documentElement.appendChild(renderer.createElement("div")) + * const comment = renderer.comment(element, { directive: "foo", expression: "bar" }) + * console.assert(renderer.getComment(element) === comment) + * ``` + */ + getComment(element: HTMLElement): Nullable { + return this.#comments.get(element) ?? null + } + + /** + * Create a new {@linkcode https://developer.mozilla.org/docs/Web/API/NamedNodeMap | NamedNodeMap}. + * + * This bypasses the illegal constructor check. + * + * @example + * ```ts + * import { Window } from "@mizu/mizu/core/vdom" + * const renderer = await new Renderer(new Window()).ready + * + * const nodemap = renderer.createNamedNodeMap() + * console.assert(nodemap.constructor.name.includes("NamedNodeMap")) + * ``` + */ + createNamedNodeMap(): NamedNodeMap { + const div = this.createElement("div") + return div.attributes + } + + /** + * Internal {@linkcode https://developer.mozilla.org/docs/Web/API/Attr | Attr} cache. + * + * It is used to store `Attr` instances so they can be duplicated with {@linkcode https://developer.mozilla.org/docs/Web/API/Node/cloneNode | Attr.cloneNode()} without having to create a new one each time. + */ + readonly #attributes = {} as Record + + /** + * Create a new {@linkcode https://developer.mozilla.org/docs/Web/API/Attr | Attr}. + * + * This bypasses the attribute name validation check. + * + * @example + * ```ts + * import { Window } from "@mizu/mizu/core/vdom" + * const renderer = await new Renderer(new Window()).ready + * + * const attribute = renderer.createAttribute("*foo", "bar") + * console.assert(attribute.name === "*foo") + * console.assert(attribute.value === "bar") + * ``` + */ + createAttribute(name: string, value = ""): Attr { + let attribute = this.#attributes[name]?.cloneNode() as Attr + try { + attribute ??= this.document.createAttribute(name) + } catch { + this.#attributes[name] ??= (this.createElement("div", { innerHTML: `
` }).firstChild! as HTMLElement).attributes[0] + attribute = this.#attributes[name].cloneNode() as Attr + } + attribute.value = value + return attribute + } + + /** + * Set an {@linkcode https://developer.mozilla.org/docs/Web/API/Attr | Attr} on a {@linkcode https://developer.mozilla.org/docs/Web/API/HTMLElement | HTMLElement} + * or updates a {@linkcode https://developer.mozilla.org/docs/Web/API/Node/nodeValue | Comment.nodeValue}. + * + * @example + * ```ts + * import { Window } from "@mizu/mizu/core/vdom" + * const renderer = await new Renderer(new Window()).ready + * + * const element = renderer.createElement("div") + * renderer.setAttribute(element, "*foo", "bar") + * console.assert(element.getAttribute("*foo") === "bar") + * + * const comment = renderer.comment(element, { directive: "foo", expression: "bar" }) + * renderer.setAttribute(comment, "*foo", "bar") + * console.assert(comment.nodeValue?.includes(`[*foo="bar"]`)) + * ``` + */ + setAttribute(element: HTMLElement | Comment, name: string, value = "") { + switch (element.nodeType) { + case this.window.Node.COMMENT_NODE: { + element = element as Comment + const tag = `[${name}="${value.replaceAll('"', """)}"]` + if (!element.nodeValue!.includes(tag)) { + element.nodeValue += ` ${tag}` + } + break + } + case this.window.Node.ELEMENT_NODE: { + element = element as HTMLElement + const attribute = Array.from(element.attributes).find((attribute) => attribute.name === name) + if (!attribute) { + element.attributes.setNamedItem(this.createAttribute(name, value)) + break + } + attribute.value = value + break + } + } + } + + /** A collection of {@linkcode https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp | RegExp} used by {@linkcode Renderer.getAttributes()} and {@linkcode Renderer.parseAttribute()}. */ + readonly #extractor = { + attribute: /^(?:(?:(?\S*?)\{(?\S+?)\})|(?[^{}]\S*?))(?:\[(?\S+?)\])?(?:\.(?\S+))?$/, + modifier: /^(?\S*?)(?:\[(?\S*)\])?$/, + boolean: /^(?yes|on|true)|(?no|off|false)$/, + duration: /^(?(?:\d+)|(?:\d*\.\d+))(?(?:ms|s|m)?)$/, + } as const + + /** + * Retrieve all matching {@linkcode https://developer.mozilla.org/docs/Web/API/Attr | Attr} from an {@linkcode https://developer.mozilla.org/docs/Web/API/HTMLElement | HTMLElement}. + * + * It is designed to handle attributes that follows the syntax described in {@linkcode Renderer.parseAttribute()}. + * + * @example + * ```ts + * import { Window } from "@mizu/mizu/core/vdom" + * const renderer = await new Renderer(new Window()).ready + * + * const element = renderer.createElement("div", { attributes: { "*foo.modifier[value]": "bar" } }) + * console.assert(renderer.getAttributes(element, "*foo").length === 1) + * ``` + */ + getAttributes(element: Optional, names: Arrayable | RegExp, options?: { first: false }): Attr[] + /** + * Retrieve the first matching {@linkcode https://developer.mozilla.org/docs/Web/API/Attr | Attr} from an {@linkcode https://developer.mozilla.org/docs/Web/API/HTMLElement | HTMLElement}. + * + * It is designed to handle attributes that follows the syntax described in {@linkcode Renderer.parseAttribute()}. + * If no matching `Attr` is found, `null` is returned. + * + * @example + * ```ts + * import { Window } from "@mizu/mizu/core/vdom" + * const renderer = await new Renderer(new Window()).ready + * + * const element = renderer.createElement("div", { attributes: { "*foo.modifier[value]": "bar" } }) + * console.assert(renderer.getAttributes(element, "*foo", { first: true })?.value === "bar") + * console.assert(renderer.getAttributes(element, "*bar", { first: true }) === null) + * ``` + */ + getAttributes(element: Optional, names: Arrayable | RegExp, options: { first: true }): Nullable + getAttributes(element: Optional, names: Arrayable | RegExp, { first = false } = {}): Attr[] | Nullable { + const attributes = [] + if (element && (this.isHtmlElement(element))) { + if (!(names instanceof RegExp)) { + names = [names].flat() + } + for (const attribute of Array.from(element.attributes)) { + const { a: _a, b: _b, name: name = `${_a}${_b}` } = attribute.name.match(this.#extractor.attribute)!.groups! + if (((names as string[]).includes?.(name)) || ((names as RegExp).test?.(name))) { + attributes.push(attribute) + if (first) { + break + } + } + } + } + return first ? attributes[0] ?? null : attributes + } + + /** + * Parse an {@linkcode https://developer.mozilla.org/docs/Web/API/Attr | Attr} from an {@linkcode https://developer.mozilla.org/docs/Web/API/HTMLElement | HTMLElement}. + * + * Prefixes can automatically be stripped from the attribute name by setting the `prefix` option. + * It is especially useful when `Attr` were extracted with a `RegExp` matching. + * + * The `typings` descriptor is passed to enforce types and validate values on {@linkcode https://developer.mozilla.org/en-US/docs/Web/API/Attr/value | Attr.value} and modifiers. + * The following {@linkcode AttrTypings} are supported: + * - {@linkcode AttrBoolean}, matching `BOOLEAN` token described below. + * - {@linkcode AttrBoolean.default} is `true` + * - {@linkcode AttrNumber}, matching `NUMBER` token described below. + * - {@linkcode AttrNumber.default} is `0`. + * - {@linkcode AttrNumber.integer} will round the value to the nearest integer. + * - {@linkcode AttrNumber.min} will clamp the value to a minimum value. + * - {@linkcode AttrNumber.max} will clamp the value to a maximum value. + * - {@linkcode AttrDuration}, matching `DURATION` described below. + * - {@linkcode AttrDuration.default} is `0`. + * - Value is normalized to milliseconds. + * - Value is clamped to a minimum of 0, and is rounded to the nearest integer. + * - {@linkcode AttrString} (the default), matching `STRING` token described below. + * - {@linkcode AttrString.default} is `""`, or first {@linkcode AttrString.allowed} value if set. + * - {@linkcode AttrString.allowed} will restrict the value to a specific set of strings, in a similar fashion to an enum. + * + * > [!IMPORTANT] + * > A {@linkcode AttrAny.default} is only applied when a key has been explicitly defined but its value was not. + * > Use {@linkcode AttrAny.enforce} to force the default value to be applied event if the key was not defined. + * > + * > ```ts ignore + * > import { Window } from "@mizu/mizu/core/vdom" + * > const renderer = await new Renderer(new Window()).ready + * > const modifier = (attribute: Attr, typing: AttrBoolean) => renderer.parseAttribute(attribute, { modifiers: { value: typing } }, { modifiers: true }).modifiers + * > const [a, b] = Array.from(renderer.createElement("div", { attributes: { "*a.value": "", "*b": "" } }).attributes) + * > + * > // `a.value === true` because it was defined, and the default for `AttrBoolean` is `true` + * > console.assert(modifier(a, { type: Boolean }).value === true) + * > // `a.value === false` because it was defined, and the default was set to `false` + * > console.assert(modifier(a, { type: Boolean, default: false }).value === false) + * > + * > // `b.value === undefined` because it was not explicitly defined, despite the default for `AttrBoolean` being `true` + * > console.assert(modifier(b, { type: Boolean }).value === undefined) + * > // `b.value === true` because it was not explicitly defined, but the default was enforced + * > console.assert(modifier(b, { type: Boolean, enforce: true }).value === true) + * > // `b.value === false` because it was not explicitly defined, and the default was set to `false` and was enforced + * > console.assert(modifier(b, { type: Boolean, default: false, enforce: true }).value === false) + * > ``` + * > + * + * > [!NOTE] + * > Modifiers parsing must be explicitly enabled with `modifiers: true`. + * > This is to prevent unnecessary parsing when modifiers are not needed. + * + * Supported syntax is described below. + * Please note that this syntax is still ruled by {@link https://html.spec.whatwg.org/#attributes-2 | HTML standards} and might not be fully accurate or subject to limitations. + * It is advised to refrain from using especially complex {@linkcode https://developer.mozilla.org/en-US/docs/Web/API/Attr/name | Attr.name} that contain specials characters or structures that may prove both confusing and challenging to parse. + * + * > ``` + * > ┌────────┬──────┬─────┬───────────┬─────┬───────┐ + * > │ PREFIX │ NAME │ TAG │ MODIFIERS │ '=' │ VALUE │ + * > └────────┴──────┴─────┴───────────┴─────┴───────┘ + * > + * > PREFIX + * > └─[ PREFIX]── STRING + * > + * > NAME + * > ├─[ ESCAPED]── STRING '{' STRING '}' + * > └─[ UNDOTTED]── [^.] + * > + * > TAG + * > ├─[ TAG]── '[' STRING ']' + * > └─[ NONE]── ∅ + * > + * > MODIFIERS + * > ├─[ MODIFIER]── '.' MODIFIER MODIFIERS + * > └─[ NONE]── ∅ + * > + * > MODIFIER + * > ├─[ KEY]── STRING + * > └─[KEY+VALUE]── STRING '[' MODIFIER_VALUE ']' + * > + * > MODIFIER_VALUE + * > └─[ VALUE]── BOOLEAN | NUMBER | DURATION | STRING + * > + * > BOOLEAN + * > ├─[ TRUE]── 'yes' | 'on' | 'true' + * > └─[ FALSE]── 'no' | 'off' | 'false' + * > + * > NUMBER + * > ├─[EXPL_NEG_N]── '-' POSITIVE_NUMBER + * > ├─[EXPL_POS_N]── '+' POSITIVE_NUMBER + * > └─[IMPL_POS_N]── POSITIVE_NUMBER + * > + * > POSITIVE_NUMBER + * > ├─[ INTEGER]── [0-9] + * > ├─[EXPL_FLOAT]── [0-9] '.' [0-9] + * > └─[IMPL_FLOAT]── '.' [0-9] + * > + * > DURATION + * > └─[ DURATION]── POSITIVE_NUMBER DURATION_UNIT + * > + * > DURATION_UNIT + * > ├─[ MINUTES]── 'm' + * > ├─[ SECONDS]── 's' + * > ├─[EXPL_MILLI]── 'ms' + * > └─[IMPL_MILLI]── ∅ + * > + * > STRING + * > └─[ ANY]── [*] + * > ``` + * + * @example + * ```ts + * import { Window } from "@mizu/mizu/core/vdom" + * const renderer = await new Renderer(new Window()).ready + * const element = renderer.createElement("div", { attributes: { "*foo.bar[baz]": "true" } }) + * const [attribute] = Array.from(element.attributes) + * + * let parsed + * const typings = { type: Boolean, modifiers: { bar: { type: String } } } + * + * parsed = renderer.parseAttribute(attribute, typings, { modifiers: true }) + * console.assert(parsed.name === "*foo") + * console.assert(parsed.value === true) + * console.assert(parsed.modifiers.bar === "baz") + * + * parsed = renderer.parseAttribute(attribute, typings, { modifiers: false, prefix: "*" }) + * console.assert(parsed.name === "foo") + * console.assert(parsed.value === true) + * console.assert(!("modifiers" in parsed)) + * ``` + * + * @example + * ```ts + * const typedef = { + * // "yes", "on", "true", "no", "off", "false" + * boolean: { type: Boolean, default: false }, + * // "0", "3.1415", ".42", "69", etc. + * number: { type: Number, default: 0, min: -Infinity, max: Infinity, integer: false }, + * // "10", "10ms", "10s", "10m", etc. + * duration: { type: Date, default: "0ms" }, + * // "foo", "bar", "foobar", etc. + * string: { type: String, default: "" }, + * // "foo", "bar" + * enum: { type: String, get default() { return this.allowed[0] } , allowed: ["foo", "bar"] }, + * } + * ``` + */ + parseAttribute(attribute: Attr, typings?: Nullable, options?: { modifiers: true; prefix?: string }): InferAttrTypings + /** + * Same as {@linkcode Renderer.parseAttribute()} but without modifiers. + */ + parseAttribute(attribute: Attr, typings?: Nullable, options?: { modifiers?: false; prefix?: string }): Omit, "modifiers"> + parseAttribute(attribute: Attr, typings?: Nullable, { modifiers = false, prefix = "" } = {}) { + // Parse attribute name + if (!this.#parsed.has(attribute)) { + const { a: _a, b: _b, name = `${_a}${_b}`, tag = "", modifiers: _modifiers = "" } = attribute.name.match(this.#extractor.attribute)!.groups! + const cached = { name, tag, modifiers: {} as Record } + if (modifiers && (typings?.modifiers)) { + const modifiers = Object.fromEntries( + _modifiers.split(".").map((modifier) => { + const { key, value } = modifier.match(this.#extractor.modifier)!.groups! + if (key in typings.modifiers!) { + return [key, value ?? ""] + } + return null + }).filter((modifier): modifier is [string, string] => modifier !== null), + ) + for (const key in typings.modifiers) { + cached.modifiers[key] = this.#parseAttributeValue(attribute.parentElement, name, key, modifiers[key], typings.modifiers[key]) + } + } + this.#parsed.set(attribute, cached) + } + const parsed = structuredClone(this.#parsed.get(attribute)) as InferAttrTypings + // Update values that might have changed since the last parsing or options + parsed.value = this.#parseAttributeValue(attribute.parentElement, parsed.name, "value", attribute.value, typings as AttrAny) as typeof parsed.value + parsed.attribute = attribute + if (prefix && (parsed.name.startsWith(prefix))) { + parsed.name = parsed.name.slice(prefix.length) + } + if (!modifiers) { + delete (parsed as Record).modifiers + } + return parsed + } + + /** Internal cache used to store parsed already parsed {@linkcode https://developer.mozilla.org/en-US/docs/Web/API/Attr/name | Attr.name}. */ + // deno-lint-ignore ban-types + readonly #parsed = new WeakMap, "name" | "tag" | "modifiers">>() + + /** Used by {@linkcode Renderer.parseAttribute()} to parse a single {@linkcode https://developer.mozilla.org/en-US/docs/Web/API/Attr/value | Attr.value} according to specified {@linkcode AttrAny} typing. */ + #parseAttributeValue(element: Nullable, name: string, key: string, value: Optional, typings?: T): Optional { + if ((value === undefined) && (!typings?.enforce)) { + return undefined + } + let fallback + switch (typings?.type) { + case Boolean: { + const typing = typings as AttrBoolean + fallback = typing?.default ?? true + const { truthy, falsy } = `${value || fallback}`.match(this.#extractor.boolean)?.groups ?? {} + if ((!truthy) && (!falsy)) { + break + } + return Boolean(truthy) + } + case Number: { + const typing = typings as AttrNumber + fallback = typing?.default ?? 0 + let number = Number.parseFloat(`${value || fallback}`) + if (typing?.integer) { + number = Math.round(number) + } + if (typeof typing?.min === "number") { + number = Math.max(typing.min, number) + } + if (typeof typing?.max === "number") { + number = Math.min(typing.max, number) + } + if (!Number.isFinite(number)) { + break + } + return number + } + case Date: { + const typing = typings as AttrDuration + fallback = typing?.default ?? 0 + const { delay, unit } = `${value || fallback}`.match(this.#extractor.duration)?.groups ?? {} + const duration = Math.round(Number.parseFloat(delay) * ({ ms: 1, s: 1000, m: 60000 }[unit || "ms"] ?? NaN)) + if ((!Number.isFinite(duration)) || (duration < 0)) { + break + } + return duration + } + default: { + const typing = typings as AttrString + fallback = typing?.default ?? "" + const string = `${value || fallback}` + if ((typing?.allowed?.length) && (!typing.allowed.includes(string))) { + fallback = typing.allowed.includes(fallback) ? fallback : typing.allowed[0] + break + } + return string + } + } + this.warn(`Invalid value "${value}" for "${name}.${key}", fallbacking to to "${fallback}"`, element) + return fallback + } + + /** + * Type guard for {@linkcode https://developer.mozilla.org/docs/Web/API/HTMLElement | HTMLElement}. + * + * @example + * ```ts + * import { Window } from "@mizu/mizu/core/vdom" + * const renderer = await new Renderer(new Window()).ready + * const element = renderer.createElement("div") + * const comment = renderer.comment(element, { directive: "foo", expression: "bar" }) + * + * console.assert(renderer.isHtmlElement(element)) + * console.assert(!renderer.isHtmlElement(comment)) + * ``` + */ + isHtmlElement(element: HTMLElement | Comment): element is HTMLElement { + return element.nodeType === this.window.Node.ELEMENT_NODE + } + + /** + * Type guard for {@linkcode https://developer.mozilla.org/docs/Web/API/Comment | Comment}. + * + * @example + * ```ts + * import { Window } from "@mizu/mizu/core/vdom" + * const renderer = await new Renderer(new Window()).ready + * const element = renderer.createElement("div") + * const comment = renderer.comment(element, { directive: "foo", expression: "bar" }) + * + * console.assert(!renderer.isComment(element)) + * console.assert(renderer.isComment(comment)) + * ``` + */ + isComment(element: HTMLElement | Comment): element is Comment { + return element.nodeType === this.window.Node.COMMENT_NODE + } + + /** + * Generate a warning message. + * + * If `target.warn` is {@link https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Function/call | callable} (e.g. a {@linkcode https://developer.mozilla.org/docs/Web/API/Console | Console} instance), it is called with the message. + * + * If instead `target` an {@linkcode https://developer.mozilla.org/docs/Web/API/HTMLElement | HTMLElement} or a {@linkcode https://developer.mozilla.org/docs/Web/API/Comment | Comment}, + * the warning message is applied with {@linkcode Renderer.setAttribute()} with the name `*warn`. + * + * @example + * ```ts + * import { Window } from "@mizu/mizu/core/vdom" + * const renderer = await new Renderer(new Window()).ready + * + * const element = renderer.createElement("div") + * renderer.warn("foo", element) + * console.assert(element.getAttribute("*warn") === "foo") + * + * const comment = renderer.comment(element, { directive: "foo", expression: "bar" }) + * renderer.warn("foo", comment) + * console.assert(comment.nodeValue?.includes(`[*warn="foo"]`)) + * ``` + */ + warn(message: string, target?: Nullable void }>): void { + if (!target) { + return + } + if ("warn" in target) { + return target.warn(message) + } + if ((this.isHtmlElement(target)) || (this.isComment(target))) { + this.setAttribute(target, "*warn", message) + } + } +} + +/** {@linkcode Renderer.render()} initial {@linkcode Context} and {@linkcode State}. */ +export type InitialContextState = Readonly<{ context: Context; state: DeepReadonly }> + +/** Current {@linkcode Renderer.render()} state. */ +export type State = Record<`$${string}` | `${typeof Renderer.internal}_${string}`, unknown> + +/** Boolean type definition. */ +export type AttrBoolean = { type: typeof Boolean; default?: boolean; enforce?: true } + +/** Duration type definition. */ +export type AttrDuration = { type: typeof Date; default?: number | string; enforce?: true } + +/** Number type definition. */ +export type AttrNumber = { type: typeof Number; default?: number; integer?: boolean; min?: number; max?: number; enforce?: true } + +/** String type definition. */ +export type AttrString = { type?: typeof String; default?: string; allowed?: string[]; enforce?: true } + +/** Generic type definition. */ +export type AttrAny = { type?: typeof Boolean | typeof Number | typeof Date | typeof String; default?: unknown; enforce?: true } + +/** Infer value from {@linkcode AttrAny} type definition. */ +export type InferAttrAny = T extends AttrBoolean ? boolean : T extends (AttrDuration | AttrNumber) ? number : string + +/** Type definition for {@linkcode https://developer.mozilla.org/docs/Web/API/Attr | Attr} compatible with {@linkcode Renderer.parseAttribute()}. */ +export type AttrTypings = Omit }, "enforce"> + +/** Infer value from {@linkcode AttrTypings} type definition. */ +export type InferAttrTypings = { + /** Parsed {@linkcode https://developer.mozilla.org/docs/Web/API/Attr | Attr} reference. */ + attribute: Attr + /** Parsed {@linkcode https://developer.mozilla.org/docs/Web/API/Attr/name | Attr.name}. */ + name: string + /** Parsed {@linkcode https://developer.mozilla.org/docs/Web/API/Attr/value | Attr.value}. */ + value: InferAttrAny + /** Parsed {@linkcode https://developer.mozilla.org/docs/Web/API/Attr | Attr} tag. */ + tag: string + /** Parsed {@linkcode https://developer.mozilla.org/docs/Web/API/Attr | Attr} modifiers. */ + modifiers: { [P in keyof T["modifiers"]]: T["modifiers"][P] extends { enforce: true } ? InferAttrAny : Optional> } +} + +/** Additional typings for {@linkcode https://developer.mozilla.org/en-US/docs/Web/API/Window | Window} when using a {@link https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model | virtual DOM implementation}. */ +export type VirtualWindow = { + Node: typeof Node + HTMLElement: typeof HTMLElement + Event: typeof Event + NodeFilter: typeof NodeFilter + KeyboardEvent: typeof KeyboardEvent + MouseEvent: typeof MouseEvent +} diff --git a/@mizu/mizu/core/engine/renderer_test.ts b/@mizu/mizu/core/engine/renderer_test.ts new file mode 100644 index 0000000..a9dbaeb --- /dev/null +++ b/@mizu/mizu/core/engine/renderer_test.ts @@ -0,0 +1,623 @@ +import type { testing } from "@libs/testing" +import { expect, fn, test, TestingError } from "@libs/testing" +import { Window } from "@mizu/mizu/core/vdom" +import { Context, Phase, Renderer } from "./renderer.ts" +import _mizu from "@mizu/mizu" +import _test from "@mizu/test" +const options = { directives: [_mizu] } + +test()("`Renderer.constructor()` returns a new instance", async () => { + await using window = new Window() + const renderer = new Renderer(window, options) + expect(renderer.ready).toBeInstanceOf(Promise) + expect(renderer.window).toBeDefined() + expect(renderer.document).toBeDefined() + await expect(renderer.ready).resolves.toBeInstanceOf(Renderer) +}) + +test()("`Renderer.ready` resolves once it is ready", async () => { + await using window = new Window() + const directive = { name: "*foo", init: fn(), phase: Phase.TESTING } + const renderer = new Renderer(window, { directives: [directive, { name: "*bar", phase: Phase.TESTING }] as testing }) + expect(renderer.ready).toBeInstanceOf(Promise) + await expect(renderer.ready).resolves.toBeInstanceOf(Renderer) + expect(directive.init).toBeCalledWith(renderer) +}) + +test()("`Renderer.cache()` manages cache registries", async () => { + await using window = new Window() + const renderer = await new Renderer(window, options).ready + expect(renderer.cache("*foo")).toBeNull() + expect(renderer.cache("*foo", new WeakMap())).toBeInstanceOf(WeakMap) + expect(renderer.cache("*foo")).toBeInstanceOf(WeakMap) +}) + +test()('`Renderer.cache("*")` returns an already instantiated `WeakMap` instance', async () => { + await using window = new Window() + const renderer = await new Renderer(window, options).ready + expect(renderer.cache("*")).toBeInstanceOf(WeakMap) +}) + +test()("`Renderer.load()` loads, initializes and sorts directives", async () => { + await using window = new Window() + const directives = [{ + name: "*bar", + phase: 2, + init: fn() as testing, + }, { + name: "*foo", + phase: 1, + }] + const renderer = await new Renderer(window).ready + await expect(renderer.load(directives)).resolves.toBe(renderer) + expect(renderer.directives).toHaveLength(directives.length) + expect(renderer.directives).toMatchObject(directives.reverse()) + expect(directives.find((directive) => directive?.init)?.init).toBeCalledWith(renderer) +}) + +test()("`Renderer.load()` resolves and loads dynamically directives with `import()`", async () => { + await using window = new Window() + const renderer = await new Renderer(window).ready + await expect(renderer.load("@mizu/test")).resolves.toBe(renderer) + expect(renderer.directives).toHaveLength(_test.length) + expect(renderer.directives).toMatchObject(_test as testing) +}) + +test()("`Renderer.load()` ignores directives with `Phase.META`", async () => { + await using window = new Window() + const renderer = await new Renderer(window).ready + await expect(renderer.load({ name: "*foo", phase: Phase.META })).resolves.not._toThrow() + expect(renderer.directives).toHaveLength(0) +}) + +test()("`Renderer.load()` ignores invalid directives", async () => { + await using window = new Window() + const directive = { + name: "*foo", + phase: Phase.TESTING, + init: fn(() => { + throw new TestingError("Expected error") + }) as testing, + } + const renderer = await new Renderer(window).ready + await expect(renderer.load(directive)).rejects._toThrow(TestingError, "Expected error") + expect(directive.init).toBeCalledWith(renderer) + expect(renderer.directives).toHaveLength(0) + await expect(renderer.load({ name: "", phase: Phase.TESTING, execute() {} })).rejects._toThrow(SyntaxError, "Malformed directive") + expect(renderer.directives).toHaveLength(0) + await expect(renderer.load({ name: "*foo", phase: Phase.UNKNOWN, execute() {} })).rejects._toThrow(SyntaxError, "Malformed directive") + expect(renderer.directives).toHaveLength(0) +}) + +test()("`Renderer.load()` ignores duplicates or already loaded directives", async () => { + await using window = new Window() + const directive = { + name: "*foo", + phase: Phase.TESTING, + } + const renderer = await new Renderer(window).ready + await renderer.load([directive, directive]) + await renderer.load([directive]) + await renderer.load(directive) + expect(renderer.directives).toHaveLength(1) + expect(renderer.directives).toMatchObject([directive]) +}) + +test()("`Renderer.internal()` returns internal identifiers", async () => { + await using window = new Window() + const renderer = await new Renderer(window, options).ready + expect(renderer.internal()).toBe("__mizu_internal") + expect(renderer.internal("foo")).toBe("__mizu_internal_foo") +}) + +test()("`Renderer.evaluate()` evaluates expressions", async () => { + await using window = new Window() + const renderer = await new Renderer(window, options).ready + await expect(renderer.evaluate(null, "1+1")).resolves.toBe(2) + await expect(renderer.evaluate(null, "true")).resolves.toBe(true) + await expect(renderer.evaluate(null, "['foo','bar']")).resolves.toEqual(["foo", "bar"]) + await expect(renderer.evaluate(null, "{foo:'bar'}")).resolves.toEqual({ foo: "bar" }) +}) + +test()("`Renderer.evaluate()` evaluates expressions with variables", async () => { + await using window = new Window() + const renderer = await new Renderer(window, options).ready + await expect(renderer.evaluate(null, "foo", { context: new Context({ foo: true }) })).resolves.toBe(true) + await expect(renderer.evaluate(null, "$foo", { state: { $foo: true } })).resolves.toBe(true) + await expect(renderer.evaluate(null, "foo", { context: new Context({ foo: true }), state: { $foo: false } })).resolves.toBe(true) + await expect(renderer.evaluate(null, "$foo", { context: new Context({ foo: true }), state: { $foo: false } })).resolves.toBe(false) + await expect(renderer.evaluate(null, "foo")).rejects.toThrow(ReferenceError) +}) + +test()("`Renderer.evaluate()` evaluates expressions with callables", async () => { + await using window = new Window() + const renderer = await new Renderer(window, options).ready + const foo = fn(() => true) + await expect(renderer.evaluate(null, "foo", { context: new Context({ foo }) })).resolves.toBeInstanceOf(Function) + expect(foo).not.toBeCalled() + await expect(renderer.evaluate(null, "foo", { context: new Context({ foo }), args: [true] })).resolves.toBe(true) + expect(foo).toBeCalledWith(true) +}) + +test()("`Renderer.evaluate()` rejects if the internal identifier is used", async () => { + await using window = new Window() + const renderer = await new Renderer(window, options).ready + await expect(renderer.evaluate(null, "null", { context: new Context({ [renderer.internal()]: true }) })).rejects.toThrow(TypeError) +}) + +test()("`Renderer.render()` requires `*mizu` attribute when `implicit: false`", async () => { + await using window = new Window() + const directive = { name: "*foo", execute: fn(), phase: Phase.TESTING } + const renderer = await new Renderer(window, { directives: [directive] as testing }).ready + await expect(renderer.render(renderer.createElement("div", { attributes: { "*foo": "" } }), { implicit: false })).not.resolves.toThrow() + expect(directive.execute).not.toBeCalled() + await expect(renderer.render(renderer.createElement("div", { attributes: { "*foo": "", [_mizu.name]: "" } }), { implicit: false })).not.resolves.toThrow() + expect(directive.execute).toBeCalled() +}) + +test()("`Renderer.render()` returns selected element with `select` option", async () => { + await using window = new Window() + const renderer = await new Renderer(window).ready + const element = renderer.createElement("div", { innerHTML: "foo" }) + await expect(renderer.render(element, { select: "span" })).resolves.toHaveProperty("tagName", "SPAN") + await expect(renderer.render(element, { select: "unknown" })).resolves.toBeNull() +}) + +test()("`Renderer.render()` returns stringified result when `stringify: true`", async () => { + await using window = new Window() + const renderer = await new Renderer(window).ready + const element = renderer.createElement("div", { innerHTML: "foo" }) + await expect(renderer.render(element, { select: "unknown", stringify: true })).resolves.toBe("") + await expect(renderer.render(element, { select: "span", stringify: true })).resolves.toBe("foo") + await expect(renderer.render(element, { stringify: true })).resolves.toBe("
foo
") +}) + +test()("`Renderer.#render() // 1` ignores non-element nodes unless they were processed before and put into cache", async () => { + await using window = new Window() + const directive = { name: "*foo", setup: fn(), phase: Phase.TESTING } + const renderer = await new Renderer(window, { directives: [directive] as testing }).ready + await expect(renderer.render(renderer.document.createComment("comment") as testing)).not.resolves.toThrow() + expect(directive.setup).not.toBeCalled() + await expect(renderer.render(renderer.comment(renderer.createElement("div"), { directive: "", expression: "" }) as testing)).not.resolves.toThrow() + expect(directive.setup).toBeCalled() +}) + +test()("`Renderer.#render() // 2` calls `directive.setup()`", async () => { + await using window = new Window() + const directives = [{ name: "*foo", setup: fn(), phase: Phase.TESTING }, { name: "*bar", setup: fn(), phase: Phase.TESTING }] + const renderer = await new Renderer(window, { directives: directives as testing }).ready + await expect(renderer.render(renderer.createElement("div", { attributes: { "*foo": "" } }))).not.resolves.toThrow() + expect(directives[0].setup).toBeCalled() + expect(directives[1].setup).toBeCalled() +}) + +test()("`Renderer.#render() // 2.1` ends the process if `false` is returned", async () => { + await using window = new Window() + const directives = [{ name: "*foo", setup: fn(() => false), execute: fn(), phase: Phase.TESTING }, { name: "*bar", execute: fn(), phase: Phase.TESTING }] + const renderer = await new Renderer(window, { directives: directives as testing }).ready + await expect(renderer.render(renderer.createElement("div", { attributes: { "*foo": "", "*bar": "" } }))).not.resolves.toThrow() + expect(directives[0].setup).toBeCalled() + expect(directives[0].setup).toReturnWith(false) + expect(directives[0].execute).not.toBeCalled() + expect(directives[1].execute).not.toBeCalled() +}) + +test()("`Renderer.#render() // 2.1` updates `state` if it is returned", async () => { + await using window = new Window() + const directives = [{ name: "*foo", setup: fn(() => ({ state: { $bar: true } })), execute: fn((_: unknown, __: unknown, { state }: testing) => state), phase: Phase.TESTING }] + const renderer = await new Renderer(window, { directives: directives as testing }).ready + await expect(renderer.render(renderer.createElement("div", { attributes: { "*foo": "", "*bar": "" } }), { state: { $foo: true } })).not.resolves.toThrow() + expect(directives[0].setup).toBeCalled() + expect(directives[0].execute).toBeCalled() + expect((directives[0].execute as testing)[Symbol.for("@MOCK")].calls[0].args[2].state).toMatchObject({ $foo: true, $bar: true }) +}) + +test()("`Renderer.#render() // 3` retrieves `HTMLElement` from `Comment` if applicable", async () => { + await using window = new Window() + const directives = [{ name: "*foo", execute: fn((_: unknown, element: unknown) => element), phase: Phase.TESTING }] + const renderer = await new Renderer(window, { directives: directives as testing }).ready + const element = renderer.createElement("div", { attributes: { "*foo": "" } }) + await expect(renderer.render(element)).not.resolves.toThrow() + expect(directives[0].execute).toBeCalled() + expect(directives[0].execute).toHaveReturnedWith(element) + const comment = renderer.comment(element, { directive: "*foo", expression: "" }) + await expect(renderer.render(comment as testing)).not.resolves.toThrow() + expect(directives[0].execute).toBeCalled() + expect(directives[0].execute).toHaveReturnedWith(element) +}) + +test()("`Renderer.#render() // 4.1` calls `directive.execute()` if node is elligible", async () => { + await using window = new Window() + const directives = [{ name: "*foo", execute: fn(), phase: Phase.TESTING }, { name: "*bar", execute: fn(), phase: Phase.TESTING }] + const renderer = await new Renderer(window, { directives: directives as testing }).ready + await expect(renderer.render(renderer.createElement("div", { attributes: { "*bar": "" } }))).not.resolves.toThrow() + expect(directives[0].execute).not.toBeCalled() + expect(directives[1].execute).toBeCalledTimes(1) + await expect(renderer.render(renderer.createElement("div", { attributes: { "*bar.foo": "" } }))).not.resolves.toThrow() + expect(directives[0].execute).not.toBeCalled() + expect(directives[1].execute).toBeCalledTimes(2) +}) + +test()("`Renderer.#render() // 4.2` warns on conflicting directives", async () => { + await using window = new Window() + const warn = fn() as testing + const directives = [{ name: "*foo", phase: Phase.CONTENT }, { name: "*bar", phase: Phase.CONTENT }] + const renderer = await new Renderer(window, { directives: directives as testing }).ready + Object.assign(renderer, { warn }) + await expect(renderer.render(renderer.createElement("div", { attributes: { "*foo": "", "*bar": "" } }))).not.resolves.toThrow() + expect(warn).toBeCalledWith("Using [*bar] and [*foo] directives together might result in unexpected behaviour", expect.anything()) +}) + +test()("`Renderer.#render() // 4.2` warns on duplicates directives", async () => { + await using window = new Window() + const warn = fn() as testing + const renderer = await new Renderer(window, { directives: [{ name: "*foo", phase: Phase.TESTING }] as testing }).ready + Object.assign(renderer, { warn }) + await expect(renderer.render(renderer.createElement("div", { attributes: { "*foo[1]": "", "*foo[2]": "" } }))).not.resolves.toThrow() + expect(warn).toBeCalledWith("Using multiple [*foo] directives might result in unexpected behaviour", expect.anything()) +}) + +test()("`Renderer.#render() // 4.3` updates node when a new `element` is returned", async () => { + await using window = new Window() + const directives = [{ name: "*foo", execute: fn((renderer: Renderer, element: HTMLElement) => ({ element: renderer.comment(element, { directive: "", expression: "" }) })), phase: Phase.TESTING }, { name: "*bar", execute: fn(), phase: Phase.TESTING }] + const renderer = await new Renderer(window, { directives: directives as testing }).ready + await expect(renderer.render(renderer.createElement("div", { attributes: { "*foo": "", "*bar": "" } }))).not.resolves.toThrow() + expect(directives[0].execute).toBeCalledTimes(1) + expect((directives[0].execute as testing)[Symbol.for("@MOCK")].calls[0].args[1].nodeType).toBe(renderer.window.Node.ELEMENT_NODE) + expect(directives[1].execute).toBeCalledTimes(1) + expect((directives[1].execute as testing)[Symbol.for("@MOCK")].calls[0].args[1].nodeType).toBe(renderer.window.Node.COMMENT_NODE) +}) + +test()("`Renderer.#render() // 4.3` ends the process if `final: true` is returned", async () => { + await using window = new Window() + const directives = [{ name: "*foo", execute: fn(() => ({ final: true })), phase: Phase.TESTING }, { name: "*bar", execute: fn(), phase: Phase.TESTING }] + const renderer = await new Renderer(window, { directives: directives as testing }).ready + await expect(renderer.render(renderer.createElement("div", { attributes: { "*foo": "", "*bar": "" } }))).not.resolves.toThrow() + expect(directives[0].execute).toBeCalled() + expect(directives[0].execute).toHaveReturnedWith({ final: true }) + expect(directives[1].execute).not.toBeCalled() +}) + +test()("`Renderer.#render() // 4.3` updates `state` or `context` if one of them is returned", async () => { + await using window = new Window() + const directives = [ + { name: "*foo", execute: fn((_: unknown, __: unknown) => ({ state: { $bar: true } })), phase: Phase.TESTING }, + { name: "*bar", execute: fn((_: unknown, __: unknown) => ({ context: { $bar: true } })), phase: Phase.TESTING }, + ] + const renderer = await new Renderer(window, { directives: directives as testing }).ready + await expect(renderer.render(renderer.createElement("div", { attributes: { "*foo": "", "*bar": "" } }), { state: { $foo: true } })).not.resolves.toThrow() + expect(directives[0].execute).toBeCalled() + expect(directives[1].execute).toBeCalled() + expect((directives[1].execute as testing)[Symbol.for("@MOCK")].calls[0].args[2].state).toMatchObject({ $foo: true, $bar: true }) +}) + +test()("`Renderer.render() // 5` recurses on child nodes", async () => { + await using window = new Window() + const directives = [{ name: "*foo", execute: fn(), phase: Phase.TESTING }, { name: "*bar", execute: fn(), phase: Phase.TESTING }] + const renderer = await new Renderer(window, { directives: directives as testing }).ready + const a = renderer.createElement("div", { attributes: { "*foo": "" } }) + const b = renderer.createElement("div", { attributes: { "*bar": "" } }) + a.appendChild(b) + await expect(renderer.render(a)).not.resolves.toThrow() + expect(directives[0].execute).toBeCalledTimes(1) + expect(directives[0].execute).toBeCalledWith(renderer, a, expect.anything()) + expect(directives[1].execute).toBeCalledTimes(1) + expect(directives[1].execute).toBeCalledWith(renderer, b, expect.anything()) +}) + +test()("`Renderer.#render() // 6` calls `directive.cleanup()`", async () => { + await using window = new Window() + const directives = [{ name: "*foo", cleanup: fn(), phase: Phase.TESTING }, { name: "*bar", cleanup: fn(), phase: Phase.TESTING }] + const renderer = await new Renderer(window, { directives: directives as testing }).ready + await expect(renderer.render(renderer.createElement("div", { attributes: { "*foo": "" } }))).not.resolves.toThrow() + expect(directives[0].cleanup).toBeCalled() + expect(directives[1].cleanup).toBeCalled() +}) + +test()("`Renderer.createElement()` creates a `new HTMLElement()`", async () => { + await using window = new Window() + const renderer = await new Renderer(window, options).ready + expect(renderer.createElement("input")).toHaveProperty("tagName", "INPUT") + expect(renderer.createElement("input", { value: "foo" })).toHaveProperty("value", "foo") + expect(renderer.createElement("input", { attributes: { foo: "bar" } }).getAttribute("foo")).toBe("bar") +}) + +test()("`Renderer.replaceElementWithChildNodes()` replaces `HTMLElement` with another `HTMLElement.childNodes`", async () => { + await using window = new Window() + const renderer = await new Renderer(window, options).ready + renderer.document.body.innerHTML = "" + const div = renderer.createElement("div", { innerHTML: "

foo

" }) + const span = renderer.document.querySelector("span")! + const slot = span.querySelector("slot")! + renderer.replaceElementWithChildNodes(slot, div) + expect(span.innerHTML).toBe(div.innerHTML) +}) + +test()("`Renderer.comment()` replaces `HTMLElement` by a `new Comment()`", async () => { + await using window = new Window() + const renderer = await new Renderer(window, options).ready + const div = renderer.createElement("div") + renderer.document.body.appendChild(div) + expect(renderer.document.body.innerHTML).toBe(div.outerHTML) + const comment = renderer.comment(div, { directive: "*foo", expression: "bar" }) + expect(comment.nodeType).toBe(renderer.window.Node.COMMENT_NODE) + expect(comment.nodeValue).toBe(`[*foo="bar"]`) + expect(renderer.document.body.innerHTML).toBe(``) +}) + +test()("`Renderer.uncomment()` restores a `HTMLElement` that was replaced by a `Comment`", async () => { + await using window = new Window() + const renderer = await new Renderer(window, options).ready + const div = renderer.createElement("div") + renderer.document.body.appendChild(div) + expect(renderer.document.body.innerHTML).toBe(div.outerHTML) + const comment = renderer.comment(div, { directive: "*foo", expression: "bar" }) + expect(renderer.document.body.innerHTML).toBe(``) + expect(renderer.uncomment(comment)).toBe(div) + expect(renderer.document.body.innerHTML).toBe(div.outerHTML) +}) + +test()("`Renderer.uncomment()` throws a `new ReferenceError()` when passing an uncached `Comment`", async () => { + await using window = new Window() + const renderer = await new Renderer(window, options).ready + const comment = renderer.document.createComment("") + expect(renderer.cache("*").has(comment)).toBe(false) + expect(() => renderer.uncomment(comment)).toThrow(ReferenceError) +}) + +test()("`Renderer.getComment()` returns the `Comment` associated with a specified `HTMLElement`", async () => { + await using window = new Window() + const renderer = await new Renderer(window, options).ready + const div = renderer.createElement("div") + renderer.document.body.appendChild(div) + expect(renderer.getComment(div)).toBeNull() + const comment = renderer.comment(div, { directive: "*foo", expression: "bar" }) + expect(renderer.getComment(div)).toBe(comment) + renderer.uncomment(comment) + expect(renderer.getComment(div)).toBeNull() +}) + +test()("`Renderer.createNamedNodeMap()` creates a `new NamedNodeMap()`", async () => { + await using window = new Window() + const renderer = await new Renderer(window, options).ready + const nodemap = renderer.createNamedNodeMap() + expect(nodemap.constructor.name).toMatch(/NamedNodeMap/) +}) + +test()("`Renderer.createAttribute()` creates a `new Attr()`", async () => { + await using window = new Window() + const renderer = await new Renderer(window, options).ready + const a = renderer.createAttribute("foo", "bar") + expect(a.constructor.name).toBe("Attr") + expect(a.name).toBe("foo") + expect(a.value).toBe("bar") + const b = renderer.createAttribute("*foo", "bar") + expect(b.constructor.name).toBe("Attr") + expect(b.name).toBe("*foo") + expect(b.value).toBe("bar") +}) + +test()("`Renderer.setAttribute()` updates `HTMLElement.attributes`", async () => { + await using window = new Window() + const renderer = await new Renderer(window, options).ready + const div = renderer.createElement("div") + expect(div.hasAttribute("foo")).toBe(false) + renderer.setAttribute(div, "foo", "bar") + expect(div.getAttribute("foo")).toBe("bar") + renderer.setAttribute(div, "foo", "baz") + expect(div.getAttribute("foo")).toBe("baz") +}) + +test()("`Renderer.setAttribute()` updates `Comment.nodeValue`", async () => { + await using window = new Window() + const renderer = await new Renderer(window, options).ready + const comment = renderer.comment(renderer.createElement("div"), { directive: "*foo", expression: "bar" }) + expect(comment.nodeValue).toBe(`[*foo="bar"]`) + renderer.setAttribute(comment, "baz", "qux") + expect(comment.nodeValue).toBe(`[*foo="bar"] [baz="qux"]`) +}) + +test()("`Renderer.getAttributes()` returns matching `HTMLElement.attributes` with supported syntax", async () => { + await using window = new Window() + const renderer = await new Renderer(window, options).ready + const div = renderer.createElement("div", { attributes: { "*foo.bar": "true", "*foo.baz": "false" } }) + expect(renderer.getAttributes(div, "*foo")).toHaveLength(2) + expect(renderer.getAttributes(div, "*foo", { first: true })).not.toBeNull() + expect(renderer.getAttributes(div, /^\*/)).toHaveLength(2) + expect(renderer.getAttributes(div, /^\*/, { first: true })).not.toBeNull() + const comment = renderer.comment(div, { directive: "", expression: "" }) + expect(renderer.getAttributes(comment as testing, "*foo")).toHaveLength(0) + expect(renderer.getAttributes(comment as testing, "*foo", { first: true })).toBeNull() + expect(renderer.getAttributes(comment as testing, /^\*/)).toHaveLength(0) + expect(renderer.getAttributes(comment as testing, /^\*/, { first: true })).toBeNull() +}) + +for ( + const [name, tested, expected, typings = {}, extras = {}] of [ + ["names", `*foo="bar"`, { value: "bar", name: "*foo" }], + ["with escaped name", `*{foo.bar}="foobar"`, { value: "foobar", name: "*foo.bar" }], + ["tags", `*foo[baz]="bar"`, { value: "bar", name: "*foo", tag: "baz" }], + ["with escaped name and with tags", `*{foo.bar}[baz]="foobar"`, { value: "foobar", name: "*foo.bar", tag: "baz" }], + ["without modifiers", `*foo="bar"`, { value: "bar", modifiers: { a: undefined } }, { modifiers: { a: { type: Boolean } } }, { modifiers: true }], + ["modifiers with optional values", `*foo.a="bar"`, { value: "bar", modifiers: { a: true } }, { modifiers: { a: { type: Boolean } } }, { modifiers: true }], + ["modifiers with default values", `*foo.a="bar"`, { value: "bar", modifiers: { a: false } }, { modifiers: { a: { type: Boolean, default: false } } }, { modifiers: true }], + ["modifiers with enforced values", `*foo="bar"`, { value: "bar", modifiers: { a: true } }, { modifiers: { a: { type: Boolean, enforce: true } } }, { modifiers: true }], + ["modifiers with enforced values and defaults", `*foo="bar"`, { value: "bar", modifiers: { a: false } }, { modifiers: { a: { type: Boolean, default: false, enforce: true } } }, { modifiers: true }], + ["modifiers values", `*foo.a[false]="bar"`, { value: "bar", modifiers: { a: false } }, { modifiers: { a: { type: Boolean } } }, { modifiers: true }], + ["modifiers values and fallbacks on empty values", `*foo.a[]="bar"`, { value: "bar", modifiers: { a: true } }, { modifiers: { a: { type: Boolean } } }, { modifiers: true }], + ["modifiers values and fallbacks invalid values", `*foo.a[garbage]="bar"`, { value: "bar", modifiers: { a: true } }, { modifiers: { a: { type: Boolean } } }, { modifiers: true }], + ] as const +) { + test()(`\`Renderer.parseAttributes()\` parses attributes ${name}`, async () => { + await using window = new Window() + const renderer = await new Renderer(window, options).ready + const div = renderer.createElement("div", { innerHTML: `
` }).querySelector("div")! + const attribute = div.attributes[0] + expect(renderer.parseAttribute(attribute, typings, extras as testing)).toMatchObject(expected) + }) +} + +test()("`Renderer.parseAttributes()` parses attributes with `prefix` option", async () => { + await using window = new Window() + const renderer = await new Renderer(window, options).ready + const div = renderer.createElement("div", { innerHTML: `
` }).querySelector("div")! + const attribute = div.attributes[0] + expect(renderer.parseAttribute(attribute, {}, { prefix: "" }).name).toBe("*foo") + expect(renderer.parseAttribute(attribute, {}, { prefix: "*" }).name).toBe("foo") +}) + +for ( + const [name, tested, expected, typings] of [ + ['with `"true"` values as `true`', `*foo="true"`, { value: true }, { type: Boolean }], + ['with `"yes"` values as `true`', `*foo="yes"`, { value: true }, { type: Boolean }], + ['with `"on"` values as `true`', `*foo="on"`, { value: true }, { type: Boolean }], + ['with `"false"` values as `false`', `*foo="false"`, { value: false }, { type: Boolean }], + ['with `"no"` values as `false`', `*foo="no"`, { value: false }, { type: Boolean }], + ['with `"off"` values as `false`', `*foo="off"`, { value: false }, { type: Boolean }], + ["with specified value and defaults", `*foo="false"`, { value: false }, { type: Boolean, default: true }], + ["and defaults to `true`", `*foo`, { value: true }, { type: Boolean }], + ["and defaults to configured `default`", `*foo`, { value: false }, { type: Boolean, default: false }], + ["and fallbacks to `true` on invalid values", `*foo="bar"`, { value: true }, { type: Boolean }], + ["and fallbacks to `default` on invalid values", `*foo="bar"`, { value: false }, { type: Boolean, default: false }], + ] as const +) { + test()(`\`Renderer.parseAttributes()\` parses booleans ${name}`, async () => { + await using window = new Window() + const renderer = await new Renderer(window, options).ready + const div = renderer.createElement("div", { innerHTML: `
` }).querySelector("div")! + const attribute = div.attributes[0] + expect(renderer.parseAttribute(attribute, typings)).toMatchObject(expected) + }) +} + +for ( + const [name, tested, expected, typings] of [ + ["", `*foo="1"`, { value: 1 }, { type: Number }], + ["with negative values", `*foo="-1"`, { value: -1 }, { type: Number }], + ["with decimal values", `*foo=".5"`, { value: .5 }, { type: Number }], + ["with negative decimal values", `*foo="-.5"`, { value: -.5 }, { type: Number }], + ["with specified value and defaults", `*foo="2"`, { value: 2 }, { type: Number, default: 1 }], + ["and defaults to `0`", `*foo`, { value: 0 }, { type: Number }], + ["and defaults to configured `default`", `*foo`, { value: 1 }, { type: Number, default: 1 }], + ["and rounds values when `integer: true`", `*foo="1"`, { value: 1 }, { type: Number, integer: true }], + ["and rounds negative values when `integer: true`", `*foo="-1"`, { value: -1 }, { type: Number, integer: true }], + ["and rounds decimal values when `integer: true`", `*foo=".5"`, { value: 1 }, { type: Number, integer: true }], + ["and rounds negative decimal values when `integer: true`", `*foo="-.5"`, { value: -0 }, { type: Number, integer: true }], + ["and validates that values are greater than `min`", `*foo="-2"`, { value: -2 }, { type: Number, min: -3 }], + ["and sets values to `min` value when they are lower than `min`", `*foo="-2"`, { value: -1 }, { type: Number, min: -1 }], + ["and validates that values are lower than `max`", `*foo="2"`, { value: 2 }, { type: Number, max: 3 }], + ["and sets values to `max` when they are greater than `max`", `*foo="2"`, { value: 1 }, { type: Number, max: 1 }], + ["and fallbacks to `0` on invalid values", `*foo="bar"`, { value: 0 }, { type: Number }], + ["and fallbacks to `default` on invalid values", `*foo="bar"`, { value: 1 }, { type: Number, default: 1 }], + ["and fallbacks on `-Infinity`", `*foo="-Infinity"`, { value: 0 }, { type: Number }], + ["and fallbacks on `Infinity`", `*foo="+Infinity"`, { value: 0 }, { type: Number }], + ["and fallbacks on `NaN`", `*foo="NaN"`, { value: 0 }, { type: Number }], + ] as const +) { + test()(`\`Renderer.parseAttributes()\` parses numbers ${name}`, async () => { + await using window = new Window() + const renderer = await new Renderer(window, options).ready + const div = renderer.createElement("div", { innerHTML: `
` }).querySelector("div")! + const attribute = div.attributes[0] + expect(renderer.parseAttribute(attribute, typings)).toMatchObject(expected) + }) +} + +for ( + const [name, tested, expected, typings] of [ + ["", `*foo="1"`, { value: 1 }, { type: Date }], + ['with `"ms"` unit', `*foo="1ms"`, { value: 1 }, { type: Date }], + ['with `"s"` unit', `*foo="1s"`, { value: 1_000 }, { type: Date }], + ['with `"m"` unit', `*foo="1m"`, { value: 60_000 }, { type: Date }], + ["with specified values and defaults", `*foo="2"`, { value: 2 }, { type: Date, default: 1 }], + ["and defaults to `0`", `*foo`, { value: 0 }, { type: Date }], + ["and defaults to configured `default`", `*foo`, { value: 1 }, { type: Date, default: 1 }], + ["and rounds values", `*foo=".9"`, { value: 1 }, { type: Date }], + ['and rounds values with `"ms"` unit', `*foo=".9ms"`, { value: 1 }, { type: Date }], + ['and rounds values with `"s"` unit', `*foo=".9s"`, { value: 900 }, { type: Date }], + ['and rounds values with `"m"` unit', `*foo=".9m"`, { value: 54_000 }, { type: Date }], + ["and fallbacks to `0` on invalid values", `*foo="bar"`, { value: 0 }, { type: Date }], + ["and fallbacks to `default` on invalid values", `*foo="bar"`, { value: 1 }, { type: Date, default: 1 }], + ["and fallbacks on invalid units", `*foo="1xx"`, { value: 0 }, { type: Date }], + ["and fallbacks on negative values", `*foo="-1"`, { value: 0 }, { type: Date }], + ["and fallbacks on `Infinity`", `*foo="Infinity"`, { value: 0 }, { type: Date }], + ] as const +) { + test()(`\`Renderer.parseAttributes()\` parses durations ${name}`, async () => { + await using window = new Window() + const renderer = await new Renderer(window, options).ready + const div = renderer.createElement("div", { innerHTML: `
` }).querySelector("div")! + const attribute = div.attributes[0] + expect(renderer.parseAttribute(attribute, typings)).toMatchObject(expected) + }) +} + +for ( + const [name, tested, expected, typings = {}] of [ + ["", `*foo="bar"`, { value: "bar" }], + ["with boolean-like truthy values", `*foo="true"`, { value: "true" }], + ["with boolean-like falsy values", `*foo="false"`, { value: "false" }], + ["with number-like integer values", `*foo="1"`, { value: "1" }], + ["with number-like decimal values", `*foo=".5"`, { value: ".5" }], + ["with duration-like values", `*foo="1ms"`, { value: "1ms" }], + ["with duration-like decimal values", `*foo=".5ms"`, { value: ".5ms" }], + ["with specified values and defaults", `*foo="bar"`, { value: "bar" }, { default: "baz" }], + ['and defaults to `""`', `*foo`, { value: "" }], + ["and defaults to configured `default`", `*foo`, { value: "bar" }, { default: "bar" }], + ["and validates values are in `allowed` values", `*foo="baz"`, { value: "baz" }, { type: String, allowed: ["bar", "baz"] }], + ["and validates values are in `allowed` values with specified defaults", `*foo="baz"`, { value: "baz" }, { type: String, allowed: ["bar", "baz"], default: "bar" }], + ["and defaults to value `allowed` first value`", `*foo`, { value: "bar" }, { type: String, allowed: ["bar", "baz"] }], + ["and defaults to value `allowed` configured `default`", `*foo`, { value: "baz" }, { type: String, allowed: ["bar", "baz"], default: "baz" }], + ["and fallbacks to `allowed` first value on invalid values", `*foo="qux"`, { value: "bar" }, { type: String, allowed: ["bar", "baz"] }], + ["and fallbacks to `allowed` configured `default` on invalid values", `*foo="qux"`, { value: "baz" }, { type: String, allowed: ["bar", "baz"], default: "baz" }], + ] as const +) { + test()(`\`Renderer.parseAttributes()\` parses strings ${name}`, async () => { + await using window = new Window() + const renderer = await new Renderer(window, options).ready + const div = renderer.createElement("div", { innerHTML: `
` }).querySelector("div")! + const attribute = div.attributes[0] + expect(renderer.parseAttribute(attribute, typings)).toMatchObject(expected) + }) +} + +test()("`Renderer.isHtmlElement()` checks if the given node is an `HTMLElement`", async () => { + await using window = new Window() + const renderer = await new Renderer(window, options).ready + const element = renderer.createElement("div") + const comment = renderer.document.createComment("") + expect(renderer.isHtmlElement(element)).toBe(true) + expect(renderer.isHtmlElement(comment)).toBe(false) +}) + +test()("`Renderer.isComment()` checks if the given node is a `Comment`", async () => { + await using window = new Window() + const renderer = await new Renderer(window, options).ready + const element = renderer.createElement("div") + const comment = renderer.document.createComment("") + expect(renderer.isComment(element)).toBe(false) + expect(renderer.isComment(comment)).toBe(true) +}) + +test()("`Renderer.warn()` is a no-op when `target` is nullish", async () => { + await using window = new Window() + const renderer = await new Renderer(window, options).ready + expect(() => renderer.warn("foo")).not.toThrow() + expect(() => renderer.warn("foo", null)).not.toThrow() +}) + +test()("`Renderer.warn()` sets `*warn` attribute when `target` is a node", async () => { + await using window = new Window() + const renderer = await new Renderer(window, options).ready + const div = renderer.createElement("div") + renderer.warn("foo", div) + expect(div.getAttribute("*warn")).toBe("foo") +}) + +test()("`Renderer.warn()` calls `target.warn()` if it exists", async () => { + await using window = new Window() + const renderer = await new Renderer(window, options).ready + const warn = fn() as testing + renderer.warn("foo", { warn }) + expect(warn).toBeCalledWith("foo") +}) diff --git a/@mizu/mizu/core/render/client/client.ts b/@mizu/mizu/core/render/client/client.ts new file mode 100644 index 0000000..e99b26a --- /dev/null +++ b/@mizu/mizu/core/render/client/client.ts @@ -0,0 +1,112 @@ +// Imports +import type { Arg, Directive } from "@mizu/mizu/core/engine" +import { Context, Renderer } from "@mizu/mizu/core/engine" +import _mizu from "@mizu/mizu" +// import _bind from "@mizu/bind" +// import _clean from "@mizu/clean" +// import _code from "@mizu/code" +// import _custom_element from "@mizu/custom-element" +// import _eval from "@mizu/eval" +// import _event from "@mizu/event" +// import _for from "@mizu/for/empty" +// import _html from "@mizu/html" +// import _http from "@mizu/http" +// import _if from "@mizu/if/else" +// import _is from "@mizu/is" +// import _markdown from "@mizu/markdown" +// import _model from "@mizu/model" +// import _once from "@mizu/once" +// import _ref from "@mizu/ref" +// import _refresh from "@mizu/refresh" +// import _set from "@mizu/set" +// import _show from "@mizu/show" +// import _skip from "@mizu/skip" +// import _text from "@mizu/text" +// import _toc from "@mizu/toc" +export type * from "@mizu/mizu/core/engine" + +/** + * Client side renderer. + * + * See {@link https://mizu.sh | mizu.sh documentation} for more details. + * @module + */ +export class Client { + /** Default directives. */ + static defaults = { + directives: [ + _mizu, + // _bind, + // _clean, + // _code, + // _custom_element, + // _eval, + // _event, + // _for, + // _html, + // _http, + // _if, + // _is, + // _markdown, + // _model, + // _once, + // _ref, + // _refresh, + // _set, + // _show, + // _skip, + // _text, + // _toc, + ] as Array | string>, + } + + /** {@linkcode Client} constructor. */ + constructor({ directives = Client.defaults.directives, context = {}, window = globalThis.window } = {} as { directives?: Arg; context?: ConstructorParameters[0]; window?: Renderer["window"] }) { + this.#renderer = new Renderer(window, { directives }) + // deno-lint-ignore no-explicit-any + this.#context = new Context(context) + } + + /** Linked {@linkcode Renderer}. */ + readonly #renderer + + /** Linked {@linkcode Context}. */ + readonly #context + + /** + * Rendering context. + * + * All properties assigned to this object will be available during rendering. + * + * Changes on this object are reactive and will trigger a re-render on related elements. + * This is done by using {@linkcode Context} which use {@linkcode https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Proxy | Proxy} handlers under the hood. + * + * > [!NOTE] + * > You cannot reassign this property directly to prevent accidental loss of reactivity. + * > It is possible to obtain a similar effect by using {@linkcode https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign | Object.assign()} instead. + */ + // deno-lint-ignore no-explicit-any + get context(): Record { + return this.#context.target + } + + /** + * Start rendering all subtrees marked with a {@link _mizu | `*mizu` attribute}. + * + * @example + * ```ts ignore + * const mizu = new Client({ context: { foo: "bar" } }) + * await mizu.render() + * ``` + */ + render>(element = this.#renderer.document.documentElement as T, options?: Partial, "state"> & { context: Arg }>): Promise { + let context = this.#context + if (options?.context) { + context = context.with(options.context) + } + return this.#renderer.render(element, { ...options, context, state: { $renderer: "client", ...options?.state }, implicit: false }) + } + + /** Default {@linkcode Client} instance. */ + static readonly default = new Client() as Client +} diff --git a/@mizu/mizu/core/render/client/client_test.ts b/@mizu/mizu/core/render/client/client_test.ts new file mode 100644 index 0000000..cb435b0 --- /dev/null +++ b/@mizu/mizu/core/render/client/client_test.ts @@ -0,0 +1,24 @@ +import { expect, test } from "@libs/testing" +import { Window } from "@mizu/mizu/core/vdom" +import { Client } from "./client.ts" + +test()("`Client.render()` renders dom content", async () => { + await using window = new Window(`
`) + Client.defaults.directives.push("@mizu/test") + const mizu = new Client({ context: { foo: "" }, window }) + await mizu.render() + expect(window.document.querySelector("a")?.textContent).toBe("") + expect(window.document.querySelector("b")?.textContent).toBe("") + expect(window.document.querySelector("c")?.textContent).toBe("client") + + await mizu.render(undefined, { context: { foo: "bar" }, state: { $renderer: "custom" } }) + expect(window.document.querySelector("a")?.textContent).toBe("bar") + expect(window.document.querySelector("b")?.textContent).toBe("") + expect(window.document.querySelector("c")?.textContent).toBe("custom") + + mizu.context.foo = "baz" + await mizu.render() + expect(window.document.querySelector("a")?.textContent).toBe("baz") + expect(window.document.querySelector("b")?.textContent).toBe("") + expect(window.document.querySelector("c")?.textContent).toBe("client") +}) diff --git a/@mizu/mizu/core/render/client/mod.ts b/@mizu/mizu/core/render/client/mod.ts new file mode 100644 index 0000000..f102318 --- /dev/null +++ b/@mizu/mizu/core/render/client/mod.ts @@ -0,0 +1,10 @@ +/** + * Mizu client-side renderer. + * @module + */ +import { Client } from "./client.ts" +export { Client as Mizu } from "./client.ts" +export type * from "./client.ts" + +/** Default Mizu {@linkcode Client} instance. */ +export default Client.default as Client diff --git a/@mizu/mizu/core/render/client/mod_test.ts b/@mizu/mizu/core/render/client/mod_test.ts new file mode 100644 index 0000000..998dfc7 --- /dev/null +++ b/@mizu/mizu/core/render/client/mod_test.ts @@ -0,0 +1 @@ +import "./mod.ts" diff --git a/@mizu/mizu/core/render/server/mod.ts b/@mizu/mizu/core/render/server/mod.ts new file mode 100644 index 0000000..8af0386 --- /dev/null +++ b/@mizu/mizu/core/render/server/mod.ts @@ -0,0 +1,10 @@ +/** + * Mizu server-side renderer. + * @module + */ +import { Server } from "./server.ts" +export { Server as Mizu } from "./server.ts" +export type * from "./server.ts" + +/** Default Mizu {@linkcode Server} instance. */ +export default Server.default as Server diff --git a/@mizu/mizu/core/render/server/mod_test.ts b/@mizu/mizu/core/render/server/mod_test.ts new file mode 100644 index 0000000..998dfc7 --- /dev/null +++ b/@mizu/mizu/core/render/server/mod_test.ts @@ -0,0 +1 @@ +import "./mod.ts" diff --git a/@mizu/mizu/core/render/server/server.ts b/@mizu/mizu/core/render/server/server.ts new file mode 100644 index 0000000..bc5b703 --- /dev/null +++ b/@mizu/mizu/core/render/server/server.ts @@ -0,0 +1,69 @@ +// Imports +import type { Arg, Directive } from "@mizu/mizu/core/engine" +import { Context, Renderer } from "@mizu/mizu/core/engine" +import { Window } from "@mizu/mizu/core/vdom" +import { Mizu as Client } from "@mizu/mizu/core/render/client" +export type * from "@mizu/mizu/core/engine" + +/** + * Server side renderer. + * + * See {@link https://mizu.sh | mizu.sh documentation} for more details. + * @module + */ +export class Server { + /** Default directives. */ + static defaults = { + directives: [ + ...Client.defaults.directives, + ] as Array | string>, + } + + /** {@linkcode Server} constructor. */ + constructor({ directives = Server.defaults.directives, context = {} } = {} as { directives?: Arg; context?: ConstructorParameters[0] }) { + this.#options = { directives } + // deno-lint-ignore no-explicit-any + this.#context = new Context(context) + } + + /** Options for {@linkcode Renderer} instantiation. */ + readonly #options + + /** Linked {@linkcode Context}. */ + #context + + /** + * Default rendering context. + * + * All properties assigned to this object will be available during rendering. + */ + // deno-lint-ignore no-explicit-any + get context(): Record { + return this.#context.target + } + set context(context: Record) { + this.#context = new Context(context) + } + + /** + * Parse a HTML string and render all subtrees marked with a `*mizu` attribute. + * + * @example + * ```ts + * const mizu = new Server({ context: { foo: "bar" } }) + * await mizu.render(``) + * ``` + */ + async render(content: string | Arg, options?: Partial, "state" | "implicit" | "select"> & { context: Arg }>): Promise { + await using window = new Window(typeof content === "string" ? content : `${content.outerHTML}`) + const renderer = await new Renderer(window, this.#options).ready + let context = this.#context + if (options?.context) { + context = context.with(options.context) + } + return await renderer.render(renderer.document.documentElement, { implicit: true, ...options, context, state: { $renderer: "server", ...options?.state }, stringify: true }) + } + + /** Default {@linkcode Server} instance. */ + static readonly default = new Server() as Server +} diff --git a/@mizu/mizu/core/render/server/server_test.ts b/@mizu/mizu/core/render/server/server_test.ts new file mode 100644 index 0000000..5c02e8a --- /dev/null +++ b/@mizu/mizu/core/render/server/server_test.ts @@ -0,0 +1,31 @@ +import { expect, test } from "@libs/testing" +import { Window } from "@mizu/mizu/core/vdom" +import { Server } from "./server.ts" + +test()("`Server.render()` renders content", async () => { + const html = `` + const mizu = new Server({ directives: ["@mizu/test"], context: { foo: "" } }) + await expect(mizu.render(html, { select: "body" })).resolves.toBe(`server`) + await expect(mizu.render(html, { select: "body", context: { foo: "bar" }, state: { $renderer: "custom" } })).resolves.toBe(`barbarcustom`) +}) + +test()("`Server.render()` renders virtual nodes", async () => { + await using window = new Window(``) + const mizu = new Server({ directives: ["@mizu/test"], context: { foo: "bar" } }) + await expect(mizu.render(window.document.documentElement, { select: "body" })).resolves.toBe(`bar`) +}) + +test()("`Server.render()` returns doctype when no selector is passed", async () => { + const html = `` + const mizu = new Server() + await expect(mizu.render(html)).resolves.toMatch(/^/) +}) + +test()("`Server.context` can be edited", async () => { + await using window = new Window(``) + const mizu = new Server({ directives: ["@mizu/test"] }) + expect(mizu.context).toEqual({}) + mizu.context = { foo: "bar" } + expect(mizu.context).toEqual({ foo: "bar" }) + await expect(mizu.render(window.document.documentElement, { select: "body" })).resolves.toBe(`bar`) +}) diff --git a/@mizu/mizu/core/render/static/mod.ts b/@mizu/mizu/core/render/static/mod.ts new file mode 100644 index 0000000..6aa8e65 --- /dev/null +++ b/@mizu/mizu/core/render/static/mod.ts @@ -0,0 +1,10 @@ +/** + * Mizu static-site renderer. + * @module + */ +import { Static } from "./static.ts" +export { Static as Mizu } from "./static.ts" +export type * from "./static.ts" + +/** Default Mizu {@linkcode Static} instance. */ +export default Static.default as Static diff --git a/@mizu/mizu/core/render/static/mod_test.ts b/@mizu/mizu/core/render/static/mod_test.ts new file mode 100644 index 0000000..998dfc7 --- /dev/null +++ b/@mizu/mizu/core/render/static/mod_test.ts @@ -0,0 +1 @@ +import "./mod.ts" diff --git a/@mizu/mizu/core/render/static/static.ts b/@mizu/mizu/core/render/static/static.ts new file mode 100644 index 0000000..dd2f256 --- /dev/null +++ b/@mizu/mizu/core/render/static/static.ts @@ -0,0 +1,190 @@ +// Imports +import type { Arg, Arrayable, Context, Directive, Promisable, Renderer } from "@mizu/mizu/core/engine" +import { Logger } from "@libs/logger" +import { expandGlob } from "@std/fs" +import { common, dirname, join, resolve } from "@std/path" +import { readAll, readerFromStreamReader } from "@std/io" +import { Mizu as Server } from "@mizu/mizu/core/render/server" +export { Logger } +export type * from "@mizu/mizu/core/engine" + +/** Text encoder. */ +const encoder = new TextEncoder() + +/** Text decoder. */ +const decoder = new TextDecoder() + +/** + * Static site renderer. + * + * See {@link https://mizu.sh | mizu.sh documentation} for more details. + * @module + */ +export class Static extends Server { + /** Default directives. */ + static override defaults = { + directives: [ + ...Server.defaults.directives, + ] as Array | string>, + } + + /** {@linkcode Static} constructor. */ + constructor({ directives = Static.defaults.directives, context = {}, ...options } = {} as { directives?: Arg; context?: ConstructorParameters[0] } & GenerateOptions) { + super({ directives, context }) + options.logger ??= new Logger() + options.output ??= "./output" + options.clean ??= true + options.stat ??= Deno.lstat + options.write ??= Deno.writeFile + options.read ??= Deno.readFile + options.mkdir ??= Deno.mkdir + options.rmdir ??= Deno.remove + this.options = options as Required + } + + /** {@linkcode Logger} instance. */ + readonly options: Required + + /** + * Generate static files. + * + * Set `output` option to specify the output directory. + * Set `clean` option to clean the output directory before generation. + * + * Content can be retrieved from local files, callback return or URLs. + * Rendering using {@linkcode Static.render()} can be enabled by setting the `render` option. + * + * @example + * ```ts + * const mizu = new Static({ logger: new Logger({ level: "disabled" }), directives: ["@mizu/test"], output: "/fake/path" }) + * await mizu.generate( + * [ + * // Copy content from local files + * { source: "**\/*", directory: "/fake/static", destination: "static" }, + * // Copy content from callback return + * { source: () => "foobar", destination: "foo.html" }, + * // Copy content from URL + * { source: new URL(`data:text/html,

foobar

`), destination: "bar.html" }, + * // You can also render content + * { source: new URL(`data:text/html,

`), destination: "baz.html", render: { context: { foo: "bar" } } }, + * ], + * // No-op: do not actually write files and directories + * { mkdir: () => null as any, write: () => null as any }, + * ) + * ``` + */ + async generate( + contents: Array, + { logger: log = this.options.logger, output = this.options.output, clean = this.options.clean, stat = this.options.stat, write = this.options.write, read = this.options.read, mkdir = this.options.mkdir, rmdir = this.options.rmdir } = {} as GenerateOptions, + ): Promise { + // Prepare output directory + output = resolve(output) + log.with({ output }).info("generating...") + if ((await stat(output).then(() => true).catch(() => false)) && clean) { + log.with({ path: output }).debug("cleaning...") + await rmdir(output, { recursive: true }) + log.with({ path: output }).ok("cleaned") + } + await mkdir(output, { recursive: true }) + log.with({ path: output }).ok() + + // Generate files + for (const content of contents) { + const path = join(output, content.destination) + log.with({ path }).debug("writing...") + // Copy content from URL + if (content.source instanceof URL) { + const bytes = await fetch(content.source).then((response) => response.bytes()) + await write(path, await this.#render(bytes, content.render)) + log.with({ path }).ok() + } // Copy content from callback + else if (typeof content.source === "function") { + let bytes = await content.source() + if (typeof bytes === "string") { + bytes = encoder.encode(bytes) + } + await write(path, await this.#render(bytes, content.render)) + log.with({ path }).ok() + } // Copy content from local files + else { + const root = (content as GlobSource).directory + for (const source of [content.source].flat()) { + for await (const { path: from } of expandGlob(source, { root, includeDirs: false })) { + const path = join(output, from.replace(common([root, from]), "")) + await mkdir(dirname(path), { recursive: true }) + await write(path, await this.#render(await read(from), content.render)) + log.with({ path }).ok() + } + } + } + log.ok("done!") + } + } + + /** Used by {@linkcode Static#generate()} to render content. */ + async #render(content: Arg, 1>, render?: Arg): Promise, 1>> { + if (render) { + if (content instanceof ReadableStream) { + content = await readAll(readerFromStreamReader(content.getReader())) + } + const rendered = await this.render(decoder.decode(content), render) + content = encoder.encode(rendered) + } + return content + } + + /** Default {@linkcode Static} instance. */ + static override readonly default = new Static() as Static +} + +/** Options for {@linkcode Static.generate()}. */ +export type GenerateOptions = { + /** {@linkcode Logger} instance. */ + logger?: Logger + /** Output directory. */ + output?: string + /** Clean output directory before generation. */ + clean?: boolean + /** Stat callback. */ + stat?: (path: string) => Promise + /** Read callback. */ + read?: (path: string) => Promise> + /** Write callback. */ + write?: (path: string, data: Awaited>>) => Promisable + /** Make directory callback. */ + mkdir?: (path: string, options?: { recursive?: boolean }) => Promisable + /** Remove directory callback. */ + rmdir?: (path: string, options?: { recursive?: boolean }) => Promisable +} + +/** Glob source. */ +export type GlobSource = { + /** Destination path (excluding filename). */ + destination: string + /** Source directory. */ + directory: string + /** A list of file globs. */ + source: Arrayable + /** Whether to render content with {@linkcode Static.render()}. */ + render?: Arg +} + +/** Callback source. */ +export type CallbackSource = { + /** Destination path (including filename). */ + destination: string + /** A callback that returns file content. */ + source: () => Promisable, 1> | string> + /** Whether to render content with {@linkcode Static.render()}. */ + render?: Arg +} + +/** URL source. */ +export type URLSource = { + /** Destination path (including filename). */ + destination: string + /** Source URL. */ + source: URL + /** Whether to render content with {@linkcode Static.render()}. */ + render?: Arg +} diff --git a/@mizu/mizu/core/render/static/static_test.ts b/@mizu/mizu/core/render/static/static_test.ts new file mode 100644 index 0000000..46a8ae8 --- /dev/null +++ b/@mizu/mizu/core/render/static/static_test.ts @@ -0,0 +1,80 @@ +import type { testing } from "@libs/testing" +import { expect, fn, test } from "@libs/testing" +import { Logger } from "@libs/logger" +import { join } from "@std/path" +import { Static } from "./static.ts" +const output = "/fake/path" +const logger = new Logger({ level: "disabled" }) +const encoder = new TextEncoder() + +test()("`Server.generate()` manages `output` directory", async () => { + let exists = false + const stat = fn(() => exists ? Promise.resolve({}) : Promise.reject("Not found")) as testing + const mkdir = fn() as testing + const rmdir = fn() as testing + const mizu = new Static({ logger, output, stat, mkdir, rmdir }) + await mizu.generate([], { clean: false }) + expect(stat).toHaveBeenCalledWith(output) + expect(rmdir).not.toBeCalled() + expect(mkdir).toHaveBeenCalledWith(output, { recursive: true }) + exists = true + await mizu.generate([], { clean: true }) + expect(stat).toHaveBeenCalledWith(output) + expect(rmdir).toHaveBeenCalledWith(output, { recursive: true }) + expect(mkdir).toHaveBeenCalledWith(output, { recursive: true }) +}, { permissions: { read: true } }) + +test()("`Server.generate()` can retrieve content from urls", async () => { + const write = fn() as testing + const mizu = new Static({ directives: ["@mizu/test"], logger, output, write, mkdir: () => null as testing }) + await mizu.generate([ + { + source: new URL(`data:text/html,

`), + destination: "bar.html", + render: { select: "p", context: { foo: "bar" } }, + }, + ]) + expect(write).toHaveBeenCalledWith(join(output, "bar.html"), encoder.encode(`

bar

`)) +}, { permissions: { read: true } }) + +test()("`Server.generate()` can retrieve content from functions", async () => { + const mizu = new Static({ directives: ["@mizu/test"], logger, output, mkdir: () => null as testing }) + for ( + const source of [ + () => `

`, + () => encoder.encode(`

`), + () => + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(`

`)) + controller.close() + }, + }), + ] + ) { + const write = fn() as testing + await mizu.generate([ + { + source, + destination: "bar.html", + render: { select: "p", context: { foo: "bar" } }, + }, + ], { write }) + expect(write).toHaveBeenCalledWith(join(output, "bar.html"), encoder.encode(`

bar

`)) + } +}, { permissions: { read: true } }) + +test()("`Server.generate()` can retrieve content from local files", async () => { + const write = fn() as testing + const mkdir = fn() as testing + const mizu = new Static({ directives: ["@mizu/test"], logger, output, write, mkdir, read: () => Promise.resolve(encoder.encode(`

`)) }) + await mizu.generate([ + { + source: "mod.ts", + directory: import.meta.dirname!, + destination: ".", + render: { select: "p", context: { foo: "bar" } }, + }, + ]) + expect(write).toHaveBeenCalledWith(join(output, "mod.ts"), encoder.encode(`

bar

`)) +}, { permissions: { read: true } }) diff --git a/@mizu/mizu/core/testing/filter.ts b/@mizu/mizu/core/testing/filter.ts new file mode 100644 index 0000000..659f5bf --- /dev/null +++ b/@mizu/mizu/core/testing/filter.ts @@ -0,0 +1,66 @@ +// Imports +import type { Arg, Directive, Nullable, Renderer } from "@mizu/mizu/core/engine" +import { format } from "./format.ts" + +/** + * Recursively filters an {@linkcode https://developer.mozilla.org/en-US/docs/Web/API/Element | Element} and its subtree and returns the {@linkcode https://developer.mozilla.org/docs/Web/API/Element/innerHTML | Element.innerHTML}. + * + * This function can be used to compare two HTML documents. + * + * ```ts + * import { expect } from "@libs/testing" + * import { Window } from "@mizu/mizu/core/vdom" + * import { Renderer } from "@mizu/mizu/core/engine" + * const renderer = await new Renderer(new Window()).ready + * + * await using a = new Window(`foo`) + * await using b = new Window(`foo`) + * expect(filter(renderer, a.document.documentElement)).toBe(filter(renderer, b.document.documentElement)) + * ``` + */ +export function filter(renderer: Renderer, node: Nullable, { format: _format = true, comments = true, directives = ["*warn", "*id"] as Array, clean = "" } = {}): string { + if (!node) { + return "" + } + let html = _filter(renderer, node.cloneNode(true) as Element, { comments, directives, clean }).innerHTML + if (_format) { + html = format(html) + } + return html.trim() +} + +/** Called by {@linkcode filter()}. */ +function _filter(renderer: Renderer, node: Element, { comments, directives, clean } = {} as Arg): Element { + // Remove comments if asked + if ((node.nodeType === renderer.window.Node.COMMENT_NODE) && (!comments)) { + node.remove() + return node + } + // Clean attributes if asked + if ((node.nodeType === renderer.window.Node.ELEMENT_NODE) && clean) { + const pattern = new RegExp(clean) + Array.from(node.attributes).forEach((attribute) => pattern.test(attribute.name) && node.removeAttribute(attribute.name)) + } + // Patch `style` attribute to be consistent with `deno fmt` + if ((node.nodeType === renderer.window.Node.ELEMENT_NODE) && (node.hasAttribute("style")) && (!node.getAttribute("style")!.endsWith(";"))) { + node.setAttribute("style", `${node.getAttribute("style")};`) + } + // Remove directives if asked + if ((node.nodeType === renderer.window.Node.ELEMENT_NODE) && (Array.isArray(directives)) && (!directives.includes("*"))) { + renderer.directives.forEach((directive) => { + if (directives.includes(`${directive.name}`)) { + return + } + renderer.getAttributes(node as HTMLElement, directive.name).forEach((attribute) => { + node.removeAttribute(attribute.name) + }) + }) + } + // Remove node if asked + if ((node.nodeType === renderer.window.Node.ELEMENT_NODE) && (node.hasAttribute("filter-remove"))) { + node.remove() + } + // Recurse + Array.from(node.childNodes).forEach((child) => _filter(renderer, child as Element, arguments[2])) + return node +} diff --git a/@mizu/mizu/core/testing/filter_test.ts b/@mizu/mizu/core/testing/filter_test.ts new file mode 100644 index 0000000..633f00f --- /dev/null +++ b/@mizu/mizu/core/testing/filter_test.ts @@ -0,0 +1,64 @@ +import { expect, test } from "@libs/testing" +import { Window } from "@mizu/mizu/core/vdom" +import { Phase, Renderer } from "@mizu/mizu/core/engine" +import { filter } from "./filter.ts" +import { format } from "./format.ts" +const renderer = await new Renderer(new Window()).ready + +test()("`filter()` filters `Element` and returns its `Element.innerHTML`", async () => { + await using window = new Window(`
`) + const main = window.document.querySelector("expect") + expect(filter(renderer, main)).toBe(format("
")) + expect(filter(renderer, null)).toBe(format("")) +}) + +test()("`filter()` formats `style` attributes to match with `deno fmt`", async () => { + await using window = new Window(`
`) + const main = window.document.querySelector("expect") + expect(filter(renderer, main)).toBe(format(`
`)) +}) + +test()("`filter()` recurse on `Node.childNodes`", async () => { + await using window = new Window(`
`) + const main = window.document.querySelector("expect") + expect(filter(renderer, main)).toBe(format(`
`)) +}) + +test()("`filter()` formats `Node.innerHTML` when `format: true`", async () => { + await using window = new Window(`
\nfoo
`) + const main = window.document.querySelector("expect") + expect(filter(renderer, main, { format: false })).toBe("
\nfoo
") + expect(filter(renderer, main, { format: true })).toBe(format("
\n foo\n
")) +}) + +test()("`filter()` filters out `Node.COMMENT_NODE` when `comments: false`", async () => { + await using window = new Window(`
`) + const main = window.document.querySelector("expect") + expect(filter(renderer, main, { comments: false })).toBe(format("
")) + expect(filter(renderer, main, { comments: true })).toBe(format("
")) +}) + +test()("`filter()` filters out `Attr` not matching any allowed `directives`", async () => { + await using window = new Window(`
`) + const main = window.document.querySelector("expect") + const directives = [{ name: "*foo", phase: Phase.TESTING }, { name: "*bar", phase: Phase.TESTING }, { name: /^~(?)/, phase: Phase.TESTING }] + const renderer = await new Renderer(window, { directives }).ready + expect(filter(renderer, main, { directives: [] })).toBe(format(`
`)) + expect(filter(renderer, main, { directives: ["*bar"] })).toBe(format(`
`)) + expect(filter(renderer, main, { directives: ["/^~(?)/"] })).toBe(format(`
`)) + expect(filter(renderer, main, { directives: ["*"] })).toBe(format(`
`)) +}) + +test()("`filter()` filters out `Attr` matching `clean` pattern", async () => { + await using window = new Window(`
`) + const main = window.document.querySelector("expect") + const renderer = await new Renderer(window).ready + expect(filter(renderer, main, { clean: "^test-" })).toBe(format(`
`)) +}) + +test()("`filter()` filters out `Node` with `filter-remove` attribute", async () => { + await using window = new Window(`
`) + const main = window.document.querySelector("expect") + const renderer = await new Renderer(window).ready + expect(filter(renderer, main)).toBe(format(`
`)) +}) diff --git a/@mizu/mizu/core/testing/fixtures/mod_test.html b/@mizu/mizu/core/testing/fixtures/mod_test.html new file mode 100644 index 0000000..e6a4f54 --- /dev/null +++ b/@mizu/mizu/core/testing/fixtures/mod_test.html @@ -0,0 +1,166 @@ + + + + + +

+
+ +

+
+ + + +

foo

+
+
+ + + + +

+
+ +

foo

+
+ + +

foo

+
+
+ + + + + + + + + +

+ +

+
+ +

+ +

+
+ +

+
+
+ + + + +

+
+ +

foo

+
+ +

foo

+
+
+ + + + +

+ foo +

+
+ + +

+ foo +

+
+ +

+ foo +

+
+
+ + + +

+
+ +

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + foo + <foo> + + + diff --git a/@mizu/mizu/core/testing/format.ts b/@mizu/mizu/core/testing/format.ts new file mode 100644 index 0000000..5a059cb --- /dev/null +++ b/@mizu/mizu/core/testing/format.ts @@ -0,0 +1,15 @@ +// Imports +import { createStreaming } from "@dprint/formatter" + +/** HTML formatter. */ +const formatter = await createStreaming(fetch("https://lecoq.io/cdn/dprint/markup_fmt-v0.13.1.wasm")) +formatter.setConfig({}, { printWidth: 120, closingBracketSameLine: true, closingTagLineBreakForEmpty: "never", preferAttrsSingleLine: true, whitespaceSensitivity: "ignore" }) + +/** Format HTML. */ +export function format(html: string): string { + const options = { filePath: "test.html", fileText: html } + return formatter.formatText({ ...options, fileText: formatter.formatText({ ...options, overrideConfig: { printWidth: 0 } }) }) + .split("\n") + .filter((line) => line) + .join("\n") +} diff --git a/@mizu/mizu/core/testing/format_test.ts b/@mizu/mizu/core/testing/format_test.ts new file mode 100644 index 0000000..ed57622 --- /dev/null +++ b/@mizu/mizu/core/testing/format_test.ts @@ -0,0 +1,24 @@ +import { expect, test } from "@libs/testing" +import { format } from "./format.ts" + +test()("`format()` formats html and tries to put a most one node per line", () => { + expect(format(` +
foo
+`.trim())).toBe(` +
+ + foo + + +
+`.trim()) +}) + +test()("`format()` formats html and tries to put empty tags on same line", () => { + expect(format(` +
+
+`.trim())).toBe(` +
+`.trim()) +}) diff --git a/@mizu/mizu/core/testing/mod.ts b/@mizu/mizu/core/testing/mod.ts new file mode 100644 index 0000000..5d24204 --- /dev/null +++ b/@mizu/mizu/core/testing/mod.ts @@ -0,0 +1,8 @@ +/** + * Testing utilities. + * + * @module + */ +export * from "./format.ts" +export * from "./filter.ts" +export * from "./test.ts" diff --git a/@mizu/mizu/core/testing/mod_test.ts b/@mizu/mizu/core/testing/mod_test.ts new file mode 100644 index 0000000..998dfc7 --- /dev/null +++ b/@mizu/mizu/core/testing/mod_test.ts @@ -0,0 +1 @@ +import "./mod.ts" diff --git a/@mizu/mizu/core/testing/test.ts b/@mizu/mizu/core/testing/test.ts new file mode 100644 index 0000000..e5fa502 --- /dev/null +++ b/@mizu/mizu/core/testing/test.ts @@ -0,0 +1,298 @@ +// Imports +import type { testing } from "@libs/testing" +import type { Arg, Nullable, VirtualWindow } from "@mizu/mizu/core/engine" +import { delay, retry } from "@std/async" +import { unescape } from "@std/html" +import { bgMagenta } from "@std/fmt/colors" +import { expect as _expect, fn, Status, test as _test } from "@libs/testing" +import { AsyncFunction } from "@libs/typing/func" +import { Context } from "@libs/reactive" +import { Window } from "@mizu/mizu/core/vdom" +import { Renderer } from "@mizu/mizu/core/engine" +import { filter } from "./filter.ts" + +/** + * Test a HTML file with the specified test cases. + * + * This runner is intended to test directives in a more convenient way. + * + * @example + * ```ts ignore + * await test(import.meta.resolve("./operations_test.html")) + * ``` + */ +export async function test(path: string | ImportMeta, runner = _test) { + if (typeof path === "object") { + path = path.resolve("./mod_test.html") + } + const { document } = new Window(await fetch(path).then((response) => response.text())) + document.querySelectorAll("body > test").forEach((testcase: testing) => { + const test = testcase.hasAttribute("skip") ? runner.skip : testcase.hasAttribute("only") ? runner.only : runner + test()(`${testcase.getAttribute("name") ?? ""}`.replace(/^\[(.*?)\]/, (_, name) => bgMagenta(` ${name} `)), async () => { + const testing = { + renderer: await new Renderer(new Window(), { directives: [] }).ready, + rendered: null, + renderable: "", + directives: [], + context: new Context({ + fetch() { + let url = arguments[0] + if (!URL.canParse(url)) { + const { hostname, port } = testing.http.server?.addr as testing + url = `http://${hostname}:${port}${arguments[0]}` + } + return fetch(url, ...Array.from(arguments).slice(1)) + }, + }), + storage: {}, + http: { server: null, request: null, response: null }, + } as Testing + try { + if (document.querySelector("body > load")) { + await load(document.querySelector("body > load") as HTMLElement, testing) + } + for (const operation of testcase.children) { + switch (operation.tagName.toLowerCase()) { + case "load": + await load(operation, testing) + break + case "render": + await render(operation, testing) + break + case "script": + await script(operation, testing) + break + case "expect": + await expect(operation, testing) + break + case "http-server": + await http(operation, testing) + break + } + } + } finally { + await testing.http.server?.shutdown() + } + }, { permissions: { net: ["localhost", "0.0.0.0"] } }) + }) +} + +/** + * Load the specified directives. + * + * Multiple directives can be specified by separating them with a comma. + * + * @example + * ```xml + * + * + * + * + * ``` + */ +async function load(operation: HTMLElement, testing: Testing) { + if (operation.hasAttribute("directives")) { + testing.directives = operation.getAttribute("directives")!.split(",").map((directive) => directive.trim()) + } + await testing.renderer.load(testing.directives) +} + +/** + * Render the specified content using {@linkcode Renderer.render()}. + * + * The same {@linkcode Renderer} instance is reused unless the operation tag is not empty. + * If it is empty, a re-render is performed instead with the last {@linkcode Testing.renderable} content. + * + * Options: + * - `explicit?: boolean = false`: Whether {@linkcode Renderer.render()} should be called with the `implicit: false`. + * + * @example + * ```xml + * + *
...
+ *
+ * ``` + * @example + * ```xml + * + * ``` + */ +async function render(operation: HTMLElement, testing: Testing) { + if (operation.childNodes.length) { + testing.renderable = operation.innerHTML + testing.renderer = await new Renderer(new Window(`${testing.renderable}`), { directives: testing.directives }).ready + } + testing.rendered = await testing.renderer.render(testing.renderer.document.documentElement, { context: testing.context, select: "body", implicit: !operation.hasAttribute("explicit") }) as HTMLElement +} + +/** + * Evaluate the specified script. + * + * See actual source code for a list of globally available variables. + * + * @example + * ```xml + * + * ``` + */ +async function script(operation: HTMLElement, testing: Testing) { + const args = { + testing, + rendered: testing.rendered, + window: testing.renderer.window, + document: testing.renderer.document, + context: testing.context.target, + storage: testing.storage, + http: { + get request() { + return testing.http.request + }, + get response() { + return testing.http.response + }, + }, + Node: testing.renderer.window.Node, + HTMLElement: testing.renderer.window.HTMLElement, + Event: testing.renderer.window.Event, + NodeFilter: testing.renderer.window.NodeFilter, + KeyboardEvent: testing.renderer.window.KeyboardEvent, + MouseEvent: testing.renderer.window.MouseEvent, + expect: _expect, + fn, + delay, + retry, + fetch: testing.context.target.fetch, + Status, + } as VirtualWindow + const script = new AsyncFunction(...Object.keys(args), operation.innerHTML) + await _expect(script(...Object.values(args))).resolves.not.toThrow() +} + +/** + * Compare current {@linkcode Testing.rendered} content against the specified content. + * + * Options: + * - `comments?: string`: Whether to preserve comments. + * - `format?: string`: Whether to preserve formatting. + * - `directive?: string`: A list of directives attributes to preserve. + * - `clean?: string`: A list of attributes to clean. + * + * See {@linkcode filter()} for more details about options. + * + * @example + * ```xml + * + * + *
...
+ *
+ * + *
...
+ *
+ * ``` + */ +async function expect(operation: HTMLElement, testing: Testing) { + const options = {} as Arg + if (operation.getAttribute("comments") === "false") { + options.comments = false + } + if (operation.getAttribute("format") === "false") { + options.format = false + } + if (operation.hasAttribute("directives")) { + options.directives = operation.getAttribute("directives")!.split(",").map((directive) => directive.trim()) + } + if (operation.hasAttribute("clean")) { + options.clean = operation.getAttribute("clean")! + } + _expect(filter(testing.renderer, testing.rendered, options)).toBe(filter(testing.renderer, operation, options)) + return await Promise.resolve() +} + +/** + * Spawn a local HTTP server that can be used to test HTTP requests. + * + * The server is automatically shutdown after the test case is finished. + * + * Options: + * - `path?: string = "/"`: The {@linkcode https://developer.mozilla.org/en-US/docs/Web/API/URL/pathname | URL.pathname} to match. + * - `status?: string = "200"`: The {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Status | HTTP status code} to return. + * - `redirect?: string`: The {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections | HTTP redirect} to perform (status code is always set to {@linkcode Status.TemporaryRedirect}). + * - `escaped?: boolean`: Whether specified content is escaped and should be unescaped. + * + * Exposes: + * - {@linkcode Testing.http.request} + * - {@linkcode Testing.http.response} + * + * @example + * ```xml + * + * + * + * <?xml version="1.0" encoding="UTF-8"?> + * + * + * + * ``` + */ +async function http(operation: HTMLElement, testing: Testing) { + const { promise, resolve } = Promise.withResolvers() + testing.http.server = Deno.serve({ port: 0, onListen: () => resolve() }, async (request) => { + const url = new URL(request.url) + testing.http.request = Object.assign(request, { received: { body: await request.text(), headers: new Headers(request.headers) } }) + testing.http.response = new Response(null, { status: Status.NotFound }) + for (const route of Array.from(operation.querySelectorAll("response"))) { + if (new URLPattern(route.getAttribute("path") ?? "/", url.origin).test(url)) { + const status = Status[route.getAttribute("status") as keyof typeof Status] || Number(route.getAttribute("status")) || Status.OK + if (route.hasAttribute("redirect")) { + testing.http.response = Response.redirect(new URL(route.getAttribute("redirect")!, url.origin).href, Status.TemporaryRedirect) + } else { + let content = route.innerHTML + if (route.hasAttribute("escaped")) { + content = unescape(content) + } + testing.http.response = new Response(content, { status }) + } + break + } + } + return testing.http.response + }) + const { hostname, port } = testing.http.server?.addr as testing + globalThis.location = Object.assign(new URL(`http://${hostname}:${port}`), { + ancestorOrigins: testing.renderer.window.location.ancestorOrigins, + assign: testing.renderer.window.location.assign, + replace: testing.renderer.window.location.replace, + reload: testing.renderer.window.location.reload, + }) + return await promise +} + +/** Testing context. */ +export type Testing = { + /** {@linkcode Renderer} instance. */ + renderer: Renderer + /** Rendered content. */ + rendered: Nullable + /** Renderable {@linkcode https://developer.mozilla.org/docs/Web/API/Element/innerHTML | Element.innerHTML}, set to the last value of {@linkcode render()}. */ + renderable: string + /** List of loaded directives. */ + directives: Arg + /** Current {@linkcode Context}. */ + context: Context + /** Shared storage that can be used across different operations. */ + storage: Record + /** HTTP testing. */ + http: { + /** Local HTTP server. */ + server: Nullable + /** Last received `Request` with additional parsed properties for easier testing. */ + request: Nullable + /** Last returned `Response`. */ + response: Nullable + } +} diff --git a/@mizu/mizu/core/testing/test_test.ts b/@mizu/mizu/core/testing/test_test.ts new file mode 100644 index 0000000..9f941e5 --- /dev/null +++ b/@mizu/mizu/core/testing/test_test.ts @@ -0,0 +1,32 @@ +import type { testing } from "@libs/testing" +import { expect, fn, test } from "@libs/testing" +import { test as runner } from "./test.ts" + +test()("`` resolves test files and generates `Deno.test()` cases", async () => { + const callback = () => () => {} + const test = Object.assign(fn(callback), { skip: fn(callback), only: fn(callback) }) + await runner(`data:text/html;charset=utf-8,`, test as testing) + expect(test).toBeCalledTimes(1) + expect(test.skip).not.toBeCalled() + expect(test.only).not.toBeCalled() +}) + +test()("`` resolves test files and generates `Deno.only()` cases", async () => { + const callback = () => () => {} + const test = Object.assign(fn(callback), { skip: fn(callback), only: fn(callback) }) + await runner(`data:text/html;charset=utf-8,`, test as testing) + expect(test).not.toBeCalled() + expect(test.skip).not.toBeCalled() + expect(test.only).toBeCalledTimes(1) +}) + +test()("`` resolves test files and generates `Deno.ignore()` cases", async () => { + const callback = () => () => {} + const test = Object.assign(fn(callback), { skip: fn(callback), only: fn(callback) }) + await runner(`data:text/html;charset=utf-8,`, test as testing) + expect(test).not.toBeCalled() + expect(test.skip).toBeCalledTimes(1) + expect(test.only).not.toBeCalled() +}) + +await runner(import.meta.resolve("./fixtures/mod_test.html")) diff --git a/@mizu/mizu/core/vdom/jsdom.ts b/@mizu/mizu/core/vdom/jsdom.ts new file mode 100644 index 0000000..d8ee9f9 --- /dev/null +++ b/@mizu/mizu/core/vdom/jsdom.ts @@ -0,0 +1,22 @@ +// Imports +import type { VirtualWindow } from "@mizu/mizu/core/engine" +import { JSDOM } from "@npm/jsdom" +export type { VirtualWindow } + +/** + * Virtual {@linkcode https://developer.mozilla.org/docs/Web/API/Window | Window} implementation based on {@link https://github.com/jsdom/jsdom | JSDOM}. + * + * @example + * ```ts + * await using window = new Window("") + * console.assert(window.document.documentElement.tagName === "HTML") + * ``` + */ +const Window = (function (content: string) { + const { window } = new JSDOM(content, { url: globalThis.location?.href, contentType: "text/html" }) + window[Symbol.asyncDispose] = async () => { + await window.close() + } + return window +}) as unknown as (new (content?: string) => Window & VirtualWindow & { [Symbol.asyncDispose]: () => Promise }) +export { Window } diff --git a/@mizu/mizu/core/vdom/jsdom_test.ts b/@mizu/mizu/core/vdom/jsdom_test.ts new file mode 100644 index 0000000..fec7905 --- /dev/null +++ b/@mizu/mizu/core/vdom/jsdom_test.ts @@ -0,0 +1,8 @@ +import { expect, test } from "@libs/testing" +import { Window } from "./jsdom.ts" + +test()("`Window.constructor()` returns a new instance", async () => { + await using window = new Window() + expect(window.document).toBeDefined() + expect(window.location.href).toBe(globalThis.location?.href ?? "about:blank") +}) diff --git a/@mizu/mizu/core/vdom/mod.ts b/@mizu/mizu/core/vdom/mod.ts new file mode 100644 index 0000000..91de5bf --- /dev/null +++ b/@mizu/mizu/core/vdom/mod.ts @@ -0,0 +1,11 @@ +/** + * Virtual {@linkcode https://developer.mozilla.org/docs/Web/API/Window | Window} implementations. + * + * They are used to render HTML content in server-side environments. + * + * > [!IMPORTANT] + * > All implementations must closely follow the {@link https://html.spec.whatwg.org/ | HTML specification} to ensure compatibility with different environments. + * + * @module + */ +export * from "./jsdom.ts" diff --git a/@mizu/mizu/core/vdom/mod_test.ts b/@mizu/mizu/core/vdom/mod_test.ts new file mode 100644 index 0000000..998dfc7 --- /dev/null +++ b/@mizu/mizu/core/vdom/mod_test.ts @@ -0,0 +1 @@ +import "./mod.ts" diff --git a/@mizu/mizu/deno.jsonc b/@mizu/mizu/deno.jsonc new file mode 100644 index 0000000..09be07d --- /dev/null +++ b/@mizu/mizu/deno.jsonc @@ -0,0 +1,13 @@ +{ + "name": "@mizu/mizu", + "version": "0.3.0", + "exports": { + ".": "./mod.ts", + "./core/engine": "./core/engine/mod.ts", + "./core/render/client": "./core/render/client/mod.ts", + "./core/render/server": "./core/render/server/mod.ts", + "./core/render/static": "./core/render/static/mod.ts", + "./core/testing": "./core/testing/mod.ts", + "./core/vdom": "./core/vdom/mod.ts" + } +} diff --git a/@mizu/mizu/mod.html b/@mizu/mizu/mod.html new file mode 100644 index 0000000..09cfbef --- /dev/null +++ b/@mizu/mizu/mod.html @@ -0,0 +1,22 @@ + + *mizu +

+ Enable mizu rendering for the element and its children. +

+ +
+ +
+
+ + For performance reasons, it is not possible to specify any attribute [tag] or .modifiers with this directive. + + + If you are using mizu programmatically, you can chose whether to require this directive or not to enable mizu rendering using the implicit option. By default, rendering is explicit in Client-Side APIs and implicit in + Server-Side APIs. + + + HTMLElement + The closest element that declares a *mizu directive. + +
diff --git a/@mizu/mizu/mod.ts b/@mizu/mizu/mod.ts new file mode 100644 index 0000000..b93a4d3 --- /dev/null +++ b/@mizu/mizu/mod.ts @@ -0,0 +1,15 @@ +// Imports +import { type Directive, Phase } from "@mizu/mizu/core/engine" +export type * from "@mizu/mizu/core/engine" + +/** `*mizu` directive. */ +export const _mizu = { + name: "*mizu", + phase: Phase.ELIGIBILITY, + execute(_, element) { + return { state: { $root: element } } + }, +} as Directive & { name: string } + +/** Default exports. */ +export default _mizu diff --git a/@mizu/mizu/mod_test.html b/@mizu/mizu/mod_test.html new file mode 100644 index 0000000..8d8898a --- /dev/null +++ b/@mizu/mizu/mod_test.html @@ -0,0 +1,45 @@ + + + + +
+
+

+
+
+
+

+
+
+ +
+
+

foo

+
+
+
+

+
+
+
+ + + +
+
+
+
+
+
+
+
+ +
+
+
DIV
+
MAIN
+
+
MAIN
+
+
+
diff --git a/@mizu/mizu/mod_test.ts b/@mizu/mizu/mod_test.ts new file mode 100644 index 0000000..f676e88 --- /dev/null +++ b/@mizu/mizu/mod_test.ts @@ -0,0 +1 @@ +await import("@mizu/mizu/core/testing").then(({ test }) => test(import.meta)) diff --git a/deno.jsonc b/deno.jsonc index b2de4f1..203c1d9 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -93,7 +93,7 @@ // "@mizu/if", // "@mizu/is", // "@mizu/markdown", - // "@mizu/mizu", + "@mizu/mizu" // "@mizu/model", // "@mizu/once", // "@mizu/ref", diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..ea0a158 --- /dev/null +++ b/deno.lock @@ -0,0 +1,338 @@ +{ + "version": "4", + "specifiers": { + "jsr:@dprint/formatter@0.4": "0.4.1", + "jsr:@libs/logger@3": "3.1.0", + "jsr:@libs/reactive@4": "4.0.0", + "jsr:@libs/run@3": "3.0.0", + "jsr:@libs/testing@3": "3.0.1", + "jsr:@libs/typing@3": "3.1.0", + "jsr:@libs/typing@3.1": "3.1.0", + "jsr:@std/assert@1": "1.0.6", + "jsr:@std/assert@^1.0.6": "1.0.6", + "jsr:@std/async@1": "1.0.5", + "jsr:@std/bytes@^1.0.2": "1.0.2", + "jsr:@std/collections@1": "1.0.7", + "jsr:@std/expect@1": "1.0.4", + "jsr:@std/fmt@1": "1.0.2", + "jsr:@std/html@1": "1.0.3", + "jsr:@std/http@1": "1.0.7", + "jsr:@std/internal@^1.0.4": "1.0.4", + "jsr:@std/regexp@1": "1.0.0", + "jsr:@std/streams@1": "1.0.6", + "npm:highlight.js@11": "11.10.0", + "npm:jsdom@25": "25.0.1" + }, + "jsr": { + "@dprint/formatter@0.4.1": { + "integrity": "96449ab83aa9f72df98caa5030d3f4a921c5c29d9b0e0d0da83d79e2024a9637" + }, + "@libs/logger@3.1.0": { + "integrity": "6d36122baa9b640474723e2f9d1d93347938dd001b48fa5716a5528c16654fe5" + }, + "@libs/reactive@4.0.0": { + "integrity": "51cd2c5148866ec6b34843e9f31e9186e3c2fc515dd9d6a7c6d14fcfe07f1b7b", + "dependencies": [ + "jsr:@libs/typing@3", + "jsr:@std/collections" + ] + }, + "@libs/run@3.0.0": { + "integrity": "cdb2252ef59668446c135de9cde55ae6264c11943626448d365766f0929d7132", + "dependencies": [ + "jsr:@libs/logger", + "jsr:@libs/typing@3", + "jsr:@std/async", + "jsr:@std/streams" + ] + }, + "@libs/testing@3.0.1": { + "integrity": "97c7c5d8a8a8727c28fdbe29f7141a6ec9be787c016075b69967b56c57074e60", + "dependencies": [ + "jsr:@libs/run", + "jsr:@libs/typing@3", + "jsr:@std/assert@1", + "jsr:@std/expect", + "jsr:@std/fmt", + "jsr:@std/html", + "jsr:@std/http", + "npm:highlight.js" + ] + }, + "@libs/typing@3.1.0": { + "integrity": "091b59f57a99f84c9fccf8f59534f77f177705ac25183b575c83fd7aa6dcfafe" + }, + "@std/assert@1.0.6": { + "integrity": "1904c05806a25d94fe791d6d883b685c9e2dcd60e4f9fc30f4fc5cf010c72207", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/async@1.0.5": { + "integrity": "31d68214bfbb31bd4c6022401d484e3964147c76c9220098baa703a39b6c2da6" + }, + "@std/bytes@1.0.2": { + "integrity": "fbdee322bbd8c599a6af186a1603b3355e59a5fb1baa139f8f4c3c9a1b3e3d57" + }, + "@std/collections@1.0.7": { + "integrity": "6cff6949907372564735e25a5c6a7945d67cc31913b1b4d1278d08c2a5a3291d" + }, + "@std/expect@1.0.4": { + "integrity": "97f68a445a9de0d9670200d2b7a19a7505a01b2cb390a983ba8d97d90ce30c4f", + "dependencies": [ + "jsr:@std/assert@^1.0.6", + "jsr:@std/internal" + ] + }, + "@std/fmt@1.0.2": { + "integrity": "87e9dfcdd3ca7c066e0c3c657c1f987c82888eb8103a3a3baa62684ffeb0f7a7" + }, + "@std/html@1.0.3": { + "integrity": "7a0ac35e050431fb49d44e61c8b8aac1ebd55937e0dc9ec6409aa4bab39a7988" + }, + "@std/http@1.0.7": { + "integrity": "9b904fc256678a5c9759f1a53a24a3fdcc59d83dc62099bb472683b6f819194c" + }, + "@std/internal@1.0.4": { + "integrity": "62e8e4911527e5e4f307741a795c0b0a9e6958d0b3790716ae71ce085f755422" + }, + "@std/regexp@1.0.0": { + "integrity": "158628d134c49a0858afe05017c4666f5f73d3a56602c346549ca42f3fab244a" + }, + "@std/streams@1.0.6": { + "integrity": "022ed94e380d06b4d91c49eb70241b7289ab78b8c2b4c4bbb7eb265e4997c25c", + "dependencies": [ + "jsr:@std/bytes" + ] + } + }, + "npm": { + "agent-base@7.1.1": { + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "dependencies": [ + "debug" + ] + }, + "asynckit@0.4.0": { + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "combined-stream@1.0.8": { + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": [ + "delayed-stream" + ] + }, + "cssstyle@4.1.0": { + "integrity": "sha512-h66W1URKpBS5YMI/V8PyXvTMFT8SupJ1IzoIV8IeBC/ji8WVmrO8dGlTi+2dh6whmdk6BiKJLD/ZBkhWbcg6nA==", + "dependencies": [ + "rrweb-cssom" + ] + }, + "data-urls@5.0.0": { + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dependencies": [ + "whatwg-mimetype", + "whatwg-url" + ] + }, + "debug@4.3.7": { + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dependencies": [ + "ms" + ] + }, + "decimal.js@10.4.3": { + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==" + }, + "delayed-stream@1.0.0": { + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + }, + "entities@4.5.0": { + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + }, + "form-data@4.0.0": { + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": [ + "asynckit", + "combined-stream", + "mime-types" + ] + }, + "highlight.js@11.10.0": { + "integrity": "sha512-SYVnVFswQER+zu1laSya563s+F8VDGt7o35d4utbamowvUNLLMovFqwCLSocpZTz3MgaSRA1IbqRWZv97dtErQ==" + }, + "html-encoding-sniffer@4.0.0": { + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dependencies": [ + "whatwg-encoding" + ] + }, + "http-proxy-agent@7.0.2": { + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dependencies": [ + "agent-base", + "debug" + ] + }, + "https-proxy-agent@7.0.5": { + "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", + "dependencies": [ + "agent-base", + "debug" + ] + }, + "iconv-lite@0.6.3": { + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": [ + "safer-buffer" + ] + }, + "is-potential-custom-element-name@1.0.1": { + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" + }, + "jsdom@25.0.1": { + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dependencies": [ + "cssstyle", + "data-urls", + "decimal.js", + "form-data", + "html-encoding-sniffer", + "http-proxy-agent", + "https-proxy-agent", + "is-potential-custom-element-name", + "nwsapi", + "parse5", + "rrweb-cssom", + "saxes", + "symbol-tree", + "tough-cookie", + "w3c-xmlserializer", + "webidl-conversions", + "whatwg-encoding", + "whatwg-mimetype", + "whatwg-url", + "ws", + "xml-name-validator" + ] + }, + "mime-db@1.52.0": { + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types@2.1.35": { + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": [ + "mime-db" + ] + }, + "ms@2.1.3": { + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "nwsapi@2.2.13": { + "integrity": "sha512-cTGB9ptp9dY9A5VbMSe7fQBcl/tt22Vcqdq8+eN93rblOuE0aCFu4aZ2vMwct/2t+lFnosm8RkQW1I0Omb1UtQ==" + }, + "parse5@7.1.2": { + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "dependencies": [ + "entities" + ] + }, + "punycode@2.3.1": { + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" + }, + "rrweb-cssom@0.7.1": { + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==" + }, + "safer-buffer@2.1.2": { + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "saxes@6.0.0": { + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dependencies": [ + "xmlchars" + ] + }, + "symbol-tree@3.2.4": { + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" + }, + "tldts-core@6.1.50": { + "integrity": "sha512-na2EcZqmdA2iV9zHV7OHQDxxdciEpxrjbkp+aHmZgnZKHzoElLajP59np5/4+sare9fQBfixgvXKx8ev1d7ytw==" + }, + "tldts@6.1.50": { + "integrity": "sha512-q9GOap6q3KCsLMdOjXhWU5jVZ8/1dIib898JBRLsN+tBhENpBDcAVQbE0epADOjw11FhQQy9AcbqKGBQPUfTQA==", + "dependencies": [ + "tldts-core" + ] + }, + "tough-cookie@5.0.0": { + "integrity": "sha512-FRKsF7cz96xIIeMZ82ehjC3xW2E+O2+v11udrDYewUbszngYhsGa8z6YUMMzO9QJZzzyd0nGGXnML/TReX6W8Q==", + "dependencies": [ + "tldts" + ] + }, + "tr46@5.0.0": { + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "dependencies": [ + "punycode" + ] + }, + "w3c-xmlserializer@5.0.0": { + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dependencies": [ + "xml-name-validator" + ] + }, + "webidl-conversions@7.0.0": { + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==" + }, + "whatwg-encoding@3.1.1": { + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dependencies": [ + "iconv-lite" + ] + }, + "whatwg-mimetype@4.0.0": { + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==" + }, + "whatwg-url@14.0.0": { + "integrity": "sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==", + "dependencies": [ + "tr46", + "webidl-conversions" + ] + }, + "ws@8.18.0": { + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==" + }, + "xml-name-validator@5.0.0": { + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==" + }, + "xmlchars@2.2.0": { + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" + } + }, + "workspace": { + "dependencies": [ + "jsr:@cross/fs@0.1", + "jsr:@dprint/formatter@0.4", + "jsr:@libs/bundle@12", + "jsr:@libs/logger@3", + "jsr:@libs/reactive@4", + "jsr:@libs/testing@3", + "jsr:@libs/typing@3.1", + "jsr:@std/assert@1", + "jsr:@std/async@1", + "jsr:@std/collections@1", + "jsr:@std/fmt@1", + "jsr:@std/fs@1", + "jsr:@std/html@1", + "jsr:@std/http@1", + "jsr:@std/io@0.224", + "jsr:@std/jsonc@1", + "jsr:@std/path@1", + "jsr:@std/regexp@1", + "jsr:@std/text@1", + "npm:jsdom@25" + ] + } +}