diff --git a/docs/modules/ROOT/pages/generators.adoc b/docs/modules/ROOT/pages/generators.adoc index 5b1a5860ad..731a3177ff 100644 --- a/docs/modules/ROOT/pages/generators.adoc +++ b/docs/modules/ROOT/pages/generators.adoc @@ -200,6 +200,76 @@ Unknown top-level keys are silently ignored so future schema additions stay non- If the same id appears under more than one addon root, the first one wins: that root's manifest sets the format's escape rules. Later roots can still contribute layered partials and helpers under the same id through the existing template-loading path, so a project can supplement a shared format without redefining it. +To add a generator that builds its output structure imperatively, rather than rendering one page per symbol from templates, see <>. + +[#script-driven-generators] +== Script-driven generators + +A data-driven generator renders one page per symbol from templates. When you need a different output structure - one file per namespace, or a single artifact aggregated across every symbol, such as a search index - a template generator cannot express it, because the page-per-symbol shape is fixed by the host. A script-driven generator hands the whole emit to a Lua or JavaScript script, which traverses the corpus and writes whatever files it wants. No C++ and no templates are involved. + +A generator directory is script-driven when its `mrdocs-generator.yml` names an entry script: + +[source,yaml] +---- +script: generator.lua +---- + +The `script` key holds a path to a Lua (`.lua`) or JavaScript (`.js`) file, relative to the generator directory. Naming a script is what distinguishes the two flavors: a manifest with a `script` key is script-driven, otherwise the directory is a data-driven (template) generator. As with template generators, the directory name is the generator id you select with `--generator`. + +=== The `generate` entry point + +The script defines a single entry point: + +[source] +---- +generate(corpus, output) +---- + +`corpus.symbols` is the array of every symbol. Each symbol carries the same fields the template and helper layers see, plus a flat `_id` string suitable as a stable per-symbol URL fragment. + +`output.write(relativePath, contents)` writes one file. The path is resolved under the output directory and may not escape it; an absolute path or one that climbs above the output directory is rejected. Parent directories are created as needed. + +Because the script owns the output, it also owns what a per-page generator would otherwise do for it: the URLs it emits, and any escaping of the content it writes. The host does not apply an escape map to a script-driven generator's output. + +In Lua, `generate` may be the value the script returns or a global function; in JavaScript it is a global function: + +[source,lua] +---- +return function(corpus, output) + -- ... +end +---- + +Unlike a corpus-transform extension, whose hook is optional, a generator must define a `generate` function: selecting the generator is a request for output, so a missing entry point is an error. + +=== Example: a search index + +This generator emits a single search-index.json aggregating every symbol, an artifact no per-page generator can produce: + +[source,lua] +---- +-- Quote a string as a JSON value. +local function json_string(s) + s = s:gsub('\\', '\\\\'):gsub('"', '\\"') + return '"' .. s .. '"' +end + +return function(corpus, output) + local entries = {} + for _, sym in ipairs(corpus.symbols) do + local name = sym.name or "" + if name ~= "" then + entries[#entries + 1] = + '{"name":' .. json_string(name) .. + ',"url":' .. json_string(sym._id .. ".html") .. "}" + end + end + output.write( + "search-index.json", + "[" .. table.concat(entries, ",") .. "]") +end +---- + == 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): diff --git a/docs/mrdocs.schema.json b/docs/mrdocs.schema.json index e72060fe0b..2e0bf61aa1 100644 --- a/docs/mrdocs.schema.json +++ b/docs/mrdocs.schema.json @@ -252,7 +252,7 @@ }, "generator": { "default": "adoc", - "description": "The generator is responsible for creating the documentation from the extracted symbols. The generator uses the extracted symbols and the templates to create the documentation. The built-in generators include `adoc`, `html`, and `xml`; data-driven generators can be added by dropping a template folder under /generator//.", + "description": "The generator is responsible for creating the documentation from the extracted symbols. The generator uses the extracted symbols and the templates to create the documentation. The built-in generators include `adoc`, `html`, and `xml`; data-driven generators can be added by dropping a template folder under /generator//; script-driven generators instead ship a Lua or JavaScript script that produces the output.", "title": "Generator used to create the documentation", "type": "string" }, diff --git a/src/lib/ConfigOptions.json b/src/lib/ConfigOptions.json index c441b29a5a..efd2ce3c9f 100644 --- a/src/lib/ConfigOptions.json +++ b/src/lib/ConfigOptions.json @@ -397,7 +397,7 @@ { "name": "generator", "brief": "Generator used to create the documentation", - "details": "The generator is responsible for creating the documentation from the extracted symbols. The generator uses the extracted symbols and the templates to create the documentation. The built-in generators include `adoc`, `html`, and `xml`; data-driven generators can be added by dropping a template folder under /generator//.", + "details": "The generator is responsible for creating the documentation from the extracted symbols. The generator uses the extracted symbols and the templates to create the documentation. The built-in generators include `adoc`, `html`, and `xml`; data-driven generators can be added by dropping a template folder under /generator//; script-driven generators instead ship a Lua or JavaScript script that produces the output.", "type": "string", "default": "adoc" }, diff --git a/src/lib/Gen/GeneratorManifest.cpp b/src/lib/Gen/GeneratorManifest.cpp new file mode 100644 index 0000000000..47ba7e917c --- /dev/null +++ b/src/lib/Gen/GeneratorManifest.cpp @@ -0,0 +1,195 @@ +// +// 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 "GeneratorManifest.hpp" +#include +#include +#include +#include +#include +#include + +namespace mrdocs { + +namespace { + +// Read a scalar node into an owned string. +std::string +scalarText(llvm::yaml::ScalarNode& node) +{ + llvm::SmallString<32> buf; + llvm::StringRef const text = node.getValue(buf); + return std::string(text.data(), text.size()); +} + +// Parse a YAML mapping whose entries are non-empty byte-sequence keys +// mapped to replacement strings. An empty key is a hard error. +Expected +parseEscape( + llvm::yaml::MappingNode& node, + GeneratorManifest& manifest, + std::string_view yamlPath) +{ + for (llvm::yaml::KeyValueNode& entry : node) + { + llvm::yaml::ScalarNode* const keyNode = + llvm::dyn_cast_or_null(entry.getKey()); + llvm::yaml::ScalarNode* const valNode = + llvm::dyn_cast_or_null(entry.getValue()); + if (!keyNode || !valNode) + { + return Unexpected(formatError( + "{}: each 'escape' entry must be a scalar->scalar mapping", + yamlPath)); + } + std::string key = scalarText(*keyNode); + if (key.empty()) + { + return Unexpected(formatError( + "{}: escape key must not be empty", yamlPath)); + } + manifest.escape.emplace_back( + std::move(key), scalarText(*valNode)); + } + return {}; +} + +// Dispatch a single top-level manifest key to its handler. Unknown keys +// are ignored so future schema additions stay non-breaking. +Expected +parseTopLevelEntry( + llvm::yaml::KeyValueNode& pair, + GeneratorManifest& manifest, + std::string_view yamlPath) +{ + llvm::yaml::ScalarNode* const keyNode = + llvm::dyn_cast_or_null(pair.getKey()); + if (!keyNode) + { + return {}; + } + llvm::SmallString<16> keyBuf; + llvm::StringRef const key = keyNode->getValue(keyBuf); + if (key == "escape") + { + llvm::yaml::MappingNode* const escNode = + llvm::dyn_cast_or_null(pair.getValue()); + if (!escNode) + { + return Unexpected(formatError( + "{}: 'escape' must be a mapping", yamlPath)); + } + return parseEscape(*escNode, manifest, yamlPath); + } + if (key == "script") + { + llvm::yaml::ScalarNode* const valNode = + llvm::dyn_cast_or_null(pair.getValue()); + if (!valNode) + { + return Unexpected(formatError( + "{}: 'script' must be a scalar", yamlPath)); + } + manifest.script = scalarText(*valNode); + } + return {}; +} + +} // (anon) + +Expected +loadGeneratorManifest(std::string_view yamlPath) +{ + MRDOCS_TRY(std::string text, files::getFileText(yamlPath)); + llvm::SourceMgr sm; + llvm::yaml::Stream stream(text, sm); + + GeneratorManifest manifest; + llvm::yaml::document_iterator docIt = stream.begin(); + if (docIt == stream.end()) + { + return manifest; + } + llvm::yaml::Node* const rootNode = docIt->getRoot(); + if (rootNode == nullptr || + llvm::isa(rootNode)) + { + // Empty document: a file with no content, only comments, or a + // literal `null`. All of these mean "no rules". + return manifest; + } + llvm::yaml::MappingNode* const root = + llvm::dyn_cast(rootNode); + if (!root) + { + return Unexpected(formatError( + "{}: top-level YAML node must be a mapping", yamlPath)); + } + for (llvm::yaml::KeyValueNode& pair : *root) + { + MRDOCS_TRY(parseTopLevelEntry(pair, manifest, yamlPath)); + } + return manifest; +} + +namespace { + +constexpr std::string_view metadataFileName = "mrdocs-generator.yml"; + +// Append every manifested subdirectory of `generatorDir` to `out`. +Expected +scanGeneratorDir( + std::string_view generatorDir, + std::vector& out) +{ + namespace fs = std::filesystem; + std::error_code iterEc; + fs::directory_iterator const end{}; + for (fs::directory_iterator it(generatorDir, iterEc); + !iterEc && it != end; + it.increment(iterEc)) + { + std::error_code typeEc; + if (!it->is_directory(typeEc)) + { + continue; + } + std::string const dir = it->path().string(); + std::string const yamlPath = files::appendPath( + dir, std::string(metadataFileName)); + if (!files::exists(yamlPath)) + { + continue; + } + MRDOCS_TRY(GeneratorManifest manifest, loadGeneratorManifest(yamlPath)); + out.push_back(DiscoveredManifest{ dir, std::move(manifest) }); + } + return {}; +} + +} // (anon) + +Expected> +discoverGeneratorManifests(std::vector const& roots) +{ + std::vector out; + for (std::string const& root : roots) + { + std::string const dir = files::appendPath(root, "generator"); + if (!files::exists(dir)) + { + continue; + } + MRDOCS_TRY(scanGeneratorDir(dir, out)); + } + return out; +} + +} // mrdocs diff --git a/src/lib/Gen/GeneratorManifest.hpp b/src/lib/Gen/GeneratorManifest.hpp new file mode 100644 index 0000000000..fd3cd8477d --- /dev/null +++ b/src/lib/Gen/GeneratorManifest.hpp @@ -0,0 +1,104 @@ +// +// 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_GEN_GENERATORMANIFEST_HPP +#define MRDOCS_LIB_GEN_GENERATORMANIFEST_HPP + +#include +#include +#include +#include +#include +#include +#include + +namespace mrdocs { + +/** The parsed contents of a generator manifest. + + A manifest is the `mrdocs-generator.yml` that an addon directory + under /generator// ships to declare a generator. The two + generator flavors read disjoint fields of the same file: + + @li A data-driven (Handlebars) generator reads the escape rules. + + @li A script-driven generator reads the entry-file path. + + The presence of the `script` entry is what distinguishes the two: a + manifest that names a `script` is a script-driven generator, + otherwise it is data-driven. +*/ +struct GeneratorManifest +{ + /** The entry file of a script-driven generator. + + Holds the value of the manifest's optional `script` key, a path + relative to the generator directory. Empty when the manifest + declares no `script`, in which case the directory is a + data-driven generator. + */ + std::optional script; + + /** The escape rules of a data-driven generator. + + Each pair maps a byte-sequence source to its replacement string, + in manifest order. Empty when no escape rules are declared. + */ + std::vector> escape; +}; + +/** Parse a generator manifest into plain data. + + Read the file at `yamlPath` and return its contents. The file is + expected to contain a top-level mapping. The optional `escape` key + holds a sub-mapping from byte-sequence keys to replacement strings; + keys may be one or more bytes long, and an empty key is a hard error. + The optional `script` key holds the entry-file path as a scalar. + Unknown top-level keys are ignored so future schema additions are + non-breaking. + + An empty document (an empty file, comments only, or a literal `null`) + yields an empty manifest. +*/ +Expected +loadGeneratorManifest(std::string_view yamlPath); + +/** A generator directory paired with its parsed manifest. +*/ +struct DiscoveredManifest +{ + /** The generator directory, of the form /generator/. + */ + std::string dir; + + /** The parsed contents of the directory's manifest. + */ + GeneratorManifest manifest; +}; + +/** Find every addon generator directory that ships a manifest. + + For each addon root, walk the immediate subdirectories of + /generator/. A subdirectory is reported when it ships an + `mrdocs-generator.yml`; the manifest is parsed and returned alongside + its directory. Directories without a manifest (the built-in shared + common/ is the canonical example) are skipped. + + The presence of a `script` entry distinguishes the two generator + flavors, so a caller installs the flavor it owns and ignores the + other. Roots are searched in order, so the result preserves addon + precedence. +*/ +Expected> +discoverGeneratorManifests(std::vector const& roots); + +} // mrdocs + +#endif diff --git a/src/lib/Gen/hbs/DataDrivenGenerators.cpp b/src/lib/Gen/hbs/DataDrivenGenerators.cpp index 9dc20f2078..e0002bb9e8 100644 --- a/src/lib/Gen/hbs/DataDrivenGenerators.cpp +++ b/src/lib/Gen/hbs/DataDrivenGenerators.cpp @@ -11,116 +11,30 @@ #include "DataDrivenGenerators.hpp" #include "AddonPaths.hpp" #include "HandlebarsGenerator.hpp" +#include #include #include -#include -#include -#include -#include -#include #include #include #include +#include +#include namespace mrdocs::hbs { namespace { -constexpr std::string_view metadataFileName = "mrdocs-generator.yml"; - -// Populate `map` from a YAML mapping whose entries are non-empty -// byte-sequence keys mapped to replacement strings. An empty key -// is a hard error. -Expected -populateEscapeFromMapping( - llvm::yaml::MappingNode& node, - EscapeMap& map, - std::string_view yamlPath) -{ - for (llvm::yaml::KeyValueNode& entry : node) - { - llvm::yaml::ScalarNode* keyNode = - llvm::dyn_cast_or_null(entry.getKey()); - llvm::yaml::ScalarNode* valNode = - llvm::dyn_cast_or_null(entry.getValue()); - if (!keyNode || !valNode) - { - return Unexpected(formatError( - "{}: each 'escape' entry must be a scalar->scalar mapping", - yamlPath)); - } - llvm::SmallString<8> keyBuf; - llvm::SmallString<32> valBuf; - llvm::StringRef const keyStr = keyNode->getValue(keyBuf); - llvm::StringRef const valStr = valNode->getValue(valBuf); - if (keyStr.empty()) - { - return Unexpected(formatError( - "{}: escape key must not be empty", - yamlPath)); - } - map.set( - std::string_view(keyStr.data(), keyStr.size()), - std::string_view(valStr.data(), valStr.size())); - } - return {}; -} - -// Install a HandlebarsGenerator for the data-driven format in `dir`, -// when `dir` opts in by shipping an `mrdocs-generator.yml`. -// -// The presence of the manifest is the explicit opt-in: a directory -// under /generator/ becomes a generator only when it ships -// this file. Directories that hold shared assets (the built-in -// `common/` is the canonical example) simply don't declare a manifest, -// and discovery skips them. -// -// The generator registry is process-global and is not cleared between -// runs in the same process. `installGenerator` fails when the id is -// already taken, whether by a built-in or by a generator an earlier -// addon root installed under the same name. That is the -// first-writer-wins layering we want, so a duplicate id is a silent -// skip rather than an error (a null generator is the only other -// failure it reports, and we never pass one). In the test executable -// this also means the first test to install an id wins for the rest -// of the process; two fixtures cannot ship competing generators of -// the same name. -Expected -maybeRegister(std::filesystem::path const& dir) -{ - std::string const yamlPath = files::appendPath( - dir.string(), std::string(metadataFileName)); - if (!files::exists(yamlPath)) - { - return {}; - } - std::string const name = dir.filename().string(); - MRDOCS_TRY(EscapeMap escapeMap, loadGeneratorMetadata(yamlPath)); - (void)installGenerator( - std::make_unique( - name, name, name, std::move(escapeMap))); - return {}; -} - -// Scan a single /generator/ directory. -Expected -scanGeneratorDir(std::string_view generatorDir) +// Build an `EscapeMap` from the manifest's ordered `escape` rules. +EscapeMap +toEscapeMap( + std::vector> const& rules) { - namespace fs = std::filesystem; - std::error_code iterEc; - fs::directory_iterator const end{}; - for (fs::directory_iterator it(generatorDir, iterEc); - !iterEc && it != end; - it.increment(iterEc)) + EscapeMap map; + for (std::pair const& rule : rules) { - std::error_code typeEc; - if (!it->is_directory(typeEc)) - { - continue; - } - MRDOCS_TRY(maybeRegister(it->path())); + map.set(rule.first, rule.second); } - return {}; + return map; } } // (anon) @@ -128,69 +42,35 @@ scanGeneratorDir(std::string_view generatorDir) Expected loadGeneratorMetadata(std::string_view yamlPath) { - MRDOCS_TRY(std::string text, files::getFileText(yamlPath)); - llvm::SourceMgr sm; - llvm::yaml::Stream stream(text, sm); - - EscapeMap map; - llvm::yaml::document_iterator docIt = stream.begin(); - if (docIt == stream.end()) - { - return map; - } - llvm::yaml::Node* const rootNode = docIt->getRoot(); - if (rootNode == nullptr || - llvm::isa(rootNode)) - { - // Empty document: file with no content, only comments, or a - // literal `null`. All of these mean "no rules". - return map; - } - llvm::yaml::MappingNode* const root = - llvm::dyn_cast(rootNode); - if (!root) - { - return Unexpected(formatError( - "{}: top-level YAML node must be a mapping", yamlPath)); - } - - for (llvm::yaml::KeyValueNode& pair : *root) - { - llvm::yaml::ScalarNode* const keyNode = - llvm::dyn_cast_or_null(pair.getKey()); - if (!keyNode) - { - continue; - } - llvm::SmallString<16> keyBuf; - if (keyNode->getValue(keyBuf) != "escape") - { - continue; - } - llvm::yaml::MappingNode* const escNode = - llvm::dyn_cast_or_null(pair.getValue()); - if (!escNode) - { - return Unexpected(formatError( - "{}: 'escape' must be a mapping", yamlPath)); - } - MRDOCS_TRY(populateEscapeFromMapping(*escNode, map, yamlPath)); - } - return map; + MRDOCS_TRY(GeneratorManifest manifest, loadGeneratorManifest(yamlPath)); + return toEscapeMap(manifest.escape); } Expected discoverDataDrivenGenerators(Config::Settings const& settings) { - std::vector const roots = addon_paths::addonRoots(settings); - for (std::string const& root : roots) + MRDOCS_TRY( + std::vector found, + discoverGeneratorManifests(addon_paths::addonRoots(settings))); + for (DiscoveredManifest const& d : found) { - std::string const dir = files::appendPath(root, "generator"); - if (!files::exists(dir)) + // A manifest that names a `script` is a script-driven generator; + // that flavor is installed by its own discovery pass. + if (d.manifest.script) { continue; } - MRDOCS_TRY(scanGeneratorDir(dir)); + // The generator registry is process-global and is not cleared + // between runs in the same process. `installGenerator` fails when + // the id is already taken, whether by a built-in or by an + // earlier addon root's generator of the same name. That is the + // first-writer-wins layering we want, so a duplicate id is a + // silent skip rather than an error (a `null` generator is the only + // other failure it reports, and we never pass one). + std::string const name(files::getFileName(d.dir)); + (void)installGenerator( + std::make_unique( + name, name, name, toEscapeMap(d.manifest.escape))); } return {}; } diff --git a/src/lib/Gen/hbs/DataDrivenGenerators.hpp b/src/lib/Gen/hbs/DataDrivenGenerators.hpp index 7146a1f2ed..533fa8238e 100644 --- a/src/lib/Gen/hbs/DataDrivenGenerators.hpp +++ b/src/lib/Gen/hbs/DataDrivenGenerators.hpp @@ -33,6 +33,10 @@ namespace mrdocs::hbs { (the built-in `common/` is the canonical example) don't declare a manifest and are skipped. + 3. Its manifest does not name a `script`. A manifest with a `script` + key declares a script-driven generator, which is installed by + `discoverScriptGenerators` instead, so it is skipped here. + For each accepted directory, a `HandlebarsGenerator` is constructed with id, file extension, and display name all set to ``, and installed into the global registry. Escape rules are read from diff --git a/src/lib/Gen/script/OutputSink.cpp b/src/lib/Gen/script/OutputSink.cpp new file mode 100644 index 0000000000..28ac585b81 --- /dev/null +++ b/src/lib/Gen/script/OutputSink.cpp @@ -0,0 +1,77 @@ +// +// 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 "OutputSink.hpp" +#include +#include +#include + +namespace mrdocs::script { + +OutputSink:: +OutputSink(std::string_view outputDir) + : root_(files::normalizePath(outputDir)) +{ +} + +Expected +OutputSink:: +resolveUnderRoot(std::string_view relPath) const +{ + if (relPath.empty()) + { + return Unexpected(formatError( + "output.write: path must not be empty")); + } + if (files::isAbsolute(relPath)) + { + return Unexpected(formatError( + "output.write: path '{}' must be relative", relPath)); + } + std::string const full = files::normalizePath( + files::appendPath(root_, relPath)); + // `startsWith` enforces a component boundary after the prefix, so the + // root is passed without a trailing separator: a sibling directory + // whose name merely begins with the root (root vs root-x) is not a + // false match. + if (!files::startsWith(full, root_)) + { + return Unexpected(formatError( + "output.write: path '{}' escapes the output directory", + relPath)); + } + return full; +} + +Expected +OutputSink:: +write(std::string_view relPath, std::string_view contents) +{ + MRDOCS_TRY(std::string full, resolveUnderRoot(relPath)); + MRDOCS_TRY(files::createDirectory(files::getParentDir(full))); + + std::ofstream os(full, std::ios::binary | std::ios::trunc); + if (!os) + { + return Unexpected(formatError( + "output.write: cannot open '{}' for writing", full)); + } + os.write( + contents.data(), + static_cast(contents.size())); + if (!os) + { + return Unexpected(formatError( + "output.write: failed writing '{}'", full)); + } + return {}; +} + +} // mrdocs::script diff --git a/src/lib/Gen/script/OutputSink.hpp b/src/lib/Gen/script/OutputSink.hpp new file mode 100644 index 0000000000..342d5edbd9 --- /dev/null +++ b/src/lib/Gen/script/OutputSink.hpp @@ -0,0 +1,63 @@ +// +// 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_GEN_SCRIPT_OUTPUTSINK_HPP +#define MRDOCS_LIB_GEN_SCRIPT_OUTPUTSINK_HPP + +#include +#include +#include +#include + +namespace mrdocs::script { + +/** The file-writing API handed to a script-driven generator. + + A script-driven generator owns its output structure: it decides + which files to write and what to put in them. This class is the only + door it has to the filesystem, bound into the script as the `write` + method of the `output` object. Every path is resolved under a single + output directory and may not escape it, so "a generator writes files" + does not become "a script writes anywhere on disk". +*/ +class OutputSink +{ + // The output directory, normalized and absolute, without a trailing + // separator. + std::string root_; + + // Resolve `relPath` under the output directory. Reject an empty path, + // an absolute path, or a path that escapes the directory. + Expected + resolveUnderRoot(std::string_view relPath) const; + +public: + /** Construct a sink rooted at the given output directory. + */ + explicit + OutputSink(std::string_view outputDir); + + /** Write `contents` to `relPath`, resolved under the output directory. + + Create any missing parent directories. Reject an absolute path + or one that escapes the output directory. + + @param relPath The destination path, relative to the output + directory. + @param contents The bytes to write. + @return Success, or an error describing why the write failed. + */ + Expected + write(std::string_view relPath, std::string_view contents); +}; + +} // mrdocs::script + +#endif diff --git a/src/lib/Gen/script/ScriptGenerator.cpp b/src/lib/Gen/script/ScriptGenerator.cpp new file mode 100644 index 0000000000..abb2aefb61 --- /dev/null +++ b/src/lib/Gen/script/ScriptGenerator.cpp @@ -0,0 +1,136 @@ +// +// 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 "ScriptGenerator.hpp" +#include "ScriptRunner.hpp" +#include "OutputSink.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mrdocs::script { + +namespace { + +// Build the read-only corpus DOM a `generate(corpus, output)` entry +// point receives. This mirrors what an extension script sees: a +// `symbols` array of lazy per-symbol objects, each tagged with its flat +// `_id` so a script can form stable per-symbol URLs. +dom::Value +buildScriptCorpus(Corpus const& corpus, DomCorpus const& domCorpus) +{ + dom::Array symbols; + for (Symbol const& sym : corpus) + { + dom::Value value = domCorpus.get(sym.id); + value.getObject().set("_id", toBase16Str(sym.id)); + symbols.emplace_back(std::move(value)); + } + dom::Object corpusObj; + corpusObj.set("symbols", std::move(symbols)); + return dom::Value(std::move(corpusObj)); +} + +} // (anon) + +ScriptGenerator:: +ScriptGenerator(std::string id, std::string scriptPath) + : id_(std::move(id)) + , scriptPath_(std::move(scriptPath)) +{ +} + +std::string_view +ScriptGenerator:: +id() const noexcept +{ + return id_; +} + +std::string_view +ScriptGenerator:: +displayName() const noexcept +{ + return id_; +} + +std::string_view +ScriptGenerator:: +fileExtension() const noexcept +{ + // A script-driven generator names its own output files, so it has + // no single extension. Report the id for diagnostics. + return id_; +} + +Expected +ScriptGenerator:: +build(std::string_view outputPath, Corpus const& corpus) const +{ + OutputSink sink(outputPath); + DomCorpus domCorpus(corpus); + dom::Value corpusValue = buildScriptCorpus(corpus, domCorpus); + if (scriptPath_.ends_with(".lua")) + { + return runLuaGenerator(corpusValue, scriptPath_, sink); + } + if (scriptPath_.ends_with(".js")) + { + return runJsGenerator(corpusValue, scriptPath_, sink); + } + return Unexpected(formatError( + "generator '{}': script '{}' must be a .lua or .js file", + id_, scriptPath_)); +} + +Expected +ScriptGenerator:: +buildOne(std::ostream&, Corpus const&) const +{ + return Unexpected(formatError( + "generator '{}' is script-driven and does not support " + "single-page output", id_)); +} + +Expected +discoverScriptGenerators(Config::Settings const& settings) +{ + MRDOCS_TRY( + std::vector found, + discoverGeneratorManifests(hbs::addon_paths::addonRoots(settings))); + for (DiscoveredManifest const& d : found) + { + // Only manifests that name a `script` are script-driven + // generators; the data-driven pass installs the rest. + if (!d.manifest.script) + { + continue; + } + std::string const name(files::getFileName(d.dir)); + std::string scriptPath = files::appendPath(d.dir, *d.manifest.script); + // First-writer-wins, exactly as the data-driven pass: a + // duplicate id is a silent skip, and we never pass a `null`. + (void)installGenerator( + std::make_unique(name, std::move(scriptPath))); + } + return {}; +} + +} // mrdocs::script diff --git a/src/lib/Gen/script/ScriptGenerator.hpp b/src/lib/Gen/script/ScriptGenerator.hpp new file mode 100644 index 0000000000..e59512bb53 --- /dev/null +++ b/src/lib/Gen/script/ScriptGenerator.hpp @@ -0,0 +1,92 @@ +// +// 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_GEN_SCRIPT_SCRIPTGENERATOR_HPP +#define MRDOCS_LIB_GEN_SCRIPT_SCRIPTGENERATOR_HPP + +#include +#include +#include +#include +#include +#include +#include + +namespace mrdocs::script { + +/** A generator whose output is produced by a user script. + + A script-driven generator hands the whole emit to a Lua or + JavaScript entry point of the form `generate(corpus, output)`: the + script traverses the corpus and writes whatever files it wants + through the `output` object. Because the script owns the output + structure, it can produce shapes the per-page generators cannot, such + as a single artifact aggregated across all symbols (a search index, + for example). +*/ +class ScriptGenerator + : public Generator +{ + std::string id_; + // The absolute path to the Lua or JavaScript entry script. + std::string scriptPath_; + +public: + /** Construct a script-driven generator. + + @param id The generator id, used to select it on the command + line. + @param scriptPath The absolute path to the entry script. + */ + ScriptGenerator(std::string id, std::string scriptPath); + + std::string_view + id() const noexcept override; + + std::string_view + displayName() const noexcept override; + + std::string_view + fileExtension() const noexcept override; + + /** Run the entry script, which owns the whole emit. + */ + Expected + build( + std::string_view outputPath, + Corpus const& corpus) const override; + + /** Reject single-page output. + + A script-driven generator owns its output structure and writes + whatever files it wants, so there is no single-stream form. + */ + Expected + buildOne( + std::ostream& os, + Corpus const& corpus) const override; +}; + +/** Discover script-driven generators and install them. + + For each configured addon root, walk the immediate subdirectories of + /generator/. A subdirectory becomes a script-driven generator + when its `mrdocs-generator.yml` names an entry script. The generator + id, used to select it on the command line, is the subdirectory name. + + Should be called once after the configuration is resolved and before + a generator is looked up by id. +*/ +Expected +discoverScriptGenerators(Config::Settings const& settings); + +} // mrdocs::script + +#endif diff --git a/src/lib/Gen/script/ScriptGeneratorJs.cpp b/src/lib/Gen/script/ScriptGeneratorJs.cpp new file mode 100644 index 0000000000..6ca005a25a --- /dev/null +++ b/src/lib/Gen/script/ScriptGeneratorJs.cpp @@ -0,0 +1,100 @@ +// +// 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 "ScriptRunner.hpp" +#include "OutputSink.hpp" + +#include +#include +#include + +#include +#include + +namespace mrdocs::script { + +namespace { + +// Build the `output` object passed as the second argument to `generate`. +// The JS wrapper exposes a `dom::Function` as a callable proxy, so unlike +// the Lua side this needs no escape hatch: `write` is a variadic +// invocable that routes to the sink. The sink outlives the call (it is a +// local in `runJsGenerator`), so capturing it by pointer is safe. +dom::Object +buildJsOutputApi(OutputSink& sink) +{ + OutputSink* sinkPtr = &sink; + dom::Object api; + api.set("write", dom::Value(dom::makeVariadicInvocable( + [sinkPtr](dom::Array const& args) -> Expected + { + if (args.size() < 2) + { + return Unexpected(Error( + "output.write: expected (path, contents)")); + } + dom::Value const path = args.get(0); + dom::Value const body = args.get(1); + if (!path.isString() || !body.isString()) + { + return Unexpected(Error( + "output.write: path and contents must be strings")); + } + Expected result = sinkPtr->write( + path.getString().get(), body.getString().get()); + if (!result) + { + return Unexpected(result.error()); + } + return dom::Value(); + }))); + return api; +} + +} // (anon) + +Expected +runJsGenerator( + dom::Value const& corpus, + std::string const& scriptPath, + OutputSink& sink) +{ + js::Context ctx; + js::Scope scope(ctx); + + MRDOCS_TRY(std::string script, files::getFileText(scriptPath)); + if (Expected exp = scope.script(script); !exp) + { + return Unexpected(formatError( + "generator '{}': {}", + scriptPath, exp.error().message())); + } + + // Unlike an extension, a generator must define `generate`: the user + // selected this generator expecting output. + Expected fn = scope.getGlobal("generate"); + if (!fn || !fn->isFunction()) + { + return Unexpected(formatError( + "generator '{}': script must define a 'generate' function", + scriptPath)); + } + + Expected result = fn->call(corpus, buildJsOutputApi(sink)); + if (!result) + { + return Unexpected(formatError( + "generator '{}': {}", + scriptPath, result.error().message())); + } + return {}; +} + +} // mrdocs::script diff --git a/src/lib/Gen/script/ScriptGeneratorLua.cpp b/src/lib/Gen/script/ScriptGeneratorLua.cpp new file mode 100644 index 0000000000..3c9a28c414 --- /dev/null +++ b/src/lib/Gen/script/ScriptGeneratorLua.cpp @@ -0,0 +1,146 @@ +// +// 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 "ScriptRunner.hpp" +#include "OutputSink.hpp" + +#include +#include +#include + +#include +#include +#include +#include + +extern "C" { +#include +#include +} + +namespace mrdocs::script { + +namespace { + +// Lua adapter for `OutputSink::write`. On failure the script aborts via +// `luaL_error`; the host turns that into an `Unexpected` when `lua_pcall` +// returns non-OK. The sink pointer is carried as the closure's single +// upvalue. +int +luaWrite(lua_State* L) +{ + OutputSink* sink = static_cast( + lua_touserdata(L, lua_upvalueindex(1))); + if (lua_type(L, 1) != LUA_TSTRING || + lua_type(L, 2) != LUA_TSTRING) + { + return luaL_error(L, + "output.write: expected (string path, string contents)"); + } + std::size_t pathLen = 0; + char const* pathData = lua_tolstring(L, 1, &pathLen); + std::size_t bodyLen = 0; + char const* bodyData = lua_tolstring(L, 2, &bodyLen); + + Expected result = sink->write( + std::string_view(pathData, pathLen), + std::string_view(bodyData, bodyLen)); + if (!result) + { + return luaL_error(L, "%s", result.error().message().c_str()); + } + return 0; +} + +// Build the `output` global table and bind its `write` method. +// +// We register the C closure directly on the raw `lua_State` (via the +// `Context::nativeState()` escape hatch) because the wrapper cannot carry +// a native callable through a DOM value: `domValue_push` has no function +// case. The closure carries the sink pointer as its single upvalue. +void +registerLuaOutputApi(lua_State* L, OutputSink& sink) +{ + lua_newtable(L); + + lua_pushlightuserdata(L, &sink); + lua_pushcclosure(L, &luaWrite, 1); + lua_setfield(L, -2, "write"); + + lua_setglobal(L, "output"); +} + +} // (anon) + +Expected +runLuaGenerator( + dom::Value const& corpus, + std::string const& scriptPath, + OutputSink& sink) +{ + lua::Context ctx; + + // Register the `output` global before loading the script so + // top-level code can reference it, and so we can pass it as the + // second argument below. + registerLuaOutputApi( + static_cast(ctx.nativeState()), sink); + + 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()); + } + + // Fetch the `output` global so it can be passed as the second + // argument. It must outlive the `generate` call below, so hold it + // here rather than moving it out. + Expected output = scope.getGlobal("output"); + if (!output) + { + return Unexpected(output.error()); + } + + auto callGenerate = + [&](lua::Function&& fn) -> Expected + { + Expected result = fn.call(corpus, *output); + if (!result) + { + return Unexpected(formatError( + "generator '{}': {}", + scriptPath, result.error().message())); + } + return {}; + }; + + // Resolve `generate` the same way extension scripts resolve their + // hook: prefer the chunk's return value, fall back to a same-named + // global. Unlike an extension, a generator must define one: the user + // selected this generator expecting output. + if (chunkResult->isFunction()) + { + return callGenerate(lua::Function(std::move(*chunkResult))); + } + Expected global = scope.getGlobal("generate"); + if (!global || !global->isFunction()) + { + return Unexpected(formatError( + "generator '{}': script must define a 'generate' function", + scriptPath)); + } + return callGenerate(lua::Function(std::move(*global))); +} + +} // mrdocs::script diff --git a/src/lib/Gen/script/ScriptRunner.hpp b/src/lib/Gen/script/ScriptRunner.hpp new file mode 100644 index 0000000000..50a64c1a5b --- /dev/null +++ b/src/lib/Gen/script/ScriptRunner.hpp @@ -0,0 +1,58 @@ +// +// 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_GEN_SCRIPT_SCRIPTRUNNER_HPP +#define MRDOCS_LIB_GEN_SCRIPT_SCRIPTRUNNER_HPP + +#include +#include +#include +#include + +namespace mrdocs::script { + +class OutputSink; + +/** Run a Lua entry script's `generate(corpus, output)`. + + Build a Lua context, expose the output writer as the `output` global, + evaluate the script, and call its `generate` function with the corpus + and the writer. A missing `generate` function is an error. + + @param corpus The read-only corpus DOM passed as the first argument. + @param scriptPath The absolute path to the Lua entry script. + @param sink The file-writing API exposed to the script. +*/ +Expected +runLuaGenerator( + dom::Value const& corpus, + std::string const& scriptPath, + OutputSink& sink); + +/** Run a JavaScript entry script's `generate(corpus, output)`. + + Build a JavaScript context, evaluate the script, and call its + `generate` function with the corpus and an `output` object whose + `write` method routes to the writer. A missing `generate` function + is an error. + + @param corpus The read-only corpus DOM passed as the first argument. + @param scriptPath The absolute path to the JavaScript entry script. + @param sink The file-writing API exposed to the script. +*/ +Expected +runJsGenerator( + dom::Value const& corpus, + std::string const& scriptPath, + OutputSink& sink); + +} // mrdocs::script + +#endif diff --git a/src/lib/Support/Lua.cpp b/src/lib/Support/Lua.cpp index 985f229464..d641ec3c4a 100644 --- a/src/lib/Support/Lua.cpp +++ b/src/lib/Support/Lua.cpp @@ -643,11 +643,20 @@ domValue_push( { case dom::Kind::Null: return lua_pushnil(A); + case dom::Kind::Undefined: + // Lua has a single nullary value, so a missing field maps to + // `nil` just as `Null` does. A read of an absent field (for + // example the global namespace's name) yields `Undefined` and + // must not abort. + return lua_pushnil(A); case dom::Kind::Boolean: return lua_pushboolean(A, value.getBool()); case dom::Kind::Integer: return lua_pushnumber(A, value.getInteger()); case dom::Kind::String: + case dom::Kind::SafeString: + // A `SafeString` is a string already marked safe for an output + // format; to a Lua script it is just its bytes. return luaM_pushstring(A, value.getString()); case dom::Kind::Array: return domArray_push(A, value.getArray()); diff --git a/src/test/TestRunner.cpp b/src/test/TestRunner.cpp index 132fc2ecb7..80c44f1492 100644 --- a/src/test/TestRunner.cpp +++ b/src/test/TestRunner.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -190,6 +191,13 @@ handleFile( { return report::error("{}: \"{}\"", discovered.error(), filePath); } + Expected scriptsDiscovered = + script::discoverScriptGenerators(loaded->settings); + if (!scriptsDiscovered) + { + return report::error( + "{}: \"{}\"", scriptsDiscovered.error(), filePath); + } Generator const* gen = findGenerator(genId_); if (!gen) { diff --git a/src/test/lib/Gen/script/ScriptGenerator.cpp b/src/test/lib/Gen/script/ScriptGenerator.cpp new file mode 100644 index 0000000000..f6d84a0d0a --- /dev/null +++ b/src/test/lib/Gen/script/ScriptGenerator.cpp @@ -0,0 +1,270 @@ +// +// 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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mrdocs::script { + +namespace { + +// Write `content` verbatim to `path`. Pre-existing files are truncated. +void +writeFile(std::string_view path, std::string_view content) +{ + std::ofstream os(std::string{path}, std::ios::binary | std::ios::trunc); + os.write(content.data(), + static_cast(content.size())); +} + +// A two-symbol corpus shaped like what `generate(corpus, output)` sees: +// a `symbols` array whose entries carry a `name` and a flat `_id`. +dom::Value +makeCorpus() +{ + dom::Object foo; + foo.set("name", std::string("foo")); + foo.set("_id", std::string("0001")); + dom::Object bar; + bar.set("name", std::string("bar")); + bar.set("_id", std::string("0002")); + dom::Array symbols; + symbols.emplace_back(dom::Value(std::move(foo))); + symbols.emplace_back(dom::Value(std::move(bar))); + dom::Object corpus; + corpus.set("symbols", std::move(symbols)); + return dom::Value(std::move(corpus)); +} + +// A Lua generator that emits one aggregated artifact across all symbols, +// the canonical thing a per-page generator cannot produce. +constexpr std::string_view luaIndex = R"LUA( +return function(corpus, output) + local parts = {} + for _, sym in ipairs(corpus.symbols) do + parts[#parts + 1] = '{"name":"' .. sym.name .. '","id":"' .. sym._id .. '"}' + end + output.write("search-index.json", "[" .. table.concat(parts, ",") .. "]") +end +)LUA"; + +// The same generator in JavaScript, using the global-function shape. +constexpr std::string_view jsIndex = R"JS( +function generate(corpus, output) { + var parts = []; + for (var i = 0; i < corpus.symbols.length; i++) { + var s = corpus.symbols[i]; + parts.push('{"name":"' + s.name + '","id":"' + s._id + '"}'); + } + output.write("search-index.json", "[" + parts.join(",") + "]"); +} +)JS"; + +constexpr std::string_view expectedJson = + R"([{"name":"foo","id":"0001"},{"name":"bar","id":"0002"}])"; + +} // (anon) + +struct ScriptGeneratorTest +{ + // + // OutputSink + // + + void + testSinkWritesUnderRoot() + { + ScopedTempDirectory td("mrdocs-scriptgen"); + BOOST_TEST(td); + OutputSink sink(td.path()); + // A nested relative path is created and written. + BOOST_TEST(sink.write("a/b/out.txt", "hello").has_value()); + Expected got = + files::getFileText(files::appendPath(td.path(), "a", "b", "out.txt")); + BOOST_TEST(got.has_value()); + if (got) + { + BOOST_TEST(*got == "hello"); + } + } + + void + testSinkRejectsAbsolutePath() + { + ScopedTempDirectory td("mrdocs-scriptgen"); + BOOST_TEST(td); + OutputSink sink(td.path()); + // An absolute path is rejected even when it points inside root. + std::string const abs = files::appendPath(td.path(), "x.txt"); + BOOST_TEST(!sink.write(abs, "no").has_value()); + } + + void + testSinkRejectsEscape() + { + ScopedTempDirectory td("mrdocs-scriptgen"); + BOOST_TEST(td); + OutputSink sink(td.path()); + // A path that climbs out of the output directory is rejected. + BOOST_TEST(!sink.write("../escaped.txt", "no").has_value()); + } + + // + // runLuaGenerator / runJsGenerator + // + + void + testLuaGenerator() + { + ScopedTempDirectory td("mrdocs-scriptgen"); + BOOST_TEST(td); + std::string const script = files::appendPath(td.path(), "g.lua"); + writeFile(script, luaIndex); + std::string const outDir = files::appendPath(td.path(), "out"); + OutputSink sink(outDir); + + Expected result = runLuaGenerator(makeCorpus(), script, sink); + BOOST_TEST(result.has_value()); + Expected got = + files::getFileText(files::appendPath(outDir, "search-index.json")); + BOOST_TEST(got.has_value()); + if (got) + { + BOOST_TEST(*got == expectedJson); + } + } + + void + testJsGenerator() + { + ScopedTempDirectory td("mrdocs-scriptgen"); + BOOST_TEST(td); + std::string const script = files::appendPath(td.path(), "g.js"); + writeFile(script, jsIndex); + std::string const outDir = files::appendPath(td.path(), "out"); + OutputSink sink(outDir); + + Expected result = runJsGenerator(makeCorpus(), script, sink); + BOOST_TEST(result.has_value()); + Expected got = + files::getFileText(files::appendPath(outDir, "search-index.json")); + BOOST_TEST(got.has_value()); + if (got) + { + BOOST_TEST(*got == expectedJson); + } + } + + void + testLuaReadsMissingFieldAsNil() + { + // A symbol object without a `name` field: `get("name")` yields + // `Undefined`, which Lua must marshal as `nil` rather than abort. + // The global namespace has no name, so a real corpus hits this. + dom::Object noName; + noName.set("_id", std::string("0009")); + dom::Array symbols; + symbols.emplace_back(dom::Value(std::move(noName))); + dom::Object corpusObj; + corpusObj.set("symbols", std::move(symbols)); + dom::Value const corpus(std::move(corpusObj)); + + ScopedTempDirectory td("mrdocs-scriptgen"); + BOOST_TEST(td); + std::string const script = files::appendPath(td.path(), "g.lua"); + writeFile(script, R"LUA( +return function(corpus, output) + local s = corpus.symbols[1] + output.write("out.txt", "name=" .. (s.name or "NONE")) +end +)LUA"); + std::string const outDir = files::appendPath(td.path(), "out"); + OutputSink sink(outDir); + + Expected result = runLuaGenerator(corpus, script, sink); + BOOST_TEST(result.has_value()); + Expected got = + files::getFileText(files::appendPath(outDir, "out.txt")); + BOOST_TEST(got.has_value()); + if (got) + { + BOOST_TEST(*got == "name=NONE"); + } + } + + void + testMissingGenerateIsError() + { + ScopedTempDirectory td("mrdocs-scriptgen"); + BOOST_TEST(td); + std::string const script = files::appendPath(td.path(), "empty.lua"); + writeFile(script, "-- this script defines no generate function\n"); + OutputSink sink(files::appendPath(td.path(), "out")); + // A generator must define `generate`; its absence is an error. + BOOST_TEST(!runLuaGenerator(makeCorpus(), script, sink).has_value()); + } + + // + // discoverScriptGenerators + // + + void + testDiscoveryRegistersScriptGenerator() + { + ScopedTempDirectory td("mrdocs-scriptgen-disc"); + BOOST_TEST(td); + // Lay out /generator// with a script manifest. The id + // is unusual so it does not collide with the process-global + // registry shared across the test binary. + std::string const id = "mrdocs-script-generator-selftest"; + std::string const genDir = + files::appendPath(td.path(), "generator", id); + BOOST_TEST(files::createDirectory(genDir).has_value()); + writeFile( + files::appendPath(genDir, "mrdocs-generator.yml"), + "script: g.lua\n"); + writeFile(files::appendPath(genDir, "g.lua"), luaIndex); + + Config::Settings settings; + settings.addons = std::string(td.path()); + BOOST_TEST(discoverScriptGenerators(settings).has_value()); + BOOST_TEST(findGenerator(id) != nullptr); + } + + void + run() + { + testSinkWritesUnderRoot(); + testSinkRejectsAbsolutePath(); + testSinkRejectsEscape(); + testLuaGenerator(); + testJsGenerator(); + testLuaReadsMissingFieldAsNil(); + testMissingGenerateIsError(); + testDiscoveryRegistersScriptGenerator(); + } +}; + +TEST_SUITE( + ScriptGeneratorTest, + "clang.mrdocs.script.ScriptGenerator"); + +} // namespace mrdocs::script diff --git a/src/tool/GenerateAction.cpp b/src/tool/GenerateAction.cpp index d6b6fed4f1..5176f968a9 100644 --- a/src/tool/GenerateAction.cpp +++ b/src/tool/GenerateAction.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -49,14 +50,16 @@ DoGenerateAction( // -------------------------------------------------------------- // - // Discover data-driven generators + // Discover addon-defined generators // // -------------------------------------------------------------- - // Each /generator// directory that ships its own - // Handlebars layouts is registered as an additional generator - // (subject to id and layout-template checks) before the user- - // requested generator is looked up below. + // Each /generator// directory that ships an + // `mrdocs-generator.yml` is registered as an additional generator + // before the user-requested generator is looked up below. A manifest + // that declares `escape` rules is a data-driven Handlebars generator; + // a manifest that names a `script` is a script-driven generator. MRDOCS_TRY(hbs::discoverDataDrivenGenerators(config->settings())); + MRDOCS_TRY(script::discoverScriptGenerators(config->settings())); // -------------------------------------------------------------- //