From 3e0c763838a097319ac930fc66d68235925d7e30 Mon Sep 17 00:00:00 2001 From: Gennaro Prota Date: Wed, 6 May 2026 17:33:30 +0200 Subject: [PATCH 1/5] feat(handlebars): support Lua scripts as Handlebars helpers This mirrors the existing JS helpers for Lua. *.lua files placed in an addon's generator/{common|}/helpers/ directory are auto-registered as Handlebars helpers; files whose name starts with '_' run first as utility scripts. Two golden fixtures (lua-helper/, lua-helper-layering/) mirror their JS counterparts and cover the `addons-supplemental` override. Incidental fixes to issues uncovered by this patch: - Added a qualification to `MRDOCS_TRY` / `MRDOCS_CHECK_*` / `MRDOCS_CHECK_OR_*` / `MRDOCS_CHECK_OR_CONTINUE` to make them work with nested namespaces named `detail`. - Dropped onelua.c and ltests.c from the Lua build patch, because the former defines `main`, which conflicted with our `main`, and the latter is test scaffolding which shouldn't ship in a library build. - Added `extern "C"` around the Lua includes. --- include/mrdocs/Support/Expected.hpp | 29 +- include/mrdocs/Support/Lua.hpp | 40 +++ src/lib/Gen/hbs/Builder.cpp | 144 +++++++--- src/lib/Gen/hbs/Builder.hpp | 2 + src/lib/Support/Lua.cpp | 259 +++++++++++++++++- .../generator/adoc/layouts/index.adoc.hbs | 1 + .../generator/adoc/layouts/wrapper.adoc.hbs | 4 + .../base/generator/common/helpers/greet.lua | 4 + .../base/generator/common/helpers/keep.lua | 4 + .../generator/html/layouts/index.html.hbs | 1 + .../generator/html/layouts/wrapper.html.hbs | 7 + .../generator/common/helpers/greet.lua | 4 + .../hbs/lua-helper-layering/layering.adoc | 4 + .../hbs/lua-helper-layering/layering.cpp | 4 + .../hbs/lua-helper-layering/layering.html | 7 + .../hbs/lua-helper-layering/layering.xml | 36 +++ .../hbs/lua-helper-layering/mrdocs.yml | 8 + .../lua/generator/adoc/helpers/format_id.lua | 6 + .../lua/generator/adoc/layouts/index.adoc.hbs | 1 + .../generator/adoc/layouts/wrapper.adoc.hbs | 16 ++ .../lua/generator/common/helpers/_utils.lua | 35 +++ .../lua/generator/common/helpers/choose.lua | 10 + .../lua/generator/common/helpers/describe.lua | 35 +++ .../lua/generator/common/helpers/echo.lua | 13 + .../generator/common/helpers/format_id.lua | 6 + .../lua/generator/common/helpers/glue.lua | 15 + .../generator/common/helpers/hash_inspect.lua | 7 + .../lua/generator/common/helpers/when.lua | 12 + .../lua/generator/html/helpers/format_id.lua | 6 + .../lua/generator/html/layouts/index.html.hbs | 1 + .../generator/html/layouts/wrapper.html.hbs | 18 ++ .../generator/hbs/lua-helper/helpers.adoc | 16 ++ .../generator/hbs/lua-helper/helpers.cpp | 3 + .../generator/hbs/lua-helper/helpers.html | 18 ++ .../generator/hbs/lua-helper/helpers.xml | 36 +++ .../generator/hbs/lua-helper/mrdocs.yml | 6 + third-party/patches/lua/CMakeLists.txt | 31 ++- 37 files changed, 785 insertions(+), 64 deletions(-) create mode 100644 test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/adoc/layouts/index.adoc.hbs create mode 100644 test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/adoc/layouts/wrapper.adoc.hbs create mode 100644 test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/common/helpers/greet.lua create mode 100644 test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/common/helpers/keep.lua create mode 100644 test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/html/layouts/index.html.hbs create mode 100644 test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/html/layouts/wrapper.html.hbs create mode 100644 test-files/golden-tests/generator/hbs/lua-helper-layering/addons/override/generator/common/helpers/greet.lua create mode 100644 test-files/golden-tests/generator/hbs/lua-helper-layering/layering.adoc create mode 100644 test-files/golden-tests/generator/hbs/lua-helper-layering/layering.cpp create mode 100644 test-files/golden-tests/generator/hbs/lua-helper-layering/layering.html create mode 100644 test-files/golden-tests/generator/hbs/lua-helper-layering/layering.xml create mode 100644 test-files/golden-tests/generator/hbs/lua-helper-layering/mrdocs.yml create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/adoc/helpers/format_id.lua create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/adoc/layouts/index.adoc.hbs create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/adoc/layouts/wrapper.adoc.hbs create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/_utils.lua create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/choose.lua create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/describe.lua create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/echo.lua create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/format_id.lua create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/glue.lua create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/hash_inspect.lua create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/when.lua create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/html/helpers/format_id.lua create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/html/layouts/index.html.hbs create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/html/layouts/wrapper.html.hbs create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/helpers.adoc create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/helpers.cpp create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/helpers.html create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/helpers.xml create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/mrdocs.yml diff --git a/include/mrdocs/Support/Expected.hpp b/include/mrdocs/Support/Expected.hpp index a8f7e60be2..e1c6f2c2fd 100644 --- a/include/mrdocs/Support/Expected.hpp +++ b/include/mrdocs/Support/Expected.hpp @@ -388,22 +388,29 @@ namespace detail # define MRDOCS_LABEL_(a) MRDOCS_MERGE_(expected_result_, a) # define MRDOCS_UNIQUE_NAME MRDOCS_LABEL_(__LINE__) +// `detail::failed` and `detail::error` below are qualified with `::mrdocs::` +// so the macros remain correct when expanded inside another `detail` +// namespace (e.g. `mrdocs::lua::detail`): a qualified `detail::` lookup +// stops at the first matching nested `detail` and never falls through to +// `mrdocs::detail`. `Unexpected` and `Error` are left unqualified: ordinary +// scope walking finds them in `mrdocs::`. + /// Try to retrive expected-like type # define MRDOCS_TRY_VOID(expr) \ auto MRDOCS_UNIQUE_NAME = expr; \ - if (detail::failed(MRDOCS_UNIQUE_NAME)) { \ - return Unexpected(detail::error(MRDOCS_UNIQUE_NAME)); \ + if (::mrdocs::detail::failed(MRDOCS_UNIQUE_NAME)) { \ + return Unexpected(::mrdocs::detail::error(MRDOCS_UNIQUE_NAME)); \ } \ void(0) # define MRDOCS_TRY_VAR(var, expr) \ auto MRDOCS_UNIQUE_NAME = expr; \ - if (detail::failed(MRDOCS_UNIQUE_NAME)) { \ - return Unexpected(detail::error(MRDOCS_UNIQUE_NAME)); \ + if (::mrdocs::detail::failed(MRDOCS_UNIQUE_NAME)) { \ + return Unexpected(::mrdocs::detail::error(MRDOCS_UNIQUE_NAME)); \ } \ var = *std::move(MRDOCS_UNIQUE_NAME) # define MRDOCS_TRY_MSG(var, expr, msg) \ auto MRDOCS_UNIQUE_NAME = expr; \ - if (detail::failed(MRDOCS_UNIQUE_NAME)) { \ + if (::mrdocs::detail::failed(MRDOCS_UNIQUE_NAME)) { \ return Unexpected(Error(msg)); \ } \ var = *std::move(MRDOCS_UNIQUE_NAME) @@ -413,12 +420,12 @@ namespace detail /// Check existing expected-like type # define MRDOCS_CHECK_VOID(var) \ - if (detail::failed(var)) { \ - return Unexpected(detail::error(var)); \ + if (::mrdocs::detail::failed(var)) { \ + return Unexpected(::mrdocs::detail::error(var)); \ } \ void(0) # define MRDOCS_CHECK_MSG(var, msg) \ - if (detail::failed(var)) { \ + if (::mrdocs::detail::failed(var)) { \ return Unexpected(Error(msg)); \ } \ void(0) @@ -428,12 +435,12 @@ namespace detail /// Check existing expected-like type and return custom value otherwise # define MRDOCS_CHECK_OR_VOID(var) \ - if (detail::failed(var)) { \ + if (::mrdocs::detail::failed(var)) { \ return; \ } \ void(0) # define MRDOCS_CHECK_OR_VALUE(var, value) \ - if (detail::failed(var)) { \ + if (::mrdocs::detail::failed(var)) { \ return value; \ } \ void(0) @@ -442,7 +449,7 @@ namespace detail MRDOCS_CHECK_GET_OR_MACRO(__VA_ARGS__, MRDOCS_CHECK_OR_VALUE, MRDOCS_CHECK_OR_VOID)(__VA_ARGS__) # define MRDOCS_CHECK_OR_CONTINUE(var) \ - if (detail::failed(var)) { \ + if (::mrdocs::detail::failed(var)) { \ continue; \ } \ void(0) diff --git a/include/mrdocs/Support/Lua.hpp b/include/mrdocs/Support/Lua.hpp index ab9f873a77..ea92992d5f 100644 --- a/include/mrdocs/Support/Lua.hpp +++ b/include/mrdocs/Support/Lua.hpp @@ -4,6 +4,7 @@ // SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception // // Copyright (c) 2023 Vinnie Falco (vinnie.falco@gmail.com) +// Copyright (c) 2026 Gennaro Prota (gennaro.prota@gmail.com) // // Official repository: https://github.com/cppalliance/mrdocs // @@ -21,6 +22,9 @@ #include namespace mrdocs { + +class Handlebars; + /** Lua interop helpers for the optional scripting/backend integration. This namespace contains glue for pushing/popping values, registering @@ -607,6 +611,42 @@ class Table : public Value Param value) const; }; +/** Register a Lua helper function + + Register a Lua chunk as a Handlebars helper. The chunk source is + resolved to a callable in the following order: + + 1. **Chunk return value** - load and execute the chunk; if it returns + a function, use that. This is the idiomatic shape for a per-file + helper: + Example: `return function(x) return 'lua:' .. tostring(x) end` + + 2. **Global lookup** - if the chunk does not return a function, look + up the helper name on the global table. This handles chunks that + define a function as a side effect: + Example: `function helper_name(x) return tostring(x) end` + + The resolved function is anchored in `LUA_REGISTRYINDEX` for the + lifetime of the registration. When the helper is invoked from a + template, positional arguments are converted from @ref dom::Value to + Lua values; the trailing Handlebars options object is dropped (matching + the JavaScript helper semantics) to avoid recursive marshalling of + symbol contexts. + + @param hbs The Handlebars instance to register the helper into + @param name The name of the helper function + @param ctx The Lua context that anchors the helper closure + @param script The Lua source that defines the helper function + @return Success, or an error if the script could not be resolved to a function +*/ +[[nodiscard]] MRDOCS_DECL +Expected +registerHelper( + mrdocs::Handlebars& hbs, + std::string_view name, + Context& ctx, + std::string_view script); + } // lua } // mrdocs diff --git a/src/lib/Gen/hbs/Builder.cpp b/src/lib/Gen/hbs/Builder.cpp index feb1a90925..c21f57eb8d 100644 --- a/src/lib/Gen/hbs/Builder.cpp +++ b/src/lib/Gen/hbs/Builder.cpp @@ -5,6 +5,7 @@ // // Copyright (c) 2023 Vinnie Falco (vinnie.falco@gmail.com) // Copyright (c) 2024 Alan de Freitas (alandefreitas@gmail.com) +// Copyright (c) 2026 Gennaro Prota (gennaro.prota@gmail.com) // // Official repository: https://github.com/cppalliance/mrdocs // @@ -242,6 +243,52 @@ registerDefaultHelpers(Handlebars& hbs) hbs.registerHelper("relativize", dom::makeInvocable(relativize_fn)); } +/** Categorizes files in the helper directories by extension. + + Walks each directory recursively, picking files whose name ends in + `ext`. Files whose stem starts with `_` are treated as utility scripts + (loaded before helpers); the rest are recorded as helpers keyed by + stem. Other extensions are ignored, so JS and Lua scans do not + interfere with each other. + + @param helperDirs The directories to scan for helper files. + @param ext The file extension to match (e.g. `.js`, `.lua`). + @param[out] utilityFiles Paths of utility scripts to run before helpers. + @param[out] helperFiles (helper name, path) pairs, in directory order. + @return Success, or the underlying filesystem error. +*/ +Expected +collectHelperFiles( + std::vector const& helperDirs, + std::string_view ext, + std::vector& utilityFiles, + std::vector>& helperFiles) +{ + for (auto const& dir : helperDirs) + { + if (!files::exists(dir)) + continue; + + auto exp = forEachFile(dir, true, + [&](std::string_view pathName) -> Expected + { + if (!pathName.ends_with(ext)) + return {}; + auto name = files::getFileName(pathName); + name.remove_suffix(ext.size()); + + if (name.starts_with("_")) + utilityFiles.emplace_back(pathName); + else + helperFiles.emplace_back(std::string(name), std::string(pathName)); + return {}; + }); + if (!exp) + return Unexpected(exp.error()); + } + return {}; +} + /** Registers user-defined JavaScript helpers from addon directories. Scans the specified directories for JavaScript files and registers @@ -261,7 +308,7 @@ registerDefaultHelpers(Handlebars& hbs) @return Success, or an error if loading/registration fails. */ Expected -registerUserHelpers( +registerUserJsHelpers( Handlebars& hbs, js::Context& ctx, std::vector const& helperDirs) @@ -278,37 +325,8 @@ registerUserHelpers( std::vector utilityFiles; std::vector> helperFiles; // (name, path) - for (auto const& dir : helperDirs) - { - if (!files::exists(dir)) - continue; + MRDOCS_TRY(collectHelperFiles(helperDirs, ".js", utilityFiles, helperFiles)); - auto exp = forEachFile(dir, true, - [&](std::string_view pathName) -> Expected - { - constexpr std::string_view ext = ".js"; - if (!pathName.ends_with(ext)) - return {}; - auto name = files::getFileName(pathName); - name.remove_suffix(ext.size()); - - if (name.starts_with("_")) - { - // Utility file: will be executed as script - utilityFiles.emplace_back(pathName); - } - else - { - // Helper file: will be registered as Handlebars helper - helperFiles.emplace_back(std::string(name), std::string(pathName)); - } - return {}; - }); - if (!exp) - return Unexpected(exp.error()); - } - - // Sort utility files alphabetically for predictable load order std::sort(utilityFiles.begin(), utilityFiles.end()); // Load utilities first (they define globals available to helpers). @@ -337,6 +355,61 @@ registerUserHelpers( return {}; } +/** Registers user-defined Lua helpers from addon directories. + + Mirrors @ref registerUserJsHelpers for Lua. Files are categorized: + + - **Utility files** (prefixed with `_`): Loaded as Lua chunks and + executed once. Use them to populate the global table or `package` + modules that helpers can reference. + - **Helper files**: Registered as Handlebars helpers via + @ref lua::registerHelper, using the filename stem as the helper name. + + Both `.js` and `.lua` files can coexist in the same addon directory. + A `.lua` helper registered with the same name as an existing `.js` + helper replaces it (because Handlebars helper registration overwrites). + + @param hbs The Handlebars instance to register helpers with. + @param ctx The Lua context for script execution. + @param helperDirs The directories to scan for helper files. + @return Success, or an error if loading/registration fails. +*/ +Expected +registerUserLuaHelpers( + Handlebars& hbs, + lua::Context& ctx, + std::vector const& helperDirs) +{ + std::vector utilityFiles; + std::vector> helperFiles; // (name, path) + + MRDOCS_TRY(collectHelperFiles(helperDirs, ".lua", utilityFiles, helperFiles)); + + std::sort(utilityFiles.begin(), utilityFiles.end()); + + for (auto const& utilPath : utilityFiles) + { + lua::Scope scope(ctx); + MRDOCS_TRY(auto script, files::getFileText(utilPath)); + MRDOCS_TRY(auto chunk, scope.loadChunk(script, utilPath)); + auto exp = chunk.call(); + if (!exp) + { + return Unexpected(formatError( + "Error loading utility {}: {}", + utilPath, exp.error().message())); + } + } + + for (auto const& [name, path] : helperFiles) + { + MRDOCS_TRY(auto script, files::getFileText(path)); + MRDOCS_TRY(lua::registerHelper(hbs, name, ctx, script)); + } + + return {}; +} + /** Loads a layout template from addon directories. Searches through the layout directories for the specified template @@ -389,9 +462,14 @@ Builder( // Load partials (later dirs overwrite earlier ones because we walk in order) registerPartials(hbs_, partialDirs); - // Built-in helpers first, then user JS helpers so overrides work as expected. + // Built-in helpers first, then user scripts (JS and Lua) so user code + // can override built-ins. JS runs before Lua, so a Lua helper with the + // same name as a JS helper takes precedence (last-write-wins on the + // Handlebars side). registerDefaultHelpers(hbs_); - if (auto exp = registerUserHelpers(hbs_, ctx_, helperDirs); !exp) + if (auto exp = registerUserJsHelpers(hbs_, ctx_, helperDirs); !exp) + exp.error().Throw(); + if (auto exp = registerUserLuaHelpers(hbs_, lua_ctx_, helperDirs); !exp) exp.error().Throw(); // Load layout templates diff --git a/src/lib/Gen/hbs/Builder.hpp b/src/lib/Gen/hbs/Builder.hpp index 67e277100d..6109c5dbae 100644 --- a/src/lib/Gen/hbs/Builder.hpp +++ b/src/lib/Gen/hbs/Builder.hpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -41,6 +42,7 @@ namespace hbs { class Builder { js::Context ctx_; + lua::Context lua_ctx_; Handlebars hbs_; std::map> templates_; std::function escapeFn_; diff --git a/src/lib/Support/Lua.cpp b/src/lib/Support/Lua.cpp index b49e6a6680..8bf733f46c 100644 --- a/src/lib/Support/Lua.cpp +++ b/src/lib/Support/Lua.cpp @@ -4,20 +4,27 @@ // SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception // // Copyright (c) 2023 Vinnie Falco (vinnie.falco@gmail.com) +// Copyright (c) 2026 Gennaro Prota (gennaro.prota@gmail.com) // // Official repository: https://github.com/cppalliance/mrdocs // #include +#include #include #include #include #include +#include +#include + +// Lua's upstream headers are C-only and ship without `extern "C"` guards. +// Wrap the includes here. +extern "C" { #include #include -#include #include -#include +} namespace mrdocs { namespace lua { @@ -34,6 +41,7 @@ static char gImplKey{}; //------------------------------------------------ static void domObject_push_metatable(Access& A); +static void domArray_push(Access& A, dom::Array const&); static void domValue_push(Access& A, dom::Value const&); //------------------------------------------------ @@ -278,8 +286,7 @@ domArray_get( lua_touserdata(A, index)); } -// Push the domObject metatable onto the stack -[[maybe_unused]] +// Push the domArray metatable onto the stack static void domArray_push_metatable( @@ -391,6 +398,21 @@ domArray_push_metatable( A->arrMetaRef = luaL_ref(A, LUA_REGISTRYINDEX); } +// Push a dom::Array onto the stack +static +void +domArray_push( + Access& A, + dom::Array const& arr) +{ + auto& arr_ = *static_cast< + dom::Array*>(lua_newuserdatauv( + A, sizeof(dom::Array), 0)); + domArray_push_metatable(A); + lua_setmetatable(A, -2); + std::construct_at(&arr_, arr); +} + //------------------------------------------------ // // dom::Object @@ -591,8 +613,7 @@ domValue_push( case dom::Kind::String: return luaM_pushstring(A, value.getString()); case dom::Kind::Array: - MRDOCS_UNREACHABLE(); - //return domArray_push(A, value.getArray()); + return domArray_push(A, value.getArray()); case dom::Kind::Object: return domObject_push(A, value.getObject()); default: @@ -711,7 +732,8 @@ push(Scope& scope) const case Kind::value: return lua_pushvalue(A, index_); case Kind::domArray: - MRDOCS_UNREACHABLE(); + domArray_push(A, arr_); + return; case Kind::domObject: domObject_push(A, obj_); return; @@ -1156,6 +1178,229 @@ callImpl( return A.construct(-1, *scope_); } +//------------------------------------------------ +// +// registerHelper +// +//------------------------------------------------ + +// Convert the Lua value at the given stack index to a dom::Value. +// Used to marshal a helper's return value back to Handlebars. +// +// Userdata wrapping our own dom::Object/dom::Array are unwrapped in place +// (preserving identity); raw Lua tables are converted to a dom::Object using +// string keys (non-string keys are skipped). Non-representable types +// (function, thread, light userdata) become null. +static +dom::Value +luaToDom(Access& A, int idx) +{ + int const t = lua_type(A, idx); + switch(t) + { + case LUA_TNIL: + return dom::Value(); + case LUA_TBOOLEAN: + return dom::Value(lua_toboolean(A, idx) != 0); + case LUA_TNUMBER: + if (lua_isinteger(A, idx)) + return dom::Value(static_cast( + lua_tointeger(A, idx))); + // dom::Value has no double kind; truncate floats. Helpers that need + // sub-integer precision should return strings. + return dom::Value(static_cast( + lua_tonumber(A, idx))); + case LUA_TSTRING: + { + std::size_t len; + char const* data = lua_tolstring(A, idx, &len); + return dom::Value(std::string(data, len)); + } + case LUA_TTABLE: + { + dom::Object obj; + int const absIdx = lua_absindex(A, idx); + lua_pushnil(A); + while (lua_next(A, absIdx) != 0) + { + if (lua_type(A, -2) == LUA_TSTRING) + { + std::size_t klen; + char const* kdata = lua_tolstring(A, -2, &klen); + obj.set( + std::string_view(kdata, klen), + luaToDom(A, -1)); + } + lua_pop(A, 1); // pop value, keep key for next iteration + } + return dom::Value(std::move(obj)); + } + case LUA_TUSERDATA: + { + if (! lua_getmetatable(A, idx)) + return dom::Value(); + int const metaIdx = lua_absindex(A, -1); + dom::Value result; + bool matched = false; + + if (A->objMetaRef != LUA_NOREF) + { + lua_rawgeti(A, LUA_REGISTRYINDEX, A->objMetaRef); + if (lua_rawequal(A, metaIdx, -1)) + { + result = dom::Value(*static_cast( + lua_touserdata(A, idx))); + matched = true; + } + lua_pop(A, 1); + } + if (! matched && A->arrMetaRef != LUA_NOREF) + { + lua_rawgeti(A, LUA_REGISTRYINDEX, A->arrMetaRef); + if (lua_rawequal(A, metaIdx, -1)) + { + result = dom::Value(*static_cast( + lua_touserdata(A, idx))); + } + lua_pop(A, 1); + } + lua_pop(A, 1); // pop metatable + return result; + } + default: + return dom::Value(); + } +} + +namespace detail { + +// Registry-anchored handle that owns a Lua function's lifetime independently +// of any Scope. The function lives in LUA_REGISTRYINDEX until the handle is +// destroyed, so the Handlebars helper closure can keep firing across renders. +struct LuaHelperHandle +{ + Context ctx; + int ref; + + LuaHelperHandle(Context c, int r) noexcept + : ctx(std::move(c)), ref(r) + { + } + LuaHelperHandle(LuaHelperHandle const&) = delete; + LuaHelperHandle& operator=(LuaHelperHandle const&) = delete; + ~LuaHelperHandle() + { + Access A(ctx); + luaL_unref(A, LUA_REGISTRYINDEX, ref); + } +}; + +// Strip the trailing Handlebars options object (matching the JS helper +// semantics), push positional args to Lua, run the helper, and return the +// converted result. Errors from lua_pcall surface as Unexpected. +static +Expected +invokeHelperRef( + std::shared_ptr const& handle, + dom::Array const& args) +{ + if (args.empty()) + { + return Unexpected(Error( + "Handlebars helper called without arguments; " + "expected options object as last argument")); + } + dom::Value const& options = args.back(); + if (! options.isObject()) + { + return Unexpected(Error( + "Handlebars helper options must be an object; " + "ensure the helper is called from a template context")); + } + + Scope scope(handle->ctx); + Access A(scope); + + lua_rawgeti(A, LUA_REGISTRYINDEX, handle->ref); + + std::size_t const narg = args.size() - 1; + for (std::size_t i = 0; i < narg; ++i) + { + Param p(args.get(i)); + Access::push(p, scope); + } + + int const rc = lua_pcall(A, static_cast(narg), 1, 0); + if (rc != LUA_OK) + return Unexpected(luaM_popError(A)); + + dom::Value result = luaToDom(A, lua_gettop(A)); + lua_pop(A, 1); + return result; +} + +} // detail + +Expected +registerHelper( + Handlebars& hbs, + std::string_view name, + Context& ctx, + std::string_view script) +{ + // Resolve a Lua chunk to a callable: the chunk's return value is preferred + // (the "return function(...) ... end" idiom), falling back to a global of + // the same name (the "function name(...) ... end" idiom). + Scope scope(ctx); + + auto chunk = scope.loadChunk(script, std::string(name)); + if (! chunk) + return Unexpected(chunk.error()); + + auto chunkResult = chunk->call(); + if (! chunkResult) + return Unexpected(chunkResult.error()); + + Access A(scope); + int ref; + + if (chunkResult->isFunction()) + { + lua_pushvalue(A, Access::index(*chunkResult)); + ref = luaL_ref(A, LUA_REGISTRYINDEX); + } + else + { + auto global = scope.getGlobal(name); + if (! global) + { + return Unexpected(formatError( + "lua helper '{}': chunk did not return a function " + "and no global of that name was defined", + name)); + } + if (! global->isFunction()) + { + return Unexpected(formatError( + "lua helper '{}' is not a function", name)); + } + lua_pushvalue(A, Access::index(*global)); + ref = luaL_ref(A, LUA_REGISTRYINDEX); + } + + auto handle = std::make_shared(ctx, ref); + + hbs.registerHelper( + std::string(name), + dom::makeVariadicInvocable( + [handle](dom::Array const& args) -> Expected + { + return detail::invokeHelperRef(handle, args); + })); + + return {}; +} + //------------------------------------------------ void diff --git a/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/adoc/layouts/index.adoc.hbs b/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/adoc/layouts/index.adoc.hbs new file mode 100644 index 0000000000..2c2b1d9f2d --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/adoc/layouts/index.adoc.hbs @@ -0,0 +1 @@ +{{! Index intentionally unused for this fixture }} diff --git a/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/adoc/layouts/wrapper.adoc.hbs b/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/adoc/layouts/wrapper.adoc.hbs new file mode 100644 index 0000000000..4ede38ccf2 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/adoc/layouts/wrapper.adoc.hbs @@ -0,0 +1,4 @@ += Layering Test + +greet: {{greet}} +keep: {{keep}} diff --git a/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/common/helpers/greet.lua b/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/common/helpers/greet.lua new file mode 100644 index 0000000000..a230efd348 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/common/helpers/greet.lua @@ -0,0 +1,4 @@ +-- Base helper - should be overridden by supplemental addon +return function() + return "base-hello" +end diff --git a/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/common/helpers/keep.lua b/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/common/helpers/keep.lua new file mode 100644 index 0000000000..5dcb9fab3f --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/common/helpers/keep.lua @@ -0,0 +1,4 @@ +-- Base helper that is NOT overridden - should remain available +return function() + return "base-keep" +end diff --git a/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/html/layouts/index.html.hbs b/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/html/layouts/index.html.hbs new file mode 100644 index 0000000000..2c2b1d9f2d --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/html/layouts/index.html.hbs @@ -0,0 +1 @@ +{{! Index intentionally unused for this fixture }} diff --git a/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/html/layouts/wrapper.html.hbs b/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/html/layouts/wrapper.html.hbs new file mode 100644 index 0000000000..8a2ca2d24e --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/html/layouts/wrapper.html.hbs @@ -0,0 +1,7 @@ + + + +

{{greet}}

+

{{keep}}

+ + diff --git a/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/override/generator/common/helpers/greet.lua b/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/override/generator/common/helpers/greet.lua new file mode 100644 index 0000000000..468accfaf0 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/override/generator/common/helpers/greet.lua @@ -0,0 +1,4 @@ +-- Override helper - should replace base greet helper +return function() + return "override-hello" +end diff --git a/test-files/golden-tests/generator/hbs/lua-helper-layering/layering.adoc b/test-files/golden-tests/generator/hbs/lua-helper-layering/layering.adoc new file mode 100644 index 0000000000..f1e578ac3f --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper-layering/layering.adoc @@ -0,0 +1,4 @@ += Layering Test + +greet: override‐hello +keep: base‐keep diff --git a/test-files/golden-tests/generator/hbs/lua-helper-layering/layering.cpp b/test-files/golden-tests/generator/hbs/lua-helper-layering/layering.cpp new file mode 100644 index 0000000000..1cc3102b0b --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper-layering/layering.cpp @@ -0,0 +1,4 @@ +// Golden test for addons-supplemental layering +// Verifies that supplemental addons override base addon helpers + +void layering_entry(); diff --git a/test-files/golden-tests/generator/hbs/lua-helper-layering/layering.html b/test-files/golden-tests/generator/hbs/lua-helper-layering/layering.html new file mode 100644 index 0000000000..a0bf59b2a9 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper-layering/layering.html @@ -0,0 +1,7 @@ + + + +

override-hello

+

base-keep

+ + diff --git a/test-files/golden-tests/generator/hbs/lua-helper-layering/layering.xml b/test-files/golden-tests/generator/hbs/lua-helper-layering/layering.xml new file mode 100644 index 0000000000..43688ca71d --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper-layering/layering.xml @@ -0,0 +1,36 @@ + + + + + + namespace + //////////////////////////8= + regular + + 6Ck47Qi5Akzd//BP/G1woD2OR1Q= + + + + layering_entry + + + layering.cpp + layering.cpp + 4 + 1 + + + function + 6Ck47Qi5Akzd//BP/G1woD2OR1Q= + regular + //////////////////////////8= + + + identifier + void + + + normal + + diff --git a/test-files/golden-tests/generator/hbs/lua-helper-layering/mrdocs.yml b/test-files/golden-tests/generator/hbs/lua-helper-layering/mrdocs.yml new file mode 100644 index 0000000000..70881123a4 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper-layering/mrdocs.yml @@ -0,0 +1,8 @@ +addons: addons/base +addons-supplemental: + - addons/override +generator: html +multipage: false +no-default-styles: true +warn-if-undocumented: false +source-root: . diff --git a/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/adoc/helpers/format_id.lua b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/adoc/helpers/format_id.lua new file mode 100644 index 0000000000..5cb81eece9 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/adoc/helpers/format_id.lua @@ -0,0 +1,6 @@ +-- AsciiDoc-specific override of format_id helper. +-- Should take precedence over the common/helpers/format_id.lua version. + +return function() + return "adoc" +end diff --git a/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/adoc/layouts/index.adoc.hbs b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/adoc/layouts/index.adoc.hbs new file mode 100644 index 0000000000..aca07fd474 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/adoc/layouts/index.adoc.hbs @@ -0,0 +1 @@ +{{! Index not used for this single-page fixture }} diff --git a/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/adoc/layouts/wrapper.adoc.hbs b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/adoc/layouts/wrapper.adoc.hbs new file mode 100644 index 0000000000..6e9b1f50c0 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/adoc/layouts/wrapper.adoc.hbs @@ -0,0 +1,16 @@ += Lua Helper Output +:mrdocs: + +* echo: {{echo "mrdocs"}} +* bool: {{describe true}} +* number: {{describe 42}} +* string: {{describe "hi"}} +* null: {{describe null}} +* undefined: {{describe}} +* array: {{describe "a" "b" 3}} +* hash: {{hash_inspect "a" 1 "b" "two"}} +* glue: {{glue "|" "x" "y" "z"}} +* block: {{#choose}}then{{else}}otherwise{{/choose}} +* format: {{format_id}} + +[.small]#Created with https://www.mrdocs.com[MrDocs]# diff --git a/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/_utils.lua b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/_utils.lua new file mode 100644 index 0000000000..dcab17878d --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/_utils.lua @@ -0,0 +1,35 @@ +-- Shared utility functions for Lua helpers. +-- Files starting with '_' are loaded before helper files and define +-- globals that can be used by all helpers. + +-- Normalize Handlebars arguments. Returns a packed table whose `n` field +-- is the argument count (so nils round-trip correctly). Userdata (DOM +-- objects passed in by Handlebars when no positional args are given) is +-- filtered out, mirroring the JS `normalize_args` helper. +function normalize_args(...) + local list = table.pack(...) + + local filtered = { n = 0 } + for i = 1, list.n do + local v = list[i] + if type(v) ~= "userdata" then + filtered.n = filtered.n + 1 + filtered[filtered.n] = v + end + end + return filtered +end + +-- Format an object's key-value pairs as a sorted, comma-separated string. +function format_object(obj) + local keys = {} + for k in pairs(obj) do + keys[#keys + 1] = k + end + table.sort(keys) + local parts = {} + for _, key in ipairs(keys) do + parts[#parts + 1] = key .. "=" .. tostring(obj[key]) + end + return table.concat(parts, ",") +end diff --git a/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/choose.lua b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/choose.lua new file mode 100644 index 0000000000..e4ca191a03 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/choose.lua @@ -0,0 +1,10 @@ +-- Block helper exercising options.fn/options.inverse. The options table is +-- dropped before the helper runs (matching the JS path), so this always +-- returns the "otherwise" branch. + +return function(options) + if options == nil or type(options) ~= "table" then + return "otherwise" + end + return "otherwise" +end diff --git a/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/describe.lua b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/describe.lua new file mode 100644 index 0000000000..c629f30d5e --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/describe.lua @@ -0,0 +1,35 @@ +-- Describe helper: reports type and value in a deterministic string. +-- Uses normalize_args and format_object from _utils.lua. + +return function(...) + local list = normalize_args(...) + local typ + local value + + if list.n == 0 then + typ = "undefined" + value = "" + elseif list.n > 1 then + typ = "array" + local strs = {} + for i = 1, list.n do + strs[i] = tostring(list[i]) + end + value = table.concat(strs, ",") + else + local v = list[1] + if v == nil then + typ = "null" + value = "" + else + typ = type(v) + if typ == "table" then + value = format_object(v) + else + value = tostring(v) + end + end + end + + return typ .. ":" .. value +end diff --git a/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/echo.lua b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/echo.lua new file mode 100644 index 0000000000..16ec067cf2 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/echo.lua @@ -0,0 +1,13 @@ +-- Echo helper used in golden tests; keeps output stable across engines. +-- Uses normalize_args from _utils.lua (loaded before helper files). + +return function(...) + local list = normalize_args(...) + local value + if list.n > 0 and list[1] ~= nil then + value = list[1] + else + value = "" + end + return "lua:" .. tostring(value) +end diff --git a/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/format_id.lua b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/format_id.lua new file mode 100644 index 0000000000..5e510b9686 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/format_id.lua @@ -0,0 +1,6 @@ +-- Helper to test format-specific override behaviour. +-- This common version should be overridden by format-specific helpers. + +return function() + return "common" +end diff --git a/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/glue.lua b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/glue.lua new file mode 100644 index 0000000000..8a8748eb7e --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/glue.lua @@ -0,0 +1,15 @@ +-- Glue helper: joins positional args using the first argument as separator. +-- Uses normalize_args from _utils.lua. + +return function(...) + local list = normalize_args(...) + if list.n == 0 then + return "" + end + local sep = tostring(list[1]) + local items = {} + for i = 2, list.n do + items[#items + 1] = tostring(list[i]) + end + return table.concat(items, sep) +end diff --git a/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/hash_inspect.lua b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/hash_inspect.lua new file mode 100644 index 0000000000..2e98ef5e8f --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/hash_inspect.lua @@ -0,0 +1,7 @@ +-- Hash helper: builds a stable string from options.hash or key/value args. +-- The Handlebars options object is dropped before the helper runs (mirroring +-- the JS path), so this returns a literal value for the golden test. + +return function() + return "hash:a=1,b=two" +end diff --git a/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/when.lua b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/when.lua new file mode 100644 index 0000000000..c045af2222 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/when.lua @@ -0,0 +1,12 @@ +-- Block helper that exercises options.fn/options.inverse. Registered for +-- parity with the JS fixture; the rendered template does not invoke it. + +return function(condition, options) + if options == nil or type(options) ~= "table" then + return "" + end + if condition then + return "" + end + return "" +end diff --git a/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/html/helpers/format_id.lua b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/html/helpers/format_id.lua new file mode 100644 index 0000000000..8f8156f634 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/html/helpers/format_id.lua @@ -0,0 +1,6 @@ +-- HTML-specific override of format_id helper. +-- Should take precedence over the common/helpers/format_id.lua version. + +return function() + return "html" +end diff --git a/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/html/layouts/index.html.hbs b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/html/layouts/index.html.hbs new file mode 100644 index 0000000000..2c2b1d9f2d --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/html/layouts/index.html.hbs @@ -0,0 +1 @@ +{{! Index intentionally unused for this fixture }} diff --git a/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/html/layouts/wrapper.html.hbs b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/html/layouts/wrapper.html.hbs new file mode 100644 index 0000000000..70c20d13c0 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/html/layouts/wrapper.html.hbs @@ -0,0 +1,18 @@ + + + +
    +
  • {{echo "mrdocs"}}
  • +
  • {{describe true}}
  • +
  • {{describe 42}}
  • +
  • {{describe "hi"}}
  • +
  • {{describe null}}
  • +
  • {{describe}}
  • +
  • {{describe "a" "b" 3}}
  • +
  • {{hash_inspect "a" 1 "b" "two"}}
  • +
  • {{glue "|" "x" "y" "z"}}
  • +
  • {{#choose}}then{{else}}otherwise{{/choose}}
  • +
  • {{format_id}}
  • +
+ + diff --git a/test-files/golden-tests/generator/hbs/lua-helper/helpers.adoc b/test-files/golden-tests/generator/hbs/lua-helper/helpers.adoc new file mode 100644 index 0000000000..ddbe318082 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/helpers.adoc @@ -0,0 +1,16 @@ += Lua Helper Output +:mrdocs: + +* echo: lua:mrdocs +* bool: boolean:true +* number: number:42 +* string: string:hi +* null: null: +* undefined: undefined: +* array: array:a,b,3 +* hash: hash:a=1,b=two +* glue: x|y|z +* block: otherwise +* format: adoc + +[.small]#Created with https://www.mrdocs.com[MrDocs]# diff --git a/test-files/golden-tests/generator/hbs/lua-helper/helpers.cpp b/test-files/golden-tests/generator/hbs/lua-helper/helpers.cpp new file mode 100644 index 0000000000..19a44d50e5 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/helpers.cpp @@ -0,0 +1,3 @@ +// Golden test input exercising multiple Handlebars helpers (JS today, room for more types later). + +void helpers_entry(); diff --git a/test-files/golden-tests/generator/hbs/lua-helper/helpers.html b/test-files/golden-tests/generator/hbs/lua-helper/helpers.html new file mode 100644 index 0000000000..02130f3b6b --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/helpers.html @@ -0,0 +1,18 @@ + + + +
    +
  • lua:mrdocs
  • +
  • boolean:true
  • +
  • number:42
  • +
  • string:hi
  • +
  • null:
  • +
  • undefined:
  • +
  • array:a,b,3
  • +
  • hash:a=1,b=two
  • +
  • x|y|z
  • +
  • otherwise
  • +
  • html
  • +
+ + diff --git a/test-files/golden-tests/generator/hbs/lua-helper/helpers.xml b/test-files/golden-tests/generator/hbs/lua-helper/helpers.xml new file mode 100644 index 0000000000..abfd7aa5f2 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/helpers.xml @@ -0,0 +1,36 @@ + + + + + + namespace + //////////////////////////8= + regular + + x33/DiL0nY266Q+QKndmXNfXoFw= + + + + helpers_entry + + + helpers.cpp + helpers.cpp + 3 + 1 + + + function + x33/DiL0nY266Q+QKndmXNfXoFw= + regular + //////////////////////////8= + + + identifier + void + + + normal + + diff --git a/test-files/golden-tests/generator/hbs/lua-helper/mrdocs.yml b/test-files/golden-tests/generator/hbs/lua-helper/mrdocs.yml new file mode 100644 index 0000000000..fd8f96ecb6 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/mrdocs.yml @@ -0,0 +1,6 @@ +addons: addons/lua +generator: html +multipage: false +no-default-styles: true +warn-if-undocumented: false +source-root: . diff --git a/third-party/patches/lua/CMakeLists.txt b/third-party/patches/lua/CMakeLists.txt index f0213814dd..1018e7a761 100644 --- a/third-party/patches/lua/CMakeLists.txt +++ b/third-party/patches/lua/CMakeLists.txt @@ -21,25 +21,30 @@ option(BUILD_SHARED_LIBS "Create lua as a shared library" OFF) project(lua VERSION ${lua_VERSION} LANGUAGES C) +# Files we always exclude from the core+stdlib library build: +# - lua.c standalone interpreter (defines its own `main`) +# - luac.c standalone bytecode compiler (defines its own `main`) +# - onelua.c unity-build alternative that #includes the others; its +# default `MAKE_LUA` mode pulls in lua.c and would re-define +# `main`, conflicting with any application that links lua.lib. +# - ltests.c Lua-internal test harness, only valid when LUA_USER_H +# enables it. +set(LUA_EXCLUDE_NAMES lua.c luac.c onelua.c ltests.c) + # Check if ./src exists if (EXISTS "${CMAKE_CURRENT_LIST_DIR}/src") - # Lua sources live under ./src - file(GLOB LUA_SOURCES "${CMAKE_CURRENT_LIST_DIR}/src/*.c") - list(REMOVE_ITEM LUA_SOURCES - "${CMAKE_CURRENT_LIST_DIR}/src/lua.c" - "${CMAKE_CURRENT_LIST_DIR}/src/luac.c" - ) - file(GLOB LUA_HEADERS "${CMAKE_CURRENT_LIST_DIR}/src/*.h") + set(LUA_SOURCE_DIR "${CMAKE_CURRENT_LIST_DIR}/src") else() # Lua sources live under ./ (github release) - file(GLOB LUA_SOURCES "${CMAKE_CURRENT_LIST_DIR}/*.c") - list(REMOVE_ITEM LUA_SOURCES - "${CMAKE_CURRENT_LIST_DIR}/lua.c" - "${CMAKE_CURRENT_LIST_DIR}/luac.c" - ) - file(GLOB LUA_HEADERS "${CMAKE_CURRENT_LIST_DIR}/*.h") + set(LUA_SOURCE_DIR "${CMAKE_CURRENT_LIST_DIR}") endif() +file(GLOB LUA_SOURCES "${LUA_SOURCE_DIR}/*.c") +foreach(_excl IN LISTS LUA_EXCLUDE_NAMES) + list(REMOVE_ITEM LUA_SOURCES "${LUA_SOURCE_DIR}/${_excl}") +endforeach() +file(GLOB LUA_HEADERS "${LUA_SOURCE_DIR}/*.h") + add_library(lua ${LUA_SOURCES} ${LUA_HEADERS}) target_include_directories(lua PUBLIC From 6e225e35d642f92b69808e19e90710f405002e90 Mon Sep 17 00:00:00 2001 From: Gennaro Prota Date: Thu, 7 May 2026 16:14:02 +0200 Subject: [PATCH 2/5] fix: dom::Object field lookup returns the key instead of the value The `__index` metamethod in `domObject_push_metatable()` retrieved the value correctly via `Object::get(key)`, then called `lua_replace(L, 1)` to move the result into the userdata's slot. `lua_replace` also pops the top, so, on return, the key string was at the top of the stack and Lua picked it up as the metamethod's single return value, making every field access on a `dom::Object` userdata silently return the key it was asked for. This was latent until now because no Lua script in the test suite previously read fields off a `dom::Object` userdata. Surfaced while wiring corpus extensions: a script doing `corpus.symbols[i]` saw `"symbols"` (the key) instead of the array. --- src/lib/Support/Lua.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/Support/Lua.cpp b/src/lib/Support/Lua.cpp index 8bf733f46c..230df1549f 100644 --- a/src/lib/Support/Lua.cpp +++ b/src/lib/Support/Lua.cpp @@ -455,7 +455,6 @@ domObject_push_metatable( domValue_push(A, domObject_get(A, 1).get( luaM_getstring(A, 2))); - lua_replace(A, 1); return 1; }); lua_settable(A, -3); From b3bbf989efb0e4334a44de796b31307516edfcd5 Mon Sep 17 00:00:00 2001 From: Gennaro Prota Date: Thu, 7 May 2026 16:24:25 +0200 Subject: [PATCH 3/5] feat: support Lua corpus-mutation extensions This adds a hook that runs user-provided Lua scripts after corpus extraction and finalization, before any generator runs. Extensions live in /extensions/*.lua for each addon root in the configuration. A script may define `transform_corpus(corpus)`, which is invoked once with a flat DOM view of the corpus. The script may mutate the corpus by calling pre-registered globals on the `mrdocs` table; currently: - `mrdocs.set_brief(symbol_id, text)`: replace a symbol's brief with a single-paragraph plain-text block. Each setter validates its arguments and raises a Lua error on misuse; any uncaught error in a script aborts the build. Multiple extensions run in alphabetical order by file path. The mutation surface is intentionally narrow; additional setters will land as concrete use cases surface. A golden fixture (test-files/golden-tests/extensions/lua-set-brief/) rewrites a function's brief from Lua and verifies the change reaches the xml output. Finally, this touches Lua wrapper for two new affordances: `Scope::pushDom` for the corpus argument, and a `Context::nativeState()` escape hatch for binding native C functions as Lua globals (the wrapper doesn't abstract that yet). --- include/mrdocs/Support/Lua.hpp | 27 ++ src/lib/CorpusImpl.cpp | 8 + src/lib/Extensions/RunExtensions.cpp | 317 ++++++++++++++++++ src/lib/Extensions/RunExtensions.hpp | 45 +++ src/lib/Support/Lua.cpp | 16 + .../addons/extensions/rebrief.lua | 24 ++ .../extensions/lua-set-brief/mrdocs.yml | 6 + .../extensions/lua-set-brief/set_brief.adoc | 32 ++ .../extensions/lua-set-brief/set_brief.cpp | 2 + .../extensions/lua-set-brief/set_brief.html | 52 +++ .../extensions/lua-set-brief/set_brief.xml | 46 +++ 11 files changed, 575 insertions(+) create mode 100644 src/lib/Extensions/RunExtensions.cpp create mode 100644 src/lib/Extensions/RunExtensions.hpp create mode 100644 test-files/golden-tests/extensions/lua-set-brief/addons/extensions/rebrief.lua create mode 100644 test-files/golden-tests/extensions/lua-set-brief/mrdocs.yml create mode 100644 test-files/golden-tests/extensions/lua-set-brief/set_brief.adoc create mode 100644 test-files/golden-tests/extensions/lua-set-brief/set_brief.cpp create mode 100644 test-files/golden-tests/extensions/lua-set-brief/set_brief.html create mode 100644 test-files/golden-tests/extensions/lua-set-brief/set_brief.xml diff --git a/include/mrdocs/Support/Lua.hpp b/include/mrdocs/Support/Lua.hpp index ea92992d5f..ee5a8f4f00 100644 --- a/include/mrdocs/Support/Lua.hpp +++ b/include/mrdocs/Support/Lua.hpp @@ -121,6 +121,19 @@ class MRDOCS_DECL /** Constructor. */ Context(Context const&) noexcept; + + /** Return the underlying `lua_State*`. + + Exposed as `void*` so callers don't have to drag `lua.h` into + the public API. Cast to `lua_State*` at the use site. The state + is owned by this Context and must not be `lua_close`d by the + caller; use this only when the wrapper does not yet abstract + the operation you need (for example, registering a native + C function that the script can call as a global). + */ + MRDOCS_DECL + void* + nativeState() const noexcept; }; //------------------------------------------------ @@ -210,6 +223,20 @@ class Scope std::string_view key, source_location loc = source_location::current()); + + /** Push a dom::Value onto the Lua stack. + + Primitives (nil, boolean, integer, string) are pushed as their + Lua-native counterparts. Arrays and objects are pushed as + userdata wrapping the underlying dom container, with the same + lazy bindings used elsewhere in the wrapper. + + @param value The DOM value to push. + @return A Value referring to the new stack slot. + */ + MRDOCS_DECL + Value + pushDom(dom::Value const& value); }; //------------------------------------------------ diff --git a/src/lib/CorpusImpl.cpp b/src/lib/CorpusImpl.cpp index 559362e8cb..5877df7a6d 100644 --- a/src/lib/CorpusImpl.cpp +++ b/src/lib/CorpusImpl.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -931,6 +932,13 @@ CorpusImpl::build( // ------------------------------------------ corpus->finalize(); + // ------------------------------------------ + // Run user extension scripts + // ------------------------------------------ + // Extensions fire after finalizers and before any generator runs, + // so any mutations they perform are visible to every output format. + MRDOCS_TRY(runExtensions(*corpus)); + report::info( "Extracted {} declarations in {}", corpus->info_.size(), diff --git a/src/lib/Extensions/RunExtensions.cpp b/src/lib/Extensions/RunExtensions.cpp new file mode 100644 index 0000000000..7db4b4472e --- /dev/null +++ b/src/lib/Extensions/RunExtensions.cpp @@ -0,0 +1,317 @@ +// +// Licensed under the Apache License v2.0 with LLVM Exceptions. +// See https://llvm.org/LICENSE.txt for license information. +// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +// +// Copyright (c) 2026 Gennaro Prota (gennaro.prota@gmail.com) +// +// Official repository: https://github.com/cppalliance/mrdocs +// + +#include "RunExtensions.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +#include +} + +namespace mrdocs { +namespace { + +// ===================================================================== +// State carried into Lua C-callbacks +// ===================================================================== + +// Shared state passed to every `mrdocs.*` callback as a Lua upvalue. +// Holds the live corpus and a string -> Symbol* index that avoids +// re-parsing the SymbolID encoding on every setter call. +struct ExtensionState +{ + CorpusImpl* corpus = nullptr; + std::unordered_map byId; +}; + +// ===================================================================== +// Discovery +// ===================================================================== + +// Return existing addon roots (primary first, then supplementals in +// declared order). Mirrors `hbs::addon_paths::addonRoots` but lives +// here so RunExtensions does not depend on the Handlebars layer. +std::vector +addonRoots(Config const& config) +{ + std::vector roots; + roots.reserve(1 + config->addonsSupplemental.size()); + if (files::exists(config->addons)) + roots.push_back(config->addons); + for (std::string const& supplemental : config->addonsSupplemental) + { + if (files::exists(supplemental)) + roots.push_back(supplemental); + } + return roots; +} + +// Collect /extensions/*.lua across every addon root, sorted +// alphabetically by full path so script ordering is stable across +// platforms and filesystems. +Expected> +collectExtensionScripts(Config const& config) +{ + std::vector scripts; + std::vector const roots = addonRoots(config); + for (std::string const& root : roots) + { + std::string const dir = files::appendPath(root, "extensions"); + if (!files::exists(dir)) + continue; + + Expected exp = forEachFile(dir, true, + [&](std::string_view pathName) -> Expected + { + constexpr std::string_view ext = ".lua"; + if (pathName.ends_with(ext)) + scripts.emplace_back(pathName); + return {}; + }); + if (!exp) + return Unexpected(exp.error()); + } + std::sort(scripts.begin(), scripts.end()); + return scripts; +} + +// ===================================================================== +// Read view: build a flat DOM of the corpus for scripts +// ===================================================================== + +// Concatenate the plain text from a brief block's inline children, +// best-effort. Used so scripts can read the current brief as a string. +std::string +briefAsText(Symbol const& sym) +{ + if (!sym.doc || !sym.doc->brief) + return {}; + std::string out; + for (Polymorphic const& child : sym.doc->brief->children) + { + // Polymorphic is constructed non-null through the public API, + // so no nullability check is needed here. + doc::getAsPlainText(*child, out); + } + return out; +} + +// Build the per-symbol object exposed to scripts. Intentionally narrow: +// just enough fields for scripts to identify a symbol and decide whether +// to act on it. Round-tripping back to a mutation goes through `id`. +dom::Object +buildSymbolDom( + Symbol const& sym, + std::string idStr, + std::string qualifiedName) +{ + dom::Object obj; + obj.set("id", std::move(idStr)); + obj.set("kind", toString(sym.Kind)); + obj.set("name", sym.Name); + obj.set("qualifiedName", std::move(qualifiedName)); + obj.set("brief", briefAsText(sym)); + return obj; +} + +// Build the top-level corpus value `transform_corpus(corpus)` receives. +// Eager construction is fine while the supported mutations are limited +// to per-symbol metadata; if corpora grow into the hundreds of +// thousands of symbols this can be revisited with a `LazyArray`. +dom::Value +buildCorpusDom(CorpusImpl& corpus, ExtensionState& state) +{ + dom::Array symbols; + for (Symbol const& sym : corpus) + { + std::string idStr = toBase16Str(sym.id); + std::string qualName; + corpus.qualifiedName(sym, qualName); + // Stash a mutable handle keyed by the same string scripts see, + // so setter callbacks don't have to re-decode the id. + state.byId[idStr] = corpus.find(sym.id); + symbols.emplace_back(dom::Value( + buildSymbolDom(sym, idStr, std::move(qualName)))); + } + dom::Object corpusObj; + corpusObj.set("symbols", std::move(symbols)); + return dom::Value(std::move(corpusObj)); +} + +// ===================================================================== +// Mutations: the `mrdocs` global table +// ===================================================================== +// +// We register C closures directly on the raw lua_State* (via the +// Context::nativeState() escape hatch) because the wrapper does not yet +// abstract "set a global to a native function with carried state." +// Each closure carries the ExtensionState pointer as its single upvalue. + +ExtensionState& +upvalueState(lua_State* L) +{ + ExtensionState* p = static_cast( + lua_touserdata(L, lua_upvalueindex(1))); + MRDOCS_ASSERT(p != nullptr); + return *p; +} + +// `mrdocs.set_brief(symbol_id, text)` +// +// Replaces the symbol's brief with a single-paragraph plain-text block. +// Validation: +// - both arguments must be strings +// - the id must resolve to a Symbol in the corpus +// On failure the script aborts via `luaL_error`; the host turns that +// into an `Unexpected` when `lua_pcall` returns non-OK. +int +luaSetBrief(lua_State* L) +{ + ExtensionState& state = upvalueState(L); + + if (lua_type(L, 1) != LUA_TSTRING) + return luaL_error(L, + "mrdocs.set_brief: argument 1 (symbol id) must be a string"); + if (lua_type(L, 2) != LUA_TSTRING) + return luaL_error(L, + "mrdocs.set_brief: argument 2 (text) must be a string"); + + std::size_t idLen = 0; + char const* idData = lua_tolstring(L, 1, &idLen); + std::size_t textLen = 0; + char const* textData = lua_tolstring(L, 2, &textLen); + + std::string idKey(idData, idLen); + auto it = state.byId.find(idKey); + if (it == state.byId.end()) + return luaL_error(L, + "mrdocs.set_brief: unknown symbol id '%s'", idKey.c_str()); + + Symbol* sym = it->second; + MRDOCS_ASSERT(sym != nullptr); + + if (!sym->doc.has_value()) + sym->doc.emplace(); + + doc::BriefBlock brief; + brief.children.push_back(Polymorphic( + std::in_place_type, + std::string_view(textData, textLen))); + sym->doc->brief.emplace(std::move(brief)); + + return 0; +} + +// Build the `mrdocs` global table and populate it with the setters. +void +registerMrDocsApi(lua_State* L, ExtensionState& state) +{ + lua_newtable(L); + + lua_pushlightuserdata(L, &state); + lua_pushcclosure(L, &luaSetBrief, 1); + lua_setfield(L, -2, "set_brief"); + + lua_setglobal(L, "mrdocs"); +} + +// ===================================================================== +// Per-script execution +// ===================================================================== + +Expected +runOneExtension(CorpusImpl& corpus, std::string const& scriptPath) +{ + lua::Context ctx; + ExtensionState state{ &corpus, {} }; + + // Build the corpus DOM and the id -> Symbol* map in one pass. + dom::Value corpusValue = buildCorpusDom(corpus, state); + + // Register the `mrdocs` global before loading the script so utility + // code at chunk top-level can reference it if it wants to. + registerMrDocsApi( + static_cast(ctx.nativeState()), state); + + // Load the chunk and execute it (defines globals, including + // `transform_corpus` if the script uses the `function name(...)` + // shape rather than a returned function). + lua::Scope scope(ctx); + MRDOCS_TRY(std::string script, files::getFileText(scriptPath)); + MRDOCS_TRY(lua::Function chunk, scope.loadChunk(script, scriptPath)); + + Expected chunkResult = chunk.call(); + if (!chunkResult) + return Unexpected(chunkResult.error()); + + // Resolve `transform_corpus`. Prefer the chunk's return value (the + // `return function(...) ... end` idiom); fall back to a same-named + // global (the `function name(...)` idiom). If neither yields a + // function, the extension has nothing to do — silently skip (an + // empty extension is valid). + // + // We can't pre-declare a `lua::Value` and assign into it because + // `lua::Value`'s user-defined move ctor implicitly deletes copy + // assignment, so we run the call inline in each branch instead. + auto callTransform = + [&](lua::Function&& fn) -> Expected + { + Expected result = fn.call(corpusValue); + if (!result) + return Unexpected(formatError( + "extension '{}': {}", + scriptPath, result.error().message())); + return {}; + }; + + if (chunkResult->isFunction()) + return callTransform(lua::Function(std::move(*chunkResult))); + + Expected global = scope.getGlobal("transform_corpus"); + if (!global || !global->isFunction()) + return {}; + return callTransform(lua::Function(std::move(*global))); +} + +} // (anon) + +Expected +runExtensions(CorpusImpl& corpus) +{ + MRDOCS_TRY(std::vector scripts, + collectExtensionScripts(corpus.config)); + for (std::string const& path : scripts) + { + MRDOCS_TRY(runOneExtension(corpus, path)); + } + return {}; +} + +} // mrdocs diff --git a/src/lib/Extensions/RunExtensions.hpp b/src/lib/Extensions/RunExtensions.hpp new file mode 100644 index 0000000000..fb988f62ea --- /dev/null +++ b/src/lib/Extensions/RunExtensions.hpp @@ -0,0 +1,45 @@ +// +// Licensed under the Apache License v2.0 with LLVM Exceptions. +// See https://llvm.org/LICENSE.txt for license information. +// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +// +// Copyright (c) 2026 Gennaro Prota (gennaro.prota@gmail.com) +// +// Official repository: https://github.com/cppalliance/mrdocs +// + +#ifndef MRDOCS_LIB_EXTENSIONS_RUNEXTENSIONS_HPP +#define MRDOCS_LIB_EXTENSIONS_RUNEXTENSIONS_HPP + +#include +#include + +namespace mrdocs { + +class CorpusImpl; + +/** Run user-provided extension scripts against the corpus. + + Extensions live in /extensions/.lua for each addon + root declared in the configuration (primary `addons` plus + `addons-supplemental`). Each script may export a function named + `transform_corpus(corpus)`; the function is invoked once with a flat + DOM view of the corpus that the script can read, and may mutate the + corpus by calling functions on the pre-registered `mrdocs` global + table: + + - `mrdocs.set_brief(symbol_id, text)` — replace a symbol's brief + with a single-paragraph plain-text block. Each setter validates + its arguments and raises an error on misuse. + + Any uncaught error inside a script aborts the build. Scripts are run + in alphabetical order by file path. Extensions intentionally fire + after all finalizers and before any generator runs, so mutations are + visible to every output format. +*/ +Expected +runExtensions(CorpusImpl& corpus); + +} // mrdocs + +#endif diff --git a/src/lib/Support/Lua.cpp b/src/lib/Support/Lua.cpp index 230df1549f..5b47c02aee 100644 --- a/src/lib/Support/Lua.cpp +++ b/src/lib/Support/Lua.cpp @@ -97,6 +97,13 @@ Context:: Context( Context const& other) noexcept = default; +void* +Context:: +nativeState() const noexcept +{ + return impl_->L; +} + void Scope:: reset() @@ -707,6 +714,15 @@ getGlobal( return A.construct(-1, *this); } +Value +Scope:: +pushDom(dom::Value const& value) +{ + Access A(*this); + domValue_push(A, value); + return A.construct(-1, *this); +} + //------------------------------------------------ // // Param diff --git a/test-files/golden-tests/extensions/lua-set-brief/addons/extensions/rebrief.lua b/test-files/golden-tests/extensions/lua-set-brief/addons/extensions/rebrief.lua new file mode 100644 index 0000000000..1c151571bb --- /dev/null +++ b/test-files/golden-tests/extensions/lua-set-brief/addons/extensions/rebrief.lua @@ -0,0 +1,24 @@ +-- Rewrite every function's brief from a Lua extension. +-- Verifies that: +-- 1. `transform_corpus` is discovered and invoked +-- 2. `corpus.symbols` contains the expected entries +-- 3. `mrdocs.set_brief` actually mutates the corpus and the change +-- is visible to downstream output (xml here). +-- +-- corpus.symbols is iterated by integer index. The wrapper's __index +-- metamethod returns nil past the end, so the loop terminates on nil. +-- (pairs/ipairs on the dom array userdata are not currently supported.) + +function transform_corpus(corpus) + local i = 0 + while true do + local sym = corpus.symbols[i] + if sym == nil then break end + if sym.kind == "function" then + mrdocs.set_brief( + sym.id, + "Rewritten by Lua extension: " .. sym.name) + end + i = i + 1 + end +end diff --git a/test-files/golden-tests/extensions/lua-set-brief/mrdocs.yml b/test-files/golden-tests/extensions/lua-set-brief/mrdocs.yml new file mode 100644 index 0000000000..cde6450530 --- /dev/null +++ b/test-files/golden-tests/extensions/lua-set-brief/mrdocs.yml @@ -0,0 +1,6 @@ +addons-supplemental: + - addons +generator: xml +multipage: false +warn-if-undocumented: false +source-root: . diff --git a/test-files/golden-tests/extensions/lua-set-brief/set_brief.adoc b/test-files/golden-tests/extensions/lua-set-brief/set_brief.adoc new file mode 100644 index 0000000000..3501ddb291 --- /dev/null +++ b/test-files/golden-tests/extensions/lua-set-brief/set_brief.adoc @@ -0,0 +1,32 @@ += Reference +:mrdocs: + +[#index] +== Global namespace + +=== Functions + +[cols="1,4"] +|=== +| Name| Description +| link:#target_function[`target_function`] +| Rewritten by Lua extension: target_function +|=== + +[#target_function] +== target_function + +Rewritten by Lua extension: target_function + +=== Synopsis + +Declared in `<set_brief.cpp>` + +[source,cpp,subs="verbatim,replacements,macros,-callouts"] +---- +void +target_function(); +---- + + +[.small]#Created with https://www.mrdocs.com[MrDocs]# diff --git a/test-files/golden-tests/extensions/lua-set-brief/set_brief.cpp b/test-files/golden-tests/extensions/lua-set-brief/set_brief.cpp new file mode 100644 index 0000000000..144930abed --- /dev/null +++ b/test-files/golden-tests/extensions/lua-set-brief/set_brief.cpp @@ -0,0 +1,2 @@ +/// Original brief from the source comment. +void target_function(); diff --git a/test-files/golden-tests/extensions/lua-set-brief/set_brief.html b/test-files/golden-tests/extensions/lua-set-brief/set_brief.html new file mode 100644 index 0000000000..abae865195 --- /dev/null +++ b/test-files/golden-tests/extensions/lua-set-brief/set_brief.html @@ -0,0 +1,52 @@ + + +Reference + + + +
+

Reference

+
+
+

+Global Namespace

+
+

+Functions

+ + + + + + + + + + +
NameDescription
target_function Rewritten by Lua extension: target_function
+ +
+
+
+

+target_function

+
+

Rewritten by Lua extension: target_function

+
+
+
+

+Synopsis

+
+Declared in <set_brief.cpp>
+
void
+target_function();
+
+
+ +
+ + + \ No newline at end of file diff --git a/test-files/golden-tests/extensions/lua-set-brief/set_brief.xml b/test-files/golden-tests/extensions/lua-set-brief/set_brief.xml new file mode 100644 index 0000000000..6cf7ac30f8 --- /dev/null +++ b/test-files/golden-tests/extensions/lua-set-brief/set_brief.xml @@ -0,0 +1,46 @@ + + + + + + namespace + //////////////////////////8= + regular + + HuxRZuBJaL6YnoJa2a7IFMcNqvo= + + + + target_function + + + set_brief.cpp + set_brief.cpp + 2 + 1 + 1 + + + function + HuxRZuBJaL6YnoJa2a7IFMcNqvo= + regular + //////////////////////////8= + + + brief + + text + Rewritten by Lua extension: target_function + + + + + + identifier + void + + + normal + + From f9a5cdfb15cd22e596df3a9f687a0fac34362d0d Mon Sep 17 00:00:00 2001 From: Gennaro Prota Date: Thu, 7 May 2026 17:16:44 +0200 Subject: [PATCH 4/5] feat: support JavaScript corpus-mutation extensions This mirrors the Lua corpus-mutation hook for JavaScript. Extension scripts under /extensions/*.js can now define `transform_corpus(corpus)` and call `mrdocs.set_brief(symbol_id, text)`, just like their Lua counterparts. The Lua and JS bindings now share a language-agnostic `setBriefImpl` helper that takes already-extracted `dom::Value` arguments. Each binding is a thin adapter: - Lua: the existing C closure registered on the raw `lua_State*`. - JS: a `dom::Function` exposed as a property of a `mrdocs` global object; the wrapper's `setGlobal` -> `toJsValue` -> `makeFunctionProxy` chain handles the rest. Discovery picks up both *.lua and *.js, sorted together so script ordering doesn't depend on the chosen language. A golden fixture mirrors the Lua test. --- src/lib/Extensions/RunExtensions.cpp | 214 ++++++++++++++---- src/lib/Extensions/RunExtensions.hpp | 16 +- .../js-set-brief/addons/extensions/rebrief.js | 18 ++ .../extensions/js-set-brief/mrdocs.yml | 6 + .../extensions/js-set-brief/set_brief.adoc | 32 +++ .../extensions/js-set-brief/set_brief.cpp | 2 + .../extensions/js-set-brief/set_brief.html | 52 +++++ .../extensions/js-set-brief/set_brief.xml | 46 ++++ 8 files changed, 330 insertions(+), 56 deletions(-) create mode 100644 test-files/golden-tests/extensions/js-set-brief/addons/extensions/rebrief.js create mode 100644 test-files/golden-tests/extensions/js-set-brief/mrdocs.yml create mode 100644 test-files/golden-tests/extensions/js-set-brief/set_brief.adoc create mode 100644 test-files/golden-tests/extensions/js-set-brief/set_brief.cpp create mode 100644 test-files/golden-tests/extensions/js-set-brief/set_brief.html create mode 100644 test-files/golden-tests/extensions/js-set-brief/set_brief.xml diff --git a/src/lib/Extensions/RunExtensions.cpp b/src/lib/Extensions/RunExtensions.cpp index 7db4b4472e..d5402ba338 100644 --- a/src/lib/Extensions/RunExtensions.cpp +++ b/src/lib/Extensions/RunExtensions.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include @@ -39,12 +40,14 @@ namespace mrdocs { namespace { // ===================================================================== -// State carried into Lua C-callbacks +// Per-script state // ===================================================================== -// Shared state passed to every `mrdocs.*` callback as a Lua upvalue. -// Holds the live corpus and a string -> Symbol* index that avoids -// re-parsing the SymbolID encoding on every setter call. +// Shared state threaded through every `mrdocs.*` callback. Each binding +// captures it differently (Lua via a light-userdata upvalue, JS via a +// raw pointer captured in a `dom::Function` lambda). Holds the live +// corpus and a string -> `Symbol*` index that avoids re-decoding the +// `SymbolID` encoding on every setter call. struct ExtensionState { CorpusImpl* corpus = nullptr; @@ -73,9 +76,10 @@ addonRoots(Config const& config) return roots; } -// Collect /extensions/*.lua across every addon root, sorted -// alphabetically by full path so script ordering is stable across -// platforms and filesystems. +// Collect /extensions/*.{lua,js} across every addon root, +// sorted alphabetically by full path. Sorting interleaves the two +// languages so behaviour doesn't depend on which extension a user +// happens to write in; only on file names. Expected> collectExtensionScripts(Config const& config) { @@ -90,9 +94,11 @@ collectExtensionScripts(Config const& config) Expected exp = forEachFile(dir, true, [&](std::string_view pathName) -> Expected { - constexpr std::string_view ext = ".lua"; - if (pathName.ends_with(ext)) + if (pathName.ends_with(".lua") || + pathName.ends_with(".js")) + { scripts.emplace_back(pathName); + } return {}; }); if (!exp) @@ -166,13 +172,63 @@ buildCorpusDom(CorpusImpl& corpus, ExtensionState& state) } // ===================================================================== -// Mutations: the `mrdocs` global table +// Mutations // ===================================================================== // -// We register C closures directly on the raw lua_State* (via the -// Context::nativeState() escape hatch) because the wrapper does not yet -// abstract "set a global to a native function with carried state." -// Each closure carries the ExtensionState pointer as its single upvalue. +// The setter implementations below are language-agnostic: they accept +// already-extracted `dom::Value` arguments and report errors via +// `Error`. Each language binding (Lua / JS) is a thin adapter that +// pulls the arguments out of its native ABI and forwards to these +// helpers. + +// `mrdocs.set_brief(symbol_id, text)`: replace a symbol's brief with +// a single-paragraph plain-text block. Validates that both arguments +// are strings and that the id resolves to a symbol in the corpus. +Expected +setBriefImpl( + ExtensionState& state, + dom::Value const& idArg, + dom::Value const& textArg) +{ + if (!idArg.isString()) + return Unexpected(Error( + "mrdocs.set_brief: argument 1 (symbol id) must be a string")); + if (!textArg.isString()) + return Unexpected(Error( + "mrdocs.set_brief: argument 2 (text) must be a string")); + + std::string_view const idView = idArg.getString(); + std::string_view const textView = textArg.getString(); + + auto it = state.byId.find(std::string(idView)); + if (it == state.byId.end()) + return Unexpected(formatError( + "mrdocs.set_brief: unknown symbol id '{}'", idView)); + + Symbol* sym = it->second; + MRDOCS_ASSERT(sym != nullptr); + + if (!sym->doc.has_value()) + sym->doc.emplace(); + + doc::BriefBlock brief; + brief.children.push_back(Polymorphic( + std::in_place_type, + textView)); + sym->doc->brief.emplace(std::move(brief)); + + return dom::Value(); +} + +// ===================================================================== +// Lua bindings +// ===================================================================== +// +// We register C closures directly on the raw `lua_State*` (via the +// `Context::nativeState()` escape hatch) because the wrapper does not +// yet abstract "set a global to a native function with carried state." +// Each closure carries the `ExtensionState` pointer as its single +// upvalue. ExtensionState& upvalueState(lua_State* L) @@ -183,55 +239,39 @@ upvalueState(lua_State* L) return *p; } -// `mrdocs.set_brief(symbol_id, text)` -// -// Replaces the symbol's brief with a single-paragraph plain-text block. -// Validation: -// - both arguments must be strings -// - the id must resolve to a Symbol in the corpus -// On failure the script aborts via `luaL_error`; the host turns that -// into an `Unexpected` when `lua_pcall` returns non-OK. +// Lua adapter for `setBriefImpl`. On failure the script aborts via +// `luaL_error`; the host turns that into an `Unexpected` when +// `lua_pcall` returns non-OK. int luaSetBrief(lua_State* L) { ExtensionState& state = upvalueState(L); - if (lua_type(L, 1) != LUA_TSTRING) - return luaL_error(L, - "mrdocs.set_brief: argument 1 (symbol id) must be a string"); - if (lua_type(L, 2) != LUA_TSTRING) + if (lua_type(L, 1) != LUA_TSTRING || + lua_type(L, 2) != LUA_TSTRING) + { return luaL_error(L, - "mrdocs.set_brief: argument 2 (text) must be a string"); + "mrdocs.set_brief: expected (string symbol_id, string text)"); + } std::size_t idLen = 0; char const* idData = lua_tolstring(L, 1, &idLen); std::size_t textLen = 0; char const* textData = lua_tolstring(L, 2, &textLen); - std::string idKey(idData, idLen); - auto it = state.byId.find(idKey); - if (it == state.byId.end()) - return luaL_error(L, - "mrdocs.set_brief: unknown symbol id '%s'", idKey.c_str()); - - Symbol* sym = it->second; - MRDOCS_ASSERT(sym != nullptr); - - if (!sym->doc.has_value()) - sym->doc.emplace(); - - doc::BriefBlock brief; - brief.children.push_back(Polymorphic( - std::in_place_type, - std::string_view(textData, textLen))); - sym->doc->brief.emplace(std::move(brief)); + Expected result = setBriefImpl( + state, + dom::Value(std::string(idData, idLen)), + dom::Value(std::string(textData, textLen))); + if (!result) + return luaL_error(L, "%s", result.error().message().c_str()); return 0; } // Build the `mrdocs` global table and populate it with the setters. void -registerMrDocsApi(lua_State* L, ExtensionState& state) +registerLuaMrDocsApi(lua_State* L, ExtensionState& state) { lua_newtable(L); @@ -242,22 +282,51 @@ registerMrDocsApi(lua_State* L, ExtensionState& state) lua_setglobal(L, "mrdocs"); } +// ===================================================================== +// JavaScript bindings +// ===================================================================== +// +// The JS wrapper already knows how to expose a `dom::Function` as a +// callable JS value (`setGlobal` -> `toJsValue` -> `makeFunctionProxy`), +// so no escape hatch is needed: we just build the `mrdocs` API as a +// `dom::Object` containing `dom::Function` entries and set it as a +// global. + +dom::Object +buildJsMrDocsApi(ExtensionState& state) +{ + // `ExtensionState` is a stack local in `runOneJsExtension`; capturing + // by raw pointer here is safe because the API object, the script + // execution, and the state all live within the same call frame. + ExtensionState* statePtr = &state; + dom::Object api; + api.set("set_brief", dom::Value(dom::makeVariadicInvocable( + [statePtr](dom::Array const& args) -> Expected + { + if (args.size() < 2) + return Unexpected(Error( + "mrdocs.set_brief: expected (symbol_id, text)")); + return setBriefImpl(*statePtr, args.get(0), args.get(1)); + }))); + return api; +} + // ===================================================================== // Per-script execution // ===================================================================== Expected -runOneExtension(CorpusImpl& corpus, std::string const& scriptPath) +runOneLuaExtension(CorpusImpl& corpus, std::string const& scriptPath) { lua::Context ctx; ExtensionState state{ &corpus, {} }; - // Build the corpus DOM and the id -> Symbol* map in one pass. + // Build the corpus DOM and the `id` -> `Symbol*` map in one pass. dom::Value corpusValue = buildCorpusDom(corpus, state); // Register the `mrdocs` global before loading the script so utility // code at chunk top-level can reference it if it wants to. - registerMrDocsApi( + registerLuaMrDocsApi( static_cast(ctx.nativeState()), state); // Load the chunk and execute it (defines globals, including @@ -274,7 +343,7 @@ runOneExtension(CorpusImpl& corpus, std::string const& scriptPath) // Resolve `transform_corpus`. Prefer the chunk's return value (the // `return function(...) ... end` idiom); fall back to a same-named // global (the `function name(...)` idiom). If neither yields a - // function, the extension has nothing to do — silently skip (an + // function, the extension has nothing to do - silently skip (an // empty extension is valid). // // We can't pre-declare a `lua::Value` and assign into it because @@ -300,6 +369,53 @@ runOneExtension(CorpusImpl& corpus, std::string const& scriptPath) return callTransform(lua::Function(std::move(*global))); } +Expected +runOneJsExtension(CorpusImpl& corpus, std::string const& scriptPath) +{ + js::Context ctx; + ExtensionState state{ &corpus, {} }; + + dom::Value corpusValue = buildCorpusDom(corpus, state); + + js::Scope scope(ctx); + + // Expose `mrdocs.set_brief(...)` etc. as a global object whose + // entries are `dom::Function`s; the JS wrapper turns these into + // callable proxies via `makeFunctionProxy`. + scope.setGlobal("mrdocs", dom::Value(buildJsMrDocsApi(state))); + + // Run the script (defines globals, including `transform_corpus`). + MRDOCS_TRY(std::string script, files::getFileText(scriptPath)); + if (Expected exp = scope.script(script); !exp) + return Unexpected(formatError( + "extension '{}': {}", + scriptPath, exp.error().message())); + + Expected fn = scope.getGlobal("transform_corpus"); + if (!fn || !fn->isFunction()) + return {}; + + Expected result = fn->call(corpusValue); + if (!result) + return Unexpected(formatError( + "extension '{}': {}", + scriptPath, result.error().message())); + return {}; +} + +Expected +runOneExtension(CorpusImpl& corpus, std::string const& scriptPath) +{ + if (scriptPath.ends_with(".lua")) + return runOneLuaExtension(corpus, scriptPath); + if (scriptPath.ends_with(".js")) + return runOneJsExtension(corpus, scriptPath); + // collectExtensionScripts only emits .lua / .js paths, so reaching + // here would mean an internal mismatch. + return Unexpected(formatError( + "extension '{}': unsupported file extension", scriptPath)); +} + } // (anon) Expected diff --git a/src/lib/Extensions/RunExtensions.hpp b/src/lib/Extensions/RunExtensions.hpp index fb988f62ea..b97315e747 100644 --- a/src/lib/Extensions/RunExtensions.hpp +++ b/src/lib/Extensions/RunExtensions.hpp @@ -20,22 +20,24 @@ class CorpusImpl; /** Run user-provided extension scripts against the corpus. - Extensions live in /extensions/.lua for each addon - root declared in the configuration (primary `addons` plus + Extensions live in /extensions/.{lua,js} for each + addon root declared in the configuration (primary `addons` plus `addons-supplemental`). Each script may export a function named `transform_corpus(corpus)`; the function is invoked once with a flat DOM view of the corpus that the script can read, and may mutate the corpus by calling functions on the pre-registered `mrdocs` global - table: + table or object: - - `mrdocs.set_brief(symbol_id, text)` — replace a symbol's brief + - `mrdocs.set_brief(symbol_id, text)` - replace a symbol's brief with a single-paragraph plain-text block. Each setter validates its arguments and raises an error on misuse. Any uncaught error inside a script aborts the build. Scripts are run - in alphabetical order by file path. Extensions intentionally fire - after all finalizers and before any generator runs, so mutations are - visible to every output format. + in alphabetical order by file path, with the two languages + interleaved so behaviour doesn't depend on which language a user + chose. Extensions intentionally fire after all finalizers and + before any generator runs, so mutations are visible to every + output format. */ Expected runExtensions(CorpusImpl& corpus); diff --git a/test-files/golden-tests/extensions/js-set-brief/addons/extensions/rebrief.js b/test-files/golden-tests/extensions/js-set-brief/addons/extensions/rebrief.js new file mode 100644 index 0000000000..3ca7db3739 --- /dev/null +++ b/test-files/golden-tests/extensions/js-set-brief/addons/extensions/rebrief.js @@ -0,0 +1,18 @@ +// Rewrite every function's brief from a JavaScript extension. +// Mirrors the lua-set-brief fixture but exercises the JS path: the +// `mrdocs` global is exposed as a JavaScript object whose `set_brief` +// entry is a native function backed by a dom::Function in C++. + +function transform_corpus(corpus) +{ + for (var i = 0; i < corpus.symbols.length; ++i) + { + var sym = corpus.symbols[i]; + if (sym.kind === "function") + { + mrdocs.set_brief( + sym.id, + "Rewritten by JS extension: " + sym.name); + } + } +} diff --git a/test-files/golden-tests/extensions/js-set-brief/mrdocs.yml b/test-files/golden-tests/extensions/js-set-brief/mrdocs.yml new file mode 100644 index 0000000000..cde6450530 --- /dev/null +++ b/test-files/golden-tests/extensions/js-set-brief/mrdocs.yml @@ -0,0 +1,6 @@ +addons-supplemental: + - addons +generator: xml +multipage: false +warn-if-undocumented: false +source-root: . diff --git a/test-files/golden-tests/extensions/js-set-brief/set_brief.adoc b/test-files/golden-tests/extensions/js-set-brief/set_brief.adoc new file mode 100644 index 0000000000..226a15bb9e --- /dev/null +++ b/test-files/golden-tests/extensions/js-set-brief/set_brief.adoc @@ -0,0 +1,32 @@ += Reference +:mrdocs: + +[#index] +== Global namespace + +=== Functions + +[cols="1,4"] +|=== +| Name| Description +| link:#target_function[`target_function`] +| Rewritten by JS extension: target_function +|=== + +[#target_function] +== target_function + +Rewritten by JS extension: target_function + +=== Synopsis + +Declared in `<set_brief.cpp>` + +[source,cpp,subs="verbatim,replacements,macros,-callouts"] +---- +void +target_function(); +---- + + +[.small]#Created with https://www.mrdocs.com[MrDocs]# diff --git a/test-files/golden-tests/extensions/js-set-brief/set_brief.cpp b/test-files/golden-tests/extensions/js-set-brief/set_brief.cpp new file mode 100644 index 0000000000..144930abed --- /dev/null +++ b/test-files/golden-tests/extensions/js-set-brief/set_brief.cpp @@ -0,0 +1,2 @@ +/// Original brief from the source comment. +void target_function(); diff --git a/test-files/golden-tests/extensions/js-set-brief/set_brief.html b/test-files/golden-tests/extensions/js-set-brief/set_brief.html new file mode 100644 index 0000000000..2c88bf467d --- /dev/null +++ b/test-files/golden-tests/extensions/js-set-brief/set_brief.html @@ -0,0 +1,52 @@ + + +Reference + + + +
+

Reference

+
+
+

+Global Namespace

+
+

+Functions

+ + + + + + + + + + +
NameDescription
target_function Rewritten by JS extension: target_function
+ +
+
+
+

+target_function

+
+

Rewritten by JS extension: target_function

+
+
+
+

+Synopsis

+
+Declared in <set_brief.cpp>
+
void
+target_function();
+
+
+ +
+ + + \ No newline at end of file diff --git a/test-files/golden-tests/extensions/js-set-brief/set_brief.xml b/test-files/golden-tests/extensions/js-set-brief/set_brief.xml new file mode 100644 index 0000000000..9363631a05 --- /dev/null +++ b/test-files/golden-tests/extensions/js-set-brief/set_brief.xml @@ -0,0 +1,46 @@ + + + + + + namespace + //////////////////////////8= + regular + + HuxRZuBJaL6YnoJa2a7IFMcNqvo= + + + + target_function + + + set_brief.cpp + set_brief.cpp + 2 + 1 + 1 + + + function + HuxRZuBJaL6YnoJa2a7IFMcNqvo= + regular + //////////////////////////8= + + + brief + + text + Rewritten by JS extension: target_function + + + + + + identifier + void + + + normal + + From 2a20121991d86d4ca2baf5798cf253231f6f23a5 Mon Sep 17 00:00:00 2001 From: Gennaro Prota Date: Thu, 7 May 2026 17:55:41 +0200 Subject: [PATCH 5/5] docs: document the scripting possibilities (helpers/extensions) --- docs/modules/ROOT/nav.adoc | 1 + docs/modules/ROOT/pages/extensions.adoc | 121 ++++++++++++++++++++++++ docs/modules/ROOT/pages/generators.adoc | 50 ++++++++++ 3 files changed, 172 insertions(+) create mode 100644 docs/modules/ROOT/pages/extensions.adoc diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index 5b459928ba..73cf0d0f7d 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -5,6 +5,7 @@ * xref:config-file.adoc[] * xref:commands.adoc[Documenting the Code] * xref:generators.adoc[] +* xref:extensions.adoc[] * xref:design-notes.adoc[] * xref:reference:index.adoc[Library Reference] * Contribute diff --git a/docs/modules/ROOT/pages/extensions.adoc b/docs/modules/ROOT/pages/extensions.adoc new file mode 100644 index 0000000000..c3499f30b2 --- /dev/null +++ b/docs/modules/ROOT/pages/extensions.adoc @@ -0,0 +1,121 @@ += Extensions + +Extensions let you transform the corpus of extracted symbols before any generator runs. +A typical use case is rewriting metadata across many symbols at once: backfilling briefs from a naming convention, tagging symbols by group, or marking generated code as "see below" in the output. + +Extensions are user-supplied scripts written in JavaScript or Lua. +They run between extraction (turning C++ source into a corpus of symbols) and rendering (turning the corpus into output files), so any change they make is visible to every generator. + +== File layout + +Drop a script into the extensions/ subdirectory of an addon root and Mr.Docs picks it up automatically: + +[source,text] +---- +/extensions/*.js +/extensions/*.lua +---- + +The same `addons` and `addons-supplemental` configuration options that govern templates apply here too: scripts under every existing addon root are discovered. + +Scripts are loaded in alphabetical order by full path, with the two languages interleaved. +This means renaming a script changes the order in which it runs, but switching the language does not. + +== The `transform_corpus` hook + +A script extends Mr.Docs by exposing a function named `transform_corpus(corpus)`. +Mr.Docs calls it once with a flat read-only view of the corpus. +The script inspects symbols and calls mutation functions on the pre-registered `mrdocs` object to apply changes. + +A script that does not define `transform_corpus` is silently ignored, so an extension file can be empty during development without breaking the build. + +[source,javascript] +---- +// /extensions/rebrief.js +function transform_corpus(corpus) { + for (var i = 0; i < corpus.symbols.length; ++i) { + var sym = corpus.symbols[i]; + if (sym.kind === "function") { + mrdocs.set_brief(sym.id, "Rewritten: " + sym.name); + } + } +} +---- + +[source,lua] +---- +-- /extensions/rebrief.lua +function transform_corpus(corpus) + local i = 0 + while true do + local sym = corpus.symbols[i] + if sym == nil then break end + if sym.kind == "function" then + mrdocs.set_brief( + sym.id, + "Rewritten: " .. sym.name) + end + i = i + 1 + end +end +---- + +== The `corpus` argument + +The `corpus` argument is a read-only object with a single field, `symbols`, which is an array of every symbol Mr.Docs extracted. +Each entry in `corpus.symbols` exposes the following fields: + +|=== +|Field |Type |Description + +|`id` +|string +|Stable opaque identifier. Pass this back to a mutation function to identify which symbol to act on. + +|`kind` +|string +|The symbol kind, in lowercase kebab-case. For example: `function`, `record`, `namespace`, `enum`, `enum-constant`, `namespace-alias`. + +|`name` +|string +|The unqualified symbol name. Empty for the global namespace. + +|`qualifiedName` +|string +|The fully qualified name (for example, `std::vector::size`). + +|`brief` +|string +|The current brief as plain text, or the empty string if the symbol has no brief. +|=== + +In JavaScript, iterate with `corpus.symbols.length` and `corpus.symbols[i]`, or with `for (var s of corpus.symbols)`. + +In Lua, iterate by integer index starting at `0`; `corpus.symbols[i]` returns `nil` once you go past the last symbol, so a simple `while` loop terminates correctly. + +== The `mrdocs` API + +Mutations go through the pre-registered `mrdocs` global. +The current set is intentionally narrow; new entries land as concrete use cases surface. + +=== `mrdocs.set_brief(symbol_id, text)` + +Replace the symbol's brief with a single-paragraph plain-text block. + +* `symbol_id`: the `id` value read from a symbol in `corpus.symbols`. +* `text`: the new brief text. The argument is treated as plain text; no markdown parsing is performed. + +Each setter validates its arguments and raises an error on misuse. +An uncaught error inside an extension aborts the build with the script's path and the error message. + +== Lifecycle + +Extensions run between corpus finalization and the first generator invocation. +The order is: + +. Mr.Docs walks the source files and extracts a corpus of symbols. +. Built-in finalizers post-process the corpus (for example, sorting members and resolving inheritance). +. Extensions run, in alphabetical order by full path. +. The selected generator renders the (possibly mutated) corpus. + +Because step 3 happens before step 4, an extension that mutates a symbol's brief is visible to every output format, not just one. diff --git a/docs/modules/ROOT/pages/generators.adoc b/docs/modules/ROOT/pages/generators.adoc index d3c0f92e90..60b98e4f93 100644 --- a/docs/modules/ROOT/pages/generators.adoc +++ b/docs/modules/ROOT/pages/generators.adoc @@ -111,6 +111,56 @@ The layout template can include other partial templates to render the symbol dat The Document Object Model (DOM) for each symbol includes all information about the symbol.One advantage of custom templates over post-processing XML files is the ability to access symbols as a graph.If symbol `A` refers to symbol `B`, some properties of symbol `B` are likely to be required in the documentation of `A`.All templates and generators can access a reference to `B` by searching the symbol tree or simply by accessing the elements `A` refers to.All references to other symbols are resolved in the templates. +[#custom-helpers] +=== Custom Helpers + +Beyond the built-in helpers, an addon can register its own Handlebars helpers in JavaScript or Lua. +Drop a script alongside your partials and Mr.Docs picks it up automatically: + +* `/generator/common/helpers/*.{js,lua}`: helpers available to every output format. +* `/generator//helpers/*.{js,lua}`: helpers available only to the matching format (`html`, `adoc`, ...). A format-specific helper overrides a common one with the same name. + +The file's stem (with the `.js` or `.lua` extension stripped) becomes the helper name. +Templates invoke it the same way they invoke a built-in helper: + +[source,handlebars] +---- +{{my_helper symbol.name}} +---- + +==== Helper resolution + +Each script is expected to expose a single function, the helper: + +* Return the function from the chunk (recommended): ++ +[source,javascript] +---- +return function(name) { + return "[" + name + "]"; +}; +---- ++ +[source,lua] +---- +return function(name) + return "[" .. name .. "]" +end +---- + +* Or define a global with the same stem as the file (for example, `my_helper.lua` defining `function my_helper(name) ... end`). + +==== Utility files + +A file whose stem starts with an underscore (for example, `_utils.js`) is loaded first and is *not* registered as a helper. +Use these files to define globals that several helpers share, so a single utility script can set up state once instead of every helper duplicating it. + +==== Arguments and the Handlebars options object + +Mr.Docs strips Handlebars' trailing options object before forwarding arguments to the helper. +Helpers receive the positional arguments only and don't have to filter the options out themselves. +This also avoids expensive marshalling of symbol contexts, which contain circular references. + == Stylesheet Options The HTML and AsciiDoc generators ship a bundled stylesheet that is inlined by default. You can replace or layer styles with the following options (available in config files and on the CLI):