diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index e3be587..c67b815 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -61,6 +61,7 @@ add_library(${PACKAGE_NAME} SHARED ../cpp/quickjs/bindings/UrlBindings.cpp ../cpp/quickjs/bindings/AbortBindings.cpp ../cpp/quickjs/bindings/TextEncodingBindings.cpp + ../cpp/quickjs/bindings/IntlBindings.cpp ../cpp/quickjs/bindings/FormBindings.cpp ../cpp/quickjs/bindings/BlobBindings.cpp ../cpp/quickjs/bindings/CSSOMBindings.cpp diff --git a/cpp/HybridHtmlSandbox.cpp b/cpp/HybridHtmlSandbox.cpp index b915e82..04373f4 100644 --- a/cpp/HybridHtmlSandbox.cpp +++ b/cpp/HybridHtmlSandbox.cpp @@ -43,10 +43,13 @@ void HybridHtmlSandbox::initialize(const std::string& html, bool runScripts, con _runtime->bindDocument(_document.get()); if (runScripts) { - for (const auto& script : _document->getScriptContents()) { + auto* rctx = _runtime->contextState(); + for (const auto& [scriptEl, script] : _document->getScriptContents()) { + rctx->current_script = scriptEl; try { _runtime->evaluate(script); } catch (...) { } + rctx->current_script = nullptr; } } @@ -111,7 +114,7 @@ void HybridHtmlSandbox::setConsoleCallback( void HybridHtmlSandbox::setDialogCallbacks( const std::optional>>& onAlert, - const std::optional>(const std::string&)>>>& onConfirm, + const std::optional>>>(const std::string&)>>>& onConfirm, const std::optional>>(const std::string&, const std::optional&)>>>& onPrompt) { if (!_runtime) return; @@ -129,10 +132,10 @@ void HybridHtmlSandbox::setDialogCallbacks( if (!onConfirm.has_value() || std::holds_alternative(onConfirm.value())) { _runtime->setConfirmCallback(nullptr); } else { - auto fn = std::get>(const std::string&)>>(onConfirm.value()); + auto fn = std::get>>>(const std::string&)>>(onConfirm.value()); _runtime->setConfirmCallback([fn](const std::string& message) -> bool { try { - return fn(message)->await().get(); + return fn(message)->await().get()->await().get(); } catch (...) { return false; } diff --git a/cpp/HybridHtmlSandbox.hpp b/cpp/HybridHtmlSandbox.hpp index f490538..32f207b 100644 --- a/cpp/HybridHtmlSandbox.hpp +++ b/cpp/HybridHtmlSandbox.hpp @@ -30,7 +30,7 @@ class HybridHtmlSandbox : public HybridHtmlSandboxSpec { void setDialogCallbacks( const std::optional>>& onAlert, - const std::optional>(const std::string& /* message */)>>>& onConfirm, + const std::optional>>>(const std::string& /* message */)>>>& onConfirm, const std::optional>>(const std::string& /* message */, const std::optional& /* defaultValue */)>>>& onPrompt ) override; diff --git a/cpp/lexbor/LexborDocument.cpp b/cpp/lexbor/LexborDocument.cpp index b2f7e50..ce8513f 100644 --- a/cpp/lexbor/LexborDocument.cpp +++ b/cpp/lexbor/LexborDocument.cpp @@ -199,9 +199,9 @@ void* LexborDocument::documentElement() const { // ── Script extraction ──────────────────────────────────────────────────────── -std::vector LexborDocument::getScriptContents() const { +std::vector> LexborDocument::getScriptContents() const { auto scriptEls = querySelectorAll_el("script"); - std::vector contents; + std::vector> contents; contents.reserve(scriptEls.size()); for (void* el : scriptEls) { @@ -217,7 +217,7 @@ std::vector LexborDocument::getScriptContents() const { size_t len = 0; lxb_char_t* text = lxb_dom_node_text_content(node, &len); if (text && len > 0) { - contents.emplace_back(reinterpret_cast(text), len); + contents.emplace_back(el, std::string(reinterpret_cast(text), len)); lxb_dom_document_destroy_text(node->owner_document, text); } } diff --git a/cpp/lexbor/LexborDocument.hpp b/cpp/lexbor/LexborDocument.hpp index d2b4ca8..377967c 100644 --- a/cpp/lexbor/LexborDocument.hpp +++ b/cpp/lexbor/LexborDocument.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include namespace margelo::nitro::nitrojsdom { @@ -29,8 +30,8 @@ class LexborDocument { void* head() const; void* documentElement() const; - // ── Script extraction ──────────────────────────────────────────────────── - std::vector getScriptContents() const; + // ── Script extraction — (element, text content) pairs; void* = lxb_dom_element_t* ── + std::vector> getScriptContents() const; // ── Node creation ───────────────────────────────────────────────────────── void* createElement(const std::string& tag); diff --git a/cpp/quickjs/DOMBindings.cpp b/cpp/quickjs/DOMBindings.cpp index f6ae46f..af1984e 100644 --- a/cpp/quickjs/DOMBindings.cpp +++ b/cpp/quickjs/DOMBindings.cpp @@ -16,6 +16,7 @@ #include "bindings/UrlBindings.hpp" #include "bindings/AbortBindings.hpp" #include "bindings/TextEncodingBindings.hpp" +#include "bindings/IntlBindings.hpp" #include "bindings/FormBindings.hpp" #include "bindings/BlobBindings.hpp" #include "bindings/CSSOMBindings.hpp" @@ -58,6 +59,7 @@ void DOMBindings::install(QuickJSRuntime* runtime, LexborDocument* document) { UrlBindings::install(ctx); AbortBindings::install(ctx); TextEncodingBindings::install(ctx); + IntlBindings::install(ctx); BlobBindings::install(ctx); // uses TextEncoder/TextDecoder + btoa, so must run after both FetchBindings::install(ctx); // XHR bootstrap uses `new Event(...)`, so must run after EventBindings FormBindings::install(ctx); // uses globalThis.Element + globalThis.Event, so must run after both diff --git a/cpp/quickjs/QuickJSRuntime.hpp b/cpp/quickjs/QuickJSRuntime.hpp index 0ebc3a7..8fde37f 100644 --- a/cpp/quickjs/QuickJSRuntime.hpp +++ b/cpp/quickjs/QuickJSRuntime.hpp @@ -77,6 +77,7 @@ struct RuntimeContext { double time_origin_ms { 0 }; void* active_element { nullptr }; + void* current_script { nullptr }; std::string ready_state { "loading" }; // node pointer → heap-allocated JSValue* (DupValue'd strong ref) diff --git a/cpp/quickjs/bindings/CSSOMBindings.cpp b/cpp/quickjs/bindings/CSSOMBindings.cpp index e945857..2d0bea5 100644 --- a/cpp/quickjs/bindings/CSSOMBindings.cpp +++ b/cpp/quickjs/bindings/CSSOMBindings.cpp @@ -201,6 +201,56 @@ const char* kCSSOMBootstrapScript = R"JS( globalThis.CSSStyleRule = CSSStyleRule; globalThis.CSSStyleSheet = CSSStyleSheet; + function cssEscape(value) { + var string = String(value); + var length = string.length; + var index = -1; + var result = ''; + var firstCodeUnit = string.charCodeAt(0); + + if (length === 1 && firstCodeUnit === 0x002D) { + return '\\' + string; + } + + while (++index < length) { + var codeUnit = string.charCodeAt(index); + if (codeUnit === 0x0000) { + result += '\uFFFD'; + continue; + } + if ( + (codeUnit >= 0x0001 && codeUnit <= 0x001F) || codeUnit === 0x007F || + (index === 0 && codeUnit >= 0x0030 && codeUnit <= 0x0039) || + (index === 1 && codeUnit >= 0x0030 && codeUnit <= 0x0039 && firstCodeUnit === 0x002D) + ) { + result += '\\' + codeUnit.toString(16) + ' '; + continue; + } + if ( + codeUnit >= 0x0080 || codeUnit === 0x002D || codeUnit === 0x005F || + (codeUnit >= 0x0030 && codeUnit <= 0x0039) || + (codeUnit >= 0x0041 && codeUnit <= 0x005A) || + (codeUnit >= 0x0061 && codeUnit <= 0x007A) + ) { + result += string.charAt(index); + continue; + } + result += '\\' + string.charAt(index); + } + return result; + } + + globalThis.CSS = { + escape: cssEscape, + supports: function(propertyOrCondition, value) { + if (arguments.length >= 2) { + return typeof propertyOrCondition === 'string' && propertyOrCondition.length > 0 && + value !== undefined && String(value).length > 0; + } + return typeof propertyOrCondition === 'string' && propertyOrCondition.trim().length > 0; + }, + }; + Object.defineProperty(Element.prototype, 'sheet', { get: function() { if (this.tagName !== 'STYLE') return null; diff --git a/cpp/quickjs/bindings/CustomElementsBindings.cpp b/cpp/quickjs/bindings/CustomElementsBindings.cpp index fd52e23..5b1f26b 100644 --- a/cpp/quickjs/bindings/CustomElementsBindings.cpp +++ b/cpp/quickjs/bindings/CustomElementsBindings.cpp @@ -27,7 +27,7 @@ const char* kCustomElementsBootstrapScript = R"JS( var n = node; while (n) { if (n === docEl) return true; - n = ('host' in n) ? n.host : n.parentNode; + n = (n instanceof ShadowRoot) ? n.host : n.parentNode; } return false; } diff --git a/cpp/quickjs/bindings/DocumentBindings.cpp b/cpp/quickjs/bindings/DocumentBindings.cpp index 5e54f19..95c5025 100644 --- a/cpp/quickjs/bindings/DocumentBindings.cpp +++ b/cpp/quickjs/bindings/DocumentBindings.cpp @@ -269,6 +269,12 @@ JSValue js_doc_get_activeElement(JSContext* ctx, JSValue) { return make_element(ctx, get_doc(ctx)->body()); } +JSValue js_doc_get_currentScript(JSContext* ctx, JSValue) { + auto* rctx = get_ctx(ctx); + if (rctx && rctx->current_script) return make_element(ctx, rctx->current_script); + return JS_NULL; +} + JSValue js_doc_get_readyState(JSContext* ctx, JSValue) { auto* rctx = get_ctx(ctx); const std::string& state = rctx ? rctx->ready_state : "loading"; @@ -350,6 +356,7 @@ void DocumentBindings::install(JSContext* ctx) { define_prop(ctx, doc, "scripts", js_doc_get_scripts, nullptr); define_prop(ctx, doc, "links", js_doc_get_links, nullptr); define_prop(ctx, doc, "activeElement", js_doc_get_activeElement, nullptr); + define_prop(ctx, doc, "currentScript", js_doc_get_currentScript, nullptr); define_prop(ctx, doc, "readyState", js_doc_get_readyState, nullptr); define_prop(ctx, doc, "compatMode", js_doc_get_compatMode, nullptr); define_prop(ctx, doc, "baseURI", js_doc_get_baseURI, nullptr); @@ -360,6 +367,7 @@ void DocumentBindings::install(JSContext* ctx) { RuntimeContext* rctx = get_ctx(ctx); bool hidden = !(rctx && rctx->pretend_to_be_visual); JS_SetPropertyStr(ctx, doc, "hidden", JS_NewBool(ctx, hidden)); + JS_SetPropertyStr(ctx, doc, "visibilityState", JS_NewString(ctx, hidden ? "hidden" : "visible")); JS_SetPropertyStr(ctx, doc, "nodeType", JS_NewInt32(ctx, 9 /* DOCUMENT_NODE */)); JS_SetPropertyStr(ctx, doc, "nodeName", JS_NewString(ctx, "#document")); JS_SetPropertyStr(ctx, doc, "ownerDocument", JS_NULL); diff --git a/cpp/quickjs/bindings/ElementBindings.cpp b/cpp/quickjs/bindings/ElementBindings.cpp index 879f57b..bdd23e1 100644 --- a/cpp/quickjs/bindings/ElementBindings.cpp +++ b/cpp/quickjs/bindings/ElementBindings.cpp @@ -303,6 +303,107 @@ JSValue js_el_set_checked(JSContext* ctx, JSValue this_val, JSValue val) { return JS_UNDEFINED; } +JSValue bool_attr_get(JSContext* ctx, JSValue this_val, const char* attr, size_t attr_len) { + auto* el = unwrap_element(ctx, this_val); + if (!el) return JS_FALSE; + bool has = lxb_dom_element_has_attribute(el, reinterpret_cast(attr), attr_len); + return JS_NewBool(ctx, has); +} + +JSValue bool_attr_set(JSContext* ctx, JSValue this_val, JSValue val, const char* attr, size_t attr_len) { + auto* el = unwrap_element(ctx, this_val); + if (!el) return JS_UNDEFINED; + bool on = JS_ToBool(ctx, val) > 0; + auto* attr_name = reinterpret_cast(attr); + bool has = lxb_dom_element_has_attribute(el, attr_name, attr_len); + + auto* rctx = get_ctx(ctx); + bool has_obs = rctx && rctx->mutation_observers && !rctx->mutation_observers->empty(); + std::optional old_val; + if (has_obs && has && rctx->mutation_observers->hasAttributeOldValueObserver()) { + size_t len = 0; + const lxb_char_t* v = lxb_dom_element_get_attribute(el, attr_name, attr_len, &len); + if (v) old_val = std::string(reinterpret_cast(v), len); + } + + if (on && !has) { + lxb_dom_element_set_attribute(el, attr_name, attr_len, reinterpret_cast(""), 0); + if (has_obs) { + rctx->mutation_observers->notifyAttribute(ctx, lxb_dom_interface_node(el), attr, std::nullopt); + } + } else if (!on && has) { + lxb_dom_element_remove_attribute(el, attr_name, attr_len); + if (has_obs) { + rctx->mutation_observers->notifyAttribute(ctx, lxb_dom_interface_node(el), attr, old_val); + } + } + + return JS_UNDEFINED; +} + +JSValue js_el_get_disabled(JSContext* ctx, JSValue this_val) { return bool_attr_get(ctx, this_val, "disabled", 8); } +JSValue js_el_set_disabled(JSContext* ctx, JSValue this_val, JSValue val) { return bool_attr_set(ctx, this_val, val, "disabled", 8); } + +JSValue js_el_get_required(JSContext* ctx, JSValue this_val) { return bool_attr_get(ctx, this_val, "required", 8); } +JSValue js_el_set_required(JSContext* ctx, JSValue this_val, JSValue val) { return bool_attr_set(ctx, this_val, val, "required", 8); } + +JSValue js_el_get_readOnly(JSContext* ctx, JSValue this_val) { return bool_attr_get(ctx, this_val, "readonly", 8); } +JSValue js_el_set_readOnly(JSContext* ctx, JSValue this_val, JSValue val) { return bool_attr_set(ctx, this_val, val, "readonly", 8); } + +JSValue js_el_get_multiple(JSContext* ctx, JSValue this_val) { return bool_attr_get(ctx, this_val, "multiple", 8); } +JSValue js_el_set_multiple(JSContext* ctx, JSValue this_val, JSValue val) { return bool_attr_set(ctx, this_val, val, "multiple", 8); } + +JSValue js_el_get_autofocus(JSContext* ctx, JSValue this_val) { return bool_attr_get(ctx, this_val, "autofocus", 9); } +JSValue js_el_set_autofocus(JSContext* ctx, JSValue this_val, JSValue val) { return bool_attr_set(ctx, this_val, val, "autofocus", 9); } + +JSValue js_el_get_selected(JSContext* ctx, JSValue this_val) { return bool_attr_get(ctx, this_val, "selected", 8); } +JSValue js_el_set_selected(JSContext* ctx, JSValue this_val, JSValue val) { return bool_attr_set(ctx, this_val, val, "selected", 8); } + +JSValue js_el_get_hidden(JSContext* ctx, JSValue this_val) { return bool_attr_get(ctx, this_val, "hidden", 6); } +JSValue js_el_set_hidden(JSContext* ctx, JSValue this_val, JSValue val) { return bool_attr_set(ctx, this_val, val, "hidden", 6); } + +JSValue string_attr_get(JSContext* ctx, JSValue this_val, const char* attr, size_t attr_len) { + auto* el = unwrap_element(ctx, this_val); + if (!el) return JS_NewString(ctx, ""); + size_t len = 0; + const lxb_char_t* val = lxb_dom_element_get_attribute(el, reinterpret_cast(attr), attr_len, &len); + return val ? JS_NewStringLen(ctx, reinterpret_cast(val), len) : JS_NewString(ctx, ""); +} + +JSValue string_attr_set(JSContext* ctx, JSValue this_val, JSValue val, const char* attr, size_t attr_len) { + auto* el = unwrap_element(ctx, this_val); + if (!el) return JS_UNDEFINED; + const char* str = JS_ToCString(ctx, val); + if (!str) return JS_UNDEFINED; + auto* attr_name = reinterpret_cast(attr); + + auto* rctx = get_ctx(ctx); + bool has_obs = rctx && rctx->mutation_observers && !rctx->mutation_observers->empty(); + std::optional old_val; + if (has_obs && rctx->mutation_observers->hasAttributeOldValueObserver()) { + size_t len = 0; + const lxb_char_t* v = lxb_dom_element_get_attribute(el, attr_name, attr_len, &len); + if (v) old_val = std::string(reinterpret_cast(v), len); + } + + lxb_dom_element_set_attribute(el, attr_name, attr_len, reinterpret_cast(str), strlen(str)); + JS_FreeCString(ctx, str); + + if (has_obs) { + rctx->mutation_observers->notifyAttribute(ctx, lxb_dom_interface_node(el), attr, old_val); + } + return JS_UNDEFINED; +} + +JSValue js_el_get_title(JSContext* ctx, JSValue this_val) { return string_attr_get(ctx, this_val, "title", 5); } +JSValue js_el_set_title(JSContext* ctx, JSValue this_val, JSValue val) { return string_attr_set(ctx, this_val, val, "title", 5); } + +JSValue js_el_get_lang(JSContext* ctx, JSValue this_val) { return string_attr_get(ctx, this_val, "lang", 4); } +JSValue js_el_set_lang(JSContext* ctx, JSValue this_val, JSValue val) { return string_attr_set(ctx, this_val, val, "lang", 4); } + +JSValue js_el_get_dir(JSContext* ctx, JSValue this_val) { return string_attr_get(ctx, this_val, "dir", 3); } +JSValue js_el_set_dir(JSContext* ctx, JSValue this_val, JSValue val) { return string_attr_set(ctx, this_val, val, "dir", 3); } + JSValue js_el_get_textContent(JSContext* ctx, JSValue this_val) { lxb_dom_node_t* node = unwrap_node(ctx, this_val); if (!node) return JS_NewString(ctx, ""); @@ -1513,6 +1614,16 @@ void ElementBindings::install(JSContext* ctx) { define_prop(ctx, proto, "className", js_el_get_className, js_el_set_className); define_prop(ctx, proto, "value", js_el_get_value, js_el_set_value); define_prop(ctx, proto, "checked", js_el_get_checked, js_el_set_checked); + define_prop(ctx, proto, "disabled", js_el_get_disabled, js_el_set_disabled); + define_prop(ctx, proto, "required", js_el_get_required, js_el_set_required); + define_prop(ctx, proto, "readOnly", js_el_get_readOnly, js_el_set_readOnly); + define_prop(ctx, proto, "multiple", js_el_get_multiple, js_el_set_multiple); + define_prop(ctx, proto, "autofocus", js_el_get_autofocus, js_el_set_autofocus); + define_prop(ctx, proto, "selected", js_el_get_selected, js_el_set_selected); + define_prop(ctx, proto, "hidden", js_el_get_hidden, js_el_set_hidden); + define_prop(ctx, proto, "title", js_el_get_title, js_el_set_title); + define_prop(ctx, proto, "lang", js_el_get_lang, js_el_set_lang); + define_prop(ctx, proto, "dir", js_el_get_dir, js_el_set_dir); define_prop(ctx, proto, "innerHTML", js_el_get_innerHTML, js_el_set_innerHTML); define_prop(ctx, proto, "outerHTML", js_el_get_outerHTML, nullptr); define_prop(ctx, proto, "innerText", js_el_get_textContent, js_el_set_textContent); @@ -1553,7 +1664,7 @@ void ElementBindings::install(JSContext* ctx) { while (true) { var parent = node.parentNode; if (parent) { node = parent; continue; } - if (composed && node.host) { node = node.host; continue; } + if (composed && node instanceof ShadowRoot) { node = node.host; continue; } return __nativeCanonicalizeRootNode(node); } }; diff --git a/cpp/quickjs/bindings/FormBindings.cpp b/cpp/quickjs/bindings/FormBindings.cpp index e6e38aa..7791362 100644 --- a/cpp/quickjs/bindings/FormBindings.cpp +++ b/cpp/quickjs/bindings/FormBindings.cpp @@ -125,6 +125,77 @@ const char* kFormBootstrapScript = R"JS( // Per spec, submit() bypasses the "submit" event and constraint validation. // There is no real navigation in this sandbox, so this is intentionally inert. }; + Element.prototype.reset = function() { + if (!isFormElement(this)) return; + this.dispatchEvent(new Event('reset', { bubbles: true, cancelable: true })); + }; + + // ── select.options / .selectedIndex / .selectedOptions ──────────────────── + function isSelectElement(el) { + return !!el && el.tagName === 'SELECT'; + } + + function selectOptionsArray(select) { + return Array.prototype.slice.call(select.querySelectorAll('option')); + } + + Object.defineProperty(Element.prototype, 'options', { + configurable: true, + get: function() { + if (!isSelectElement(this)) return undefined; + var select = this; + var opts = selectOptionsArray(select); + opts.item = function(index) { return this[index] !== undefined ? this[index] : null; }; + opts.namedItem = function(name) { + for (var i = 0; i < this.length; i++) { + if (this[i].id === name || this[i].getAttribute('name') === name) return this[i]; + } + return null; + }; + opts.add = function(option, before) { + var refNode = null; + if (typeof before === 'number') refNode = this[before] || null; + else if (before) refNode = before; + if (refNode) select.insertBefore(option, refNode); + else select.appendChild(option); + }; + opts.remove = function(index) { + var target = this[index]; + if (target) select.removeChild(target); + }; + return opts; + }, + }); + + Object.defineProperty(Element.prototype, 'selectedIndex', { + configurable: true, + get: function() { + if (!isSelectElement(this)) return -1; + var opts = selectOptionsArray(this); + for (var i = 0; i < opts.length; i++) { + if (opts[i].selected) return i; + } + return (!this.multiple && opts.length > 0) ? 0 : -1; + }, + set: function(index) { + if (!isSelectElement(this)) return; + var opts = selectOptionsArray(this); + for (var i = 0; i < opts.length; i++) { + opts[i].selected = (i === index); + } + }, + }); + + Object.defineProperty(Element.prototype, 'selectedOptions', { + configurable: true, + get: function() { + if (!isSelectElement(this)) return undefined; + var opts = selectOptionsArray(this); + var selected = opts.filter(function(o) { return o.selected; }); + if (selected.length === 0 && !this.multiple && opts.length > 0) return [opts[0]]; + return selected; + }, + }); // ── Constraint Validation API (ValidityState, checkValidity, ...) ───────── // Covers the subset real-world CMS forms actually hit: required, pattern, diff --git a/cpp/quickjs/bindings/IntlBindings.cpp b/cpp/quickjs/bindings/IntlBindings.cpp new file mode 100644 index 0000000..f6ab129 --- /dev/null +++ b/cpp/quickjs/bindings/IntlBindings.cpp @@ -0,0 +1,260 @@ +#include "IntlBindings.hpp" +#include + +namespace margelo::nitro::nitrojsdom { + +namespace { + +const char* kIntlBootstrapScript = R"JS( +(function() { + var LOCALE_DATA = { + en: { + months: { + long: ['January','February','March','April','May','June','July','August','September','October','November','December'], + short: ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'], + narrow: ['J','F','M','A','M','J','J','A','S','O','N','D'], + }, + weekdays: { + long: ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'], + short: ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'], + narrow: ['S','M','T','W','T','F','S'], + }, + dayPeriod: ['AM', 'PM'], + decimal: '.', + group: ',', + dateOrder: 'MDY', + currencySpace: false, + }, + pt: { + months: { + long: ['janeiro','fevereiro','março','abril','maio','junho','julho','agosto','setembro','outubro','novembro','dezembro'], + short: ['jan','fev','mar','abr','mai','jun','jul','ago','set','out','nov','dez'], + narrow: ['J','F','M','A','M','J','J','A','S','O','N','D'], + }, + weekdays: { + long: ['domingo','segunda-feira','terça-feira','quarta-feira','quinta-feira','sexta-feira','sábado'], + short: ['dom','seg','ter','qua','qui','sex','sáb'], + narrow: ['D','S','T','Q','Q','S','S'], + }, + dayPeriod: ['AM', 'PM'], + decimal: ',', + group: '.', + dateOrder: 'DMY', + currencySpace: true, + }, + }; + + var CURRENCY_SYMBOLS = { + USD: '$', EUR: '€', GBP: '£', JPY: '¥', BRL: 'R$', CAD: 'CA$', AUD: 'A$', CHF: 'CHF', CNY: 'CN¥', INR: '₹', MXN: 'MX$', + }; + + var CURRENCY_DECIMALS = { + JPY: 0, KRW: 0, VND: 0, CLP: 0, ISK: 0, UGX: 0, XAF: 0, XOF: 0, XPF: 0, + BHD: 3, IQD: 3, JOD: 3, KWD: 3, OMR: 3, TND: 3, + }; + + function currencyDecimals(code) { + return CURRENCY_DECIMALS[code] !== undefined ? CURRENCY_DECIMALS[code] : 2; + } + + function localeLanguage(locales) { + var tag = Array.isArray(locales) ? locales[0] : locales; + tag = String(tag || 'en'); + return tag.split(/[-_]/)[0].toLowerCase(); + } + + function resolveLocale(locales) { + var lang = localeLanguage(locales); + return LOCALE_DATA[lang] || LOCALE_DATA.en; + } + + function groupInteger(digits, groupSep) { + var out = ''; + var count = 0; + for (var i = digits.length - 1; i >= 0; i--) { + out = digits.charAt(i) + out; + count++; + if (count % 3 === 0 && i !== 0) out = groupSep + out; + } + return out; + } + + function formatFixed(value, minFrac, maxFrac, useGrouping, data) { + var negative = value < 0; + var abs = Math.abs(value); + var fixed = abs.toFixed(maxFrac); + var parts = fixed.split('.'); + var intPart = parts[0]; + var fracPart = parts[1] || ''; + + while (fracPart.length > minFrac && fracPart.charAt(fracPart.length - 1) === '0') { + fracPart = fracPart.slice(0, -1); + } + + var out = useGrouping ? groupInteger(intPart, data.group) : intPart; + if (fracPart.length > 0) out += data.decimal + fracPart; + return (negative ? '-' : '') + out; + } + + function NumberFormat(locales, options) { + this._data = resolveLocale(locales); + options = options || {}; + this._style = options.style || 'decimal'; + if (this._style === 'currency') { + if (!options.currency) throw new TypeError('Currency code is required with currency style.'); + this._currency = options.currency; + } + var currDecimals = this._style === 'currency' ? currencyDecimals(this._currency) : undefined; + var defaultMinFrac = this._style === 'currency' ? currDecimals : 0; + var defaultMaxFrac = this._style === 'currency' ? currDecimals : (this._style === 'percent' ? 0 : 3); + this._minFrac = options.minimumFractionDigits !== undefined ? options.minimumFractionDigits : defaultMinFrac; + this._maxFrac = options.maximumFractionDigits !== undefined ? options.maximumFractionDigits : Math.max(defaultMaxFrac, this._minFrac); + this._useGrouping = options.useGrouping !== undefined ? !!options.useGrouping : true; + } + + NumberFormat.prototype.format = function(value) { + value = Number(value); + if (this._style === 'percent') value = value * 100; + var formatted = formatFixed(value, this._minFrac, this._maxFrac, this._useGrouping, this._data); + if (this._style === 'percent') return formatted + '%'; + if (this._style === 'currency') { + var symbol = CURRENCY_SYMBOLS[this._currency] || this._currency; + return symbol + (this._data.currencySpace ? ' ' : '') + formatted; + } + return formatted; + }; + + NumberFormat.prototype.resolvedOptions = function() { + return { + style: this._style, + currency: this._style === 'currency' ? this._currency : undefined, + minimumFractionDigits: this._minFrac, + maximumFractionDigits: this._maxFrac, + useGrouping: this._useGrouping, + }; + }; + + function pad2(n) { return (n < 10 ? '0' : '') + n; } + + var DATE_STYLE_OPTIONS = { + short: { year: '2-digit', month: 'numeric', day: 'numeric' }, + medium: { year: 'numeric', month: 'short', day: 'numeric' }, + long: { year: 'numeric', month: 'long', day: 'numeric' }, + full: { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }, + }; + var TIME_STYLE_OPTIONS = { + short: { hour: 'numeric', minute: 'numeric' }, + medium: { hour: 'numeric', minute: 'numeric', second: 'numeric' }, + long: { hour: 'numeric', minute: 'numeric', second: 'numeric' }, + full: { hour: 'numeric', minute: 'numeric', second: 'numeric' }, + }; + + function DateTimeFormat(locales, options) { + this._data = resolveLocale(locales); + options = options || {}; + + if (options.dateStyle || options.timeStyle) { + var withDate = options.dateStyle ? (DATE_STYLE_OPTIONS[options.dateStyle] || DATE_STYLE_OPTIONS.medium) : {}; + var withTime = options.timeStyle ? (TIME_STYLE_OPTIONS[options.timeStyle] || TIME_STYLE_OPTIONS.short) : {}; + options = Object.assign({}, withDate, withTime, options); + } else if (Object.keys(options).length === 0) { + options = { year: 'numeric', month: 'numeric', day: 'numeric' }; + } + + this._options = options; + } + + function datePartFor(date, o, data) { + var textual = o.month === 'long' || o.month === 'short' || o.month === 'narrow'; + var day = o.day ? (o.day === '2-digit' ? pad2(date.getDate()) : String(date.getDate())) : ''; + var year = o.year ? (o.year === '2-digit' ? pad2(date.getFullYear() % 100) : String(date.getFullYear())) : ''; + var month = o.month + ? (textual ? data.months[o.month][date.getMonth()] : (o.month === '2-digit' ? pad2(date.getMonth() + 1) : String(date.getMonth() + 1))) + : ''; + + if (!month && !day && !year) return ''; + + if (textual) { + if (data.dateOrder === 'MDY') { + var head = day ? month + ' ' + day : month; + return year ? head + ', ' + year : head; + } + return [day, month, year].filter(Boolean).join(' '); + } + + var ordered = data.dateOrder === 'MDY' ? [month, day, year] : [day, month, year]; + return ordered.filter(Boolean).join('/'); + } + + DateTimeFormat.prototype.format = function(date) { + date = date === undefined ? new Date() : (date instanceof Date ? date : new Date(date)); + var o = this._options; + var data = this._data; + var pieces = []; + + var datePart = datePartFor(date, o, data); + if (o.weekday) { + var w = date.getDay(); + var weekdayName = data.weekdays[o.weekday === 'narrow' ? 'narrow' : (o.weekday === 'long' ? 'long' : 'short')][w]; + pieces.push(datePart ? weekdayName + ', ' + datePart : weekdayName); + } else if (datePart) { + pieces.push(datePart); + } + + if (o.hour) { + var h = date.getHours(); + var hour12 = o.hour12 !== false; + var hourVal = hour12 ? (h % 12 === 0 ? 12 : h % 12) : h; + var timeSegs = [o.hour === '2-digit' ? pad2(hourVal) : String(hourVal)]; + if (o.minute) timeSegs.push(pad2(date.getMinutes())); + if (o.second) timeSegs.push(pad2(date.getSeconds())); + var timeStr = timeSegs.join(':'); + if (hour12) timeStr += ' ' + (h < 12 ? data.dayPeriod[0] : data.dayPeriod[1]); + pieces.push(timeStr); + } else if (o.minute) { + var timeSegs2 = [pad2(date.getMinutes())]; + if (o.second) timeSegs2.push(pad2(date.getSeconds())); + pieces.push(timeSegs2.join(':')); + } + + return pieces.join(', '); + }; + + DateTimeFormat.prototype.resolvedOptions = function() { + return Object.assign({}, this._options); + }; + + globalThis.Intl = globalThis.Intl || {}; + globalThis.Intl.NumberFormat = NumberFormat; + globalThis.Intl.DateTimeFormat = DateTimeFormat; + + function hasOwnKeys(o) { return !!o && Object.keys(o).length > 0; } + + Number.prototype.toLocaleString = function(locales, options) { + return new NumberFormat(locales, options).format(this); + }; + + Date.prototype.toLocaleString = function(locales, options) { + return new DateTimeFormat(locales, hasOwnKeys(options) ? options : { year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' }).format(this); + }; + Date.prototype.toLocaleDateString = function(locales, options) { + return new DateTimeFormat(locales, hasOwnKeys(options) ? options : { year: 'numeric', month: 'numeric', day: 'numeric' }).format(this); + }; + Date.prototype.toLocaleTimeString = function(locales, options) { + return new DateTimeFormat(locales, hasOwnKeys(options) ? options : { hour: 'numeric', minute: 'numeric', second: 'numeric' }).format(this); + }; +})(); +)JS"; + +} // namespace + +void IntlBindings::install(JSContext* ctx) { + JSValue result = JS_Eval(ctx, kIntlBootstrapScript, strlen(kIntlBootstrapScript), + "", JS_EVAL_TYPE_GLOBAL); + if (JS_IsException(result)) { + JS_FreeValue(ctx, JS_GetException(ctx)); + } + JS_FreeValue(ctx, result); +} + +} // namespace margelo::nitro::nitrojsdom diff --git a/cpp/quickjs/bindings/IntlBindings.hpp b/cpp/quickjs/bindings/IntlBindings.hpp new file mode 100644 index 0000000..52a6548 --- /dev/null +++ b/cpp/quickjs/bindings/IntlBindings.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include "quickjs.h" + +namespace margelo::nitro::nitrojsdom { + +// Registers a pure-JS globalThis.Intl.NumberFormat/DateTimeFormat (no ICU — +// QuickJS ships none). Locale data is a small hand-built table (en + pt, the +// two this project's users actually need), not real CLDR data. Also rewires +// Number.prototype.toLocaleString and Date.prototype.toLocaleString/ +// toLocaleDateString/toLocaleTimeString to go through these instead of +// QuickJS's own locale-blind built-ins. +struct IntlBindings { + static void install(JSContext* ctx); +}; + +} // namespace margelo::nitro::nitrojsdom diff --git a/cpp/quickjs/bindings/UrlBindings.cpp b/cpp/quickjs/bindings/UrlBindings.cpp index 7ead6e3..558dd7c 100644 --- a/cpp/quickjs/bindings/UrlBindings.cpp +++ b/cpp/quickjs/bindings/UrlBindings.cpp @@ -278,6 +278,40 @@ const char* kUrlBootstrapScript = R"JS( }; globalThis.URL = URL; + + function isAnchorElement(el) { + return !!el && (el.tagName === 'A' || el.tagName === 'AREA'); + } + + function anchorUrl(el) { + var raw = el.getAttribute('href'); + if (raw === null) return null; + try { return new URL(raw, document.baseURI); } catch (e) { return null; } + } + + Object.defineProperty(Element.prototype, 'href', { + configurable: true, + get: function() { + if (!isAnchorElement(this)) return undefined; + var u = anchorUrl(this); + return u ? u.href : ''; + }, + set: function(value) { + if (!isAnchorElement(this)) return; + this.setAttribute('href', String(value)); + }, + }); + + ['protocol', 'username', 'password', 'hostname', 'port', 'pathname', 'search', 'hash', 'host', 'origin'].forEach(function(part) { + Object.defineProperty(Element.prototype, part, { + configurable: true, + get: function() { + if (!isAnchorElement(this)) return undefined; + var u = anchorUrl(this); + return u ? u[part] : ''; + }, + }); + }); })(); )JS"; diff --git a/docs/overview.md b/docs/overview.md index b084a24..4302f27 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -497,6 +497,92 @@ dom.dispose() // ← always pair with create() - [x] `Element.prototype.getClientRects()`/`webkitMatchesSelector()`. - [x] `window.reportError()`. +### v0.17 — Boolean Attribute Reflection & Select Ergonomics +> A gap list compiled by diffing this project against jsdom's own supported +> interface set (`lib/jsdom/living/interfaces.js`), scoped down to what a +> real-world CMS embedded script (countdown timer, personalized greeting, +> discount badge) actually reaches for — dropping everything layout/rendering +> or full-page-navigation related, which stays out of scope for the reasons +> given throughout this roadmap. +- [x] `element.disabled` / `.required` / `.readOnly` / `.multiple` / + `.autofocus` / `.selected` as direct boolean properties (same + attribute-presence-as-truthiness convention `.checked` already used) — + previously only reachable via `getAttribute`/`setAttribute`, which is + not how real-world scripts disable a button or mark a field required. +- [x] `form.reset()` — dispatches the cancelable `reset` event (what widget + scripts actually listen for to run their own cleanup). Does not revert + field values: this sandbox has no separate default-value storage + (`element.value`/`.checked` read/write the live attribute directly), so + a script that already reassigned `.value` has overwritten its own + default with nothing left to revert to — the same simplification + `submit()` already made for the "submit" event. +- [x] `select.options` (`HTMLOptionsCollection`-like: `item()`/`namedItem()`/ + `add()`/`remove()`) / `.selectedIndex` / `.selectedOptions` — `select.value` + already worked; this rounds out the rest of the dropdown-widget surface. + Static array, not a live collection, same trade-off as `form.elements`/ + `element.labels`. +- [x] `CSS.escape()` — ports the CSSOM spec's own reference algorithm, so it's + exact rather than a best-effort stub; used by scripts building selectors + from dynamic IDs (`'#' + CSS.escape(dynamicId)`). `CSS.supports()` has + no real CSS engine to validate against, so it reports "supported" for + any syntactically-plausible property/value pair instead of parsing CSS. +- [x] `document.visibilityState` (`'visible'` / `'hidden'`) — the companion to + `document.hidden`, which already existed; scripts commonly check both. + +### v0.18 — Link Ergonomics, currentScript & Intl +> A second pass on the same jsdom-interfaces gap list that produced v0.17, +> plus the one gap that isn't a missing DOM binding at all: QuickJS ships no +> `Intl` implementation, which blocks this project's own two headline +> examples (`docs/overview.md`'s "personalized greeting" needs date +> formatting, "discount badge" needs currency formatting). +- [x] `element.hidden` / `.title` / `.lang` / `.dir` as direct properties, + same attribute-reflection convention as v0.17's `.disabled` etc. +- [x] ``/`` `.href` (resolved absolute URL, settable) and read-only + `.protocol`/`.username`/`.password`/`.hostname`/`.port`/`.pathname`/ + `.search`/`.hash`/`.host`/`.origin`, resolved against `document.baseURI` + by delegating to the existing `URL` class (`UrlBindings.cpp`) rather + than re-implementing URL parsing. Other elements' `.href` and these + parts are `undefined`, matching real jsdom's behavior for non-hyperlink + elements. The component parts are read-only; only `.href` itself is + settable (writes the raw attribute) — real `HTMLHyperlinkElementUtils` + allows setting each part individually too, which this sandbox doesn't + attempt. +- [x] `document.currentScript` — the classic embedded-widget pattern (a + ` + +
+ +
+ + `); + const result = await dom.evaluate(` + JSON.stringify({ + widget1: window.__widget1Container, + widget2: window.__widget2Container, + afterExecution: document.currentScript, + }); + `); + expect(JSON.parse(result)).toEqual({ + widget1: 'widget-1', + widget2: 'widget-2', + afterExecution: null, + }); + }); }); diff --git a/example/src/__harness__/JSDOM.url.harness.ts b/example/src/__harness__/JSDOM.url.harness.ts index 73f6c74..75efe42 100644 --- a/example/src/__harness__/JSDOM.url.harness.ts +++ b/example/src/__harness__/JSDOM.url.harness.ts @@ -83,4 +83,56 @@ describe('JSDOM URL/URLSearchParams', () => { `); expect(JSON.parse(result)).toEqual({ absolute: true, relativeWithBase: true, invalid: false }); }); + + it('
/ href resolves relative URLs and decomposes into protocol/hostname/pathname/etc, other elements get undefined', async () => { + dom = JSDOM.create( + ` + 10% off + + + `, + { url: 'https://shop.example.com/dir/page.html' } + ); + const result = await dom.evaluate(` + const a = document.getElementById('rel'); + const area = document.getElementById('area'); + const div = document.getElementById('notLink'); + JSON.stringify({ + href: a.href, + protocol: a.protocol, + hostname: a.hostname, + pathname: a.pathname, + search: a.search, + hash: a.hash, + origin: a.origin, + areaHref: area.href, + divHref: div.href, + divProtocol: div.protocol, + }); + `); + expect(JSON.parse(result)).toEqual({ + href: 'https://shop.example.com/discount?code=SAVE10#top', + protocol: 'https:', + hostname: 'shop.example.com', + pathname: '/discount', + search: '?code=SAVE10', + hash: '#top', + origin: 'https://shop.example.com', + areaHref: 'https://shop.example.com/dir/page2.html', + divHref: undefined, + divProtocol: undefined, + }); + }); + + it('setting a.href writes the raw href attribute', async () => { + dom = JSDOM.create('old', { + url: 'https://example.com/', + }); + const result = await dom.evaluate(` + const a = document.getElementById('a'); + a.href = '/new'; + JSON.stringify({ attr: a.getAttribute('href'), resolved: a.href }); + `); + expect(JSON.parse(result)).toEqual({ attr: '/new', resolved: 'https://example.com/new' }); + }); }); diff --git a/nitrogen/generated/shared/c++/HybridHtmlSandboxSpec.hpp b/nitrogen/generated/shared/c++/HybridHtmlSandboxSpec.hpp index fb7373b..258fc4f 100644 --- a/nitrogen/generated/shared/c++/HybridHtmlSandboxSpec.hpp +++ b/nitrogen/generated/shared/c++/HybridHtmlSandboxSpec.hpp @@ -58,7 +58,7 @@ namespace margelo::nitro::nitrojsdom { virtual std::shared_ptr> evaluate(const std::string& script) = 0; virtual std::string serialize() = 0; virtual void setConsoleCallback(const std::optional& /* args */)>>>& callback) = 0; - virtual void setDialogCallbacks(const std::optional>>& onAlert, const std::optional>(const std::string& /* message */)>>>& onConfirm, const std::optional>>(const std::string& /* message */, const std::optional& /* defaultValue */)>>>& onPrompt) = 0; + virtual void setDialogCallbacks(const std::optional>>& onAlert, const std::optional>>>(const std::string& /* message */)>>>& onConfirm, const std::optional>>(const std::string& /* message */, const std::optional& /* defaultValue */)>>>& onPrompt) = 0; virtual void setFetchCallback(const std::optional>>>(const std::string& /* url */, const std::string& /* method */, const std::string& /* headersJson */, const std::optional& /* body */)>>>& callback) = 0; protected: diff --git a/src/classes/JSDOM/JSDOM.class.ts b/src/classes/JSDOM/JSDOM.class.ts index 86e960b..5a81f56 100644 --- a/src/classes/JSDOM/JSDOM.class.ts +++ b/src/classes/JSDOM/JSDOM.class.ts @@ -55,9 +55,10 @@ export class JSDOM { } if (options?.onAlert || options?.onConfirm || options?.onPrompt) { + const onConfirm = options.onConfirm; sandbox.setDialogCallbacks( options.onAlert ?? null, - options.onConfirm ?? null, + onConfirm ? async (message) => await onConfirm(message) : null, options.onPrompt ?? null, ); } diff --git a/src/classes/JSDOM/types/IJSDOMOptions.ts b/src/classes/JSDOM/types/IJSDOMOptions.ts index 2dbfefc..20371d3 100644 --- a/src/classes/JSDOM/types/IJSDOMOptions.ts +++ b/src/classes/JSDOM/types/IJSDOMOptions.ts @@ -22,12 +22,13 @@ export interface IJSDOMOptions { onAlert?: (message: string) => void; /** * Callback invoked when `window.confirm(message)` is called inside the sandbox. - * Must return a boolean. If not provided, confirm() returns false (browser default). + * Return a boolean, or a `Promise`. If not provided, confirm() returns + * false (browser default). * - * WARNING: The callback is synchronous. Do NOT call back into the same sandbox - * from inside the callback — QuickJS is not re-entrant. + * WARNING: Do NOT call back into the same sandbox from inside the callback — + * QuickJS is not re-entrant. */ - onConfirm?: (message: string) => boolean; + onConfirm?: (message: string) => boolean | Promise; /** * Callback invoked when `window.prompt(message, defaultValue?)` is called inside * the sandbox. Return a string for the user input, or null for a dismissed prompt. diff --git a/src/specs/HtmlSandbox.nitro.ts b/src/specs/HtmlSandbox.nitro.ts index a18f98a..fe0dccf 100644 --- a/src/specs/HtmlSandbox.nitro.ts +++ b/src/specs/HtmlSandbox.nitro.ts @@ -17,7 +17,7 @@ export interface HtmlSandbox extends HybridObject<{ ios: 'c++'; android: 'c++' } // Pass null for any callback to use the browser default (no-op / false / null). setDialogCallbacks( onAlert: ((message: string) => void) | null, - onConfirm: ((message: string) => boolean) | null, + onConfirm: ((message: string) => Promise) | null, onPrompt: ((message: string, defaultValue?: string) => string | null) | null, ): void;