Skip to content

Commit 17830fe

Browse files
committed
feat: javascript helpers extension
fix #881
1 parent fbf7b02 commit 17830fe

File tree

4 files changed

+193
-5
lines changed

4 files changed

+193
-5
lines changed

include/mrdocs/Support/JavaScript.hpp

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,30 @@ class Handlebars;
3030
These functions abstract over the embedded JavaScript engine so that
3131
scripts and helpers can be bound, invoked, and marshalled without leaking
3232
engine-specific types into the rest of the codebase.
33+
34+
## Implementation Notes (for Python/Lua integrations)
35+
36+
The current implementation uses JerryScript with the following design choices:
37+
38+
### Integer Limitations
39+
JerryScript only guarantees 32-bit integer precision. Values outside the
40+
int32 range (approximately ±2 billion) are converted to strings when passed
41+
to JavaScript to avoid wraparound bugs. When reading values back, integers
42+
that fit in int64 are returned as integers; others remain as doubles.
43+
44+
### DOM ↔ JS Conversion Strategy
45+
- **Objects**: Use lazy Proxy wrappers to avoid infinite recursion from
46+
circular references (e.g., Handlebars symbol contexts that reference
47+
parent symbols). Properties are converted on-demand when accessed.
48+
- **Arrays**: Converted eagerly (snapshot semantics) because they rarely
49+
contain circular references. This means JS mutations don't affect the
50+
original C++ array and vice versa.
51+
- **Functions**: Wrapped bidirectionally so JS can call C++ functions and
52+
C++ can invoke JS functions through dom::Function.
53+
54+
### Thread Safety
55+
The Context mutex serializes all engine operations. Values can be safely
56+
copied across threads; the underlying handle operations are protected.
3357
*/
3458
namespace js {
3559

src/lib/Gen/hbs/Builder.cpp

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
#include <mrdocs/Metadata/DomCorpus.hpp>
1616
#include <mrdocs/Support/Path.hpp>
1717
#include <mrdocs/Support/Report.hpp>
18+
#include <algorithm>
1819
#include <filesystem>
1920
#include <format>
2021
#include <ranges>
@@ -272,6 +273,11 @@ registerUserHelpers(
272273
// Utility files (starting with '_') define shared globals and are
273274
// loaded before helper files. This allows helpers to share code
274275
// without duplicating implementations.
276+
//
277+
// Utility files are loaded in alphabetical order to ensure predictable
278+
// behavior when utilities depend on each other (e.g., _a.js runs before
279+
// _b.js). If you need complex dependencies, consider consolidating into
280+
// a single utility file.
275281
std::vector<std::string> utilityFiles;
276282
std::vector<std::pair<std::string, std::string>> helperFiles; // (name, path)
277283

@@ -305,6 +311,9 @@ registerUserHelpers(
305311
return Unexpected(exp.error());
306312
}
307313

314+
// Sort utility files alphabetically for predictable load order
315+
std::sort(utilityFiles.begin(), utilityFiles.end());
316+
308317
// Load utilities first (they define globals available to helpers).
309318
// Each utility is loaded in its own scope; globals persist across scopes.
310319
for (auto const& utilPath : utilityFiles)

src/lib/Support/JavaScript.cpp

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,17 @@ invokeHelper(Value const& fn, dom::Array const& args)
5757
{
5858
if (args.empty())
5959
{
60-
return Unexpected(Error("helper options missing"));
60+
return Unexpected(Error(
61+
"Handlebars helper called without arguments; "
62+
"expected options object as last argument"));
6163
}
6264

6365
dom::Value const& options = args.back();
6466
if (!options.isObject())
6567
{
66-
return Unexpected(Error("helper options must be an object"));
68+
return Unexpected(Error(
69+
"Handlebars helper options must be an object; "
70+
"ensure the helper is called from a template context"));
6771
}
6872

6973
// Build arguments without the options object.
@@ -117,8 +121,13 @@ toString(jerry_value_t v)
117121

118122
// Normalize a JerryScript exception into a MrDocs Error type.
119123
// Order: unwrap exception → if object use .message → else if string use it →
120-
// otherwise stringify the original exception. Prefix "Unexpected: " for
121-
// runtime errors to distinguish them from syntax-like failures.
124+
// otherwise stringify the original exception.
125+
//
126+
// Error message format:
127+
// - Syntax errors from JerryScript typically contain "Unexpected" or "SyntaxError"
128+
// - Runtime errors (thrown exceptions) are prefixed with "Unexpected: " if they
129+
// don't already contain that marker, helping distinguish them from parse errors
130+
// - This prefix is intentionally consistent to aid debugging and testing
122131
static Error
123132
makeError(jerry_value_t exc)
124133
{
@@ -147,6 +156,9 @@ makeError(jerry_value_t exc)
147156
msg = toString(exc);
148157
}
149158

159+
// Prefix runtime exceptions for consistent error messaging. Skip if the
160+
// message already indicates a syntax/parse error (contains "Unexpected")
161+
// or if this isn't actually an exception value.
150162
if (jerry_value_is_exception(exc)
151163
&& msg.find("Unexpected") == std::string::npos)
152164
{
@@ -380,6 +392,17 @@ Scope::compile_function(std::string_view script)
380392
// parenthesize to force expression parsing; if that fails, execute the
381393
// script and return the first declared function to match older behavior
382394
// used by templates.
395+
//
396+
// IMPORTANT: Side effects warning
397+
// If the parenthesized expression attempt fails (e.g., for scripts with
398+
// statements before the function), the fallback path executes the script
399+
// to define the function. This means scripts with side effects may run
400+
// those side effects even when compile_function ultimately fails. For
401+
// pure function definitions this is not an issue, but scripts like:
402+
// "counter++; function foo() {}"
403+
// will increment counter during compile_function even though the
404+
// intent is only to extract the function.
405+
//
383406
// Parenthesize the provided source so it is treated as a function expression
384407
std::string wrapped = "(";
385408
wrapped.append(script);
@@ -391,7 +414,7 @@ Scope::compile_function(std::string_view script)
391414
}
392415

393416
// Fall back: execute declarations and return the first declared function
394-
// name
417+
// name. Note: this path runs the script, so any side effects will occur.
395418
auto findFirstFunctionName =
396419
[](std::string_view sv) -> std::optional<std::string> {
397420
std::size_t pos = 0;
@@ -533,6 +556,12 @@ Value::Value(Value const& other) : impl_(other.impl_), val_(0)
533556
{
534557
// Copy by bumping JerryScript handle refcount; paired with jerry_value_free
535558
// in the destructor for shared lifetime management across Value copies.
559+
//
560+
// Thread safety note: The shared_ptr copy (impl_) is done outside the lock
561+
// because std::shared_ptr is thread-safe for concurrent copies. The
562+
// jerry_value_copy call requires the lock since JerryScript is single-threaded.
563+
// This allows Values to be safely copied across threads while ensuring all
564+
// engine operations are serialized.
536565
auto lock = lockContext(other.impl_);
537566
if (other.val_)
538567
{
@@ -603,6 +632,8 @@ isSafeNumberForJerry(double d)
603632
{
604633
// JerryScript only guarantees 32-bit ints; reject wider values early to
605634
// avoid wraparound in the engine and round-trip surprises.
635+
// Note: std::isfinite returns false for NaN and ±Infinity, so those are
636+
// correctly rejected here without needing a separate std::isnan check.
606637
if (!std::isfinite(d))
607638
{
608639
return false;

src/test/Support/JavaScript.cpp

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1813,6 +1813,127 @@ struct JavaScript_test
18131813
}
18141814
}
18151815

1816+
void
1817+
test_operator_bracket_access()
1818+
{
1819+
// Test operator[] for objects and arrays as a convenience alternative
1820+
// to the get() method.
1821+
Context ctx;
1822+
Scope scope(ctx);
1823+
1824+
// Object subscript access
1825+
{
1826+
Value obj = scope.eval("({ key: 'value', nested: { inner: 42 } })").value();
1827+
BOOST_TEST(obj.isObject());
1828+
1829+
// String key access
1830+
BOOST_TEST(obj["key"].isString());
1831+
BOOST_TEST(obj["key"].getString() == "value");
1832+
1833+
// Missing key returns undefined
1834+
BOOST_TEST(obj["missing"].isUndefined());
1835+
1836+
// Nested access via chained subscripts
1837+
BOOST_TEST(obj["nested"]["inner"].isNumber());
1838+
BOOST_TEST(obj["nested"]["inner"].getInteger() == 42);
1839+
}
1840+
1841+
// Array subscript access
1842+
{
1843+
Value arr = scope.eval("([10, 20, 30])").value();
1844+
BOOST_TEST(arr.isArray());
1845+
1846+
// Index access
1847+
BOOST_TEST(arr[0].isNumber());
1848+
BOOST_TEST(arr[0].getInteger() == 10);
1849+
BOOST_TEST(arr[1].getInteger() == 20);
1850+
BOOST_TEST(arr[2].getInteger() == 30);
1851+
1852+
// Out of bounds returns undefined
1853+
BOOST_TEST(arr[99].isUndefined());
1854+
}
1855+
}
1856+
1857+
void
1858+
test_getstring_owning_string()
1859+
{
1860+
// getString() returns std::string (owning) rather than string_view
1861+
// because JerryScript allocates new buffers for string extraction.
1862+
// This test documents this API behavior for users migrating from
1863+
// other JS engines that might return views.
1864+
Context ctx;
1865+
Scope scope(ctx);
1866+
1867+
Value str = scope.eval("'Hello, World!'").value();
1868+
BOOST_TEST(str.isString());
1869+
1870+
// getString returns std::string - verify it's a proper copy
1871+
std::string result = str.getString();
1872+
BOOST_TEST(result == "Hello, World!");
1873+
1874+
// The returned string should remain valid even after scope operations
1875+
// (unlike a string_view which might be invalidated)
1876+
scope.eval("'something else'");
1877+
BOOST_TEST(result == "Hello, World!"); // Still valid
1878+
1879+
// Works with non-ASCII UTF-8 content
1880+
Value utf8 = scope.eval("'日本語'").value();
1881+
std::string utf8Result = utf8.getString();
1882+
BOOST_TEST(utf8Result == "日本語");
1883+
}
1884+
1885+
void
1886+
test_utility_file_globals()
1887+
{
1888+
// Test that globals defined in one scope persist to subsequent scopes,
1889+
// which is the mechanism utility files use to provide shared functions.
1890+
Context ctx;
1891+
1892+
// First scope: define a utility function (simulates loading _utils.js)
1893+
{
1894+
Scope scope(ctx);
1895+
auto exp = scope.script(
1896+
"function sharedUtil(x) { return x * 2; }\n"
1897+
"var SHARED_CONSTANT = 42;");
1898+
BOOST_TEST(exp);
1899+
}
1900+
1901+
// Second scope: verify globals persist and can be used
1902+
{
1903+
Scope scope(ctx);
1904+
1905+
// Function should be available
1906+
auto result = scope.eval("sharedUtil(21)");
1907+
BOOST_TEST(result);
1908+
if (result)
1909+
{
1910+
BOOST_TEST(result->isNumber());
1911+
BOOST_TEST(result->getInteger() == 42);
1912+
}
1913+
1914+
// Constant should be available
1915+
auto constVal = scope.getGlobal("SHARED_CONSTANT");
1916+
BOOST_TEST(constVal);
1917+
if (constVal)
1918+
{
1919+
BOOST_TEST(constVal->isNumber());
1920+
BOOST_TEST(constVal->getInteger() == 42);
1921+
}
1922+
}
1923+
1924+
// Third scope: test that a helper can use the utility function
1925+
Handlebars hbs;
1926+
auto ok = js::registerHelper(
1927+
hbs,
1928+
"doubler",
1929+
ctx,
1930+
"(function(x) { return sharedUtil(x); })");
1931+
BOOST_TEST(ok);
1932+
// Registration succeeded, meaning the helper script was able to
1933+
// reference sharedUtil from the global scope. The helper is now
1934+
// registered and usable in templates.
1935+
}
1936+
18161937
void run()
18171938
{
18181939
test_context();
@@ -1834,6 +1955,9 @@ struct JavaScript_test
18341955
test_utility_globals_persist();
18351956
test_circular_references();
18361957
test_deep_nesting();
1958+
test_operator_bracket_access();
1959+
test_getstring_owning_string();
1960+
test_utility_file_globals();
18371961
}
18381962
};
18391963

0 commit comments

Comments
 (0)