diff --git a/CMakeLists.txt b/CMakeLists.txt index 2856e67d42..772c0beca9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -553,6 +553,44 @@ if (MRDOCS_BUILD_TESTS) ) endforeach () + #------------------------------------------------- + # Template-only generators + # + # Fixtures under test-files/template-only-generators/ ship an + # addon defining their own Handlebars generator. They live outside + # test-files/golden-tests so the xml/adoc/html runs do not walk + # into them and demand expected files in their own formats. + #------------------------------------------------- + set(MRDOCS_TEMPLATE_ONLY_ROOT "${PROJECT_SOURCE_DIR}/test-files/template-only-generators") + add_test(NAME mrdocs-golden-tests-mock-md + COMMAND + mrdocs-test + --unit=false + --action=test + "${MRDOCS_TEMPLATE_ONLY_ROOT}/mock-md" + "--addons=${CMAKE_SOURCE_DIR}/share/mrdocs/addons" + --generator=mock-md + "--stdlib-includes=${LIBCXX_DIR}" + "--libc-includes=${CMAKE_SOURCE_DIR}/share/mrdocs/headers/libc-stubs" + --log-level=warn + ) + foreach (action IN ITEMS test create update) + add_custom_target( + mrdocs-${action}-test-fixtures-mock-md + COMMAND + mrdocs-test + --unit=false + --action=${action} + "${MRDOCS_TEMPLATE_ONLY_ROOT}/mock-md" + "--addons=${CMAKE_SOURCE_DIR}/share/mrdocs/addons" + --generator=mock-md + "--stdlib-includes=${LIBCXX_DIR}" + "--libc-includes=${CMAKE_SOURCE_DIR}/share/mrdocs/headers/libc-stubs" + --log-level=warn + DEPENDS mrdocs-test + ) + endforeach () + #------------------------------------------------- # Self-documentation test (warn-as-error toggled by strict flag) #------------------------------------------------- diff --git a/docs/modules/ROOT/pages/generators.adoc b/docs/modules/ROOT/pages/generators.adoc index 38c56bd26b..5b1a5860ad 100644 --- a/docs/modules/ROOT/pages/generators.adoc +++ b/docs/modules/ROOT/pages/generators.adoc @@ -156,6 +156,50 @@ Mr.Docs strips Handlebars' trailing options object before forwarding arguments t 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. +=== Custom output formats + +Beyond the three built-in formats (`adoc`, `html`, `xml`), you can add an output format of your own by dropping a template directory under an addon root. +No C++ subclass is needed: Mr.Docs walks the immediate subdirectories of /generator/ at startup, and any subdirectory that ships an `mrdocs-generator.yml` file is installed as a new generator with id ``. + +The directory name is both the format id and the output file extension: the Handlebars Builder keys all template lookups on the file extension, so the two must agree. +To add a format that emits `.md` files, name the directory `md` and select it with `--generator=md`. + +To add the format: + +. Create /generator/md/mrdocs-generator.yml. The file declares the directory as a generator and supplies any escape rules; an empty file is fine when there are no rules to specify. +. Create /generator/md/layouts/index.md.hbs and /generator/md/layouts/wrapper.md.hbs. These have the same role they have for `adoc` and `html`: `index` renders one symbol; `wrapper` is interpolated around the index output and is responsible for the page title and any boilerplate. +. Optionally add /generator/md/partials/ and /generator/md/helpers/ for the partials and helpers your layouts use. The lookup paths and override rules are the same as for the built-in formats. +. Select the format on the command line or in your config with `generator: md`. Mr.Docs writes output files with the `.md` extension. + +A directory under `generator/` that does not ship an `mrdocs-generator.yml` is treated as a shared-assets directory and skipped. That's how the built-in `common/` directory (which ships only CSS and shared partials) coexists with real generator directories without being mistakenly registered. + +==== The `mrdocs-generator.yml` manifest + +If your format needs per-pattern escape rules, drop a `mrdocs-generator.yml` file alongside `layouts/`: + +[source,yaml] +---- +escape: + '*': '\*' + '**': '' + '_': '\_' + '`': '\`' +---- + +The `escape` key is optional and holds a sub-mapping from byte-sequence keys to replacement strings. +A position in the input that matches no rule passes through unchanged. +Keys may be one or more bytes long; an empty key is rejected at startup. + +Multi-byte keys are useful for tokens like Markdown's `**` (bold) versus a literal `*`, RST's ``` `` ``` (literal) versus ``` ` ``` (emphasis), or for whole UTF-8 codepoints that should be replaced as a unit. +When more than one rule could match at a position, the longest match wins, so a `**` rule takes precedence over a `*` rule when both are present. + +Unknown top-level keys are silently ignored so future schema additions stay non-breaking. + +==== Layering across addon roots + +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. + == 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 ec184dbee5..c6c407c2c4 100644 --- a/docs/mrdocs.schema.json +++ b/docs/mrdocs.schema.json @@ -252,13 +252,9 @@ }, "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 generator can create different types of documentation such as HTML, XML, and AsciiDoc.", - "enum": [ - "adoc", - "html", - "xml" - ], - "title": "Generator used to create the documentation" + "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`; addon-defined generators can be added by dropping a template folder under /generator//.", + "title": "Generator used to create the documentation", + "type": "string" }, "global-namespace-index": { "default": true, diff --git a/include/mrdocs/Support/Handlebars.hpp b/include/mrdocs/Support/Handlebars.hpp index ccdc506423..fd1da66207 100644 --- a/include/mrdocs/Support/Handlebars.hpp +++ b/include/mrdocs/Support/Handlebars.hpp @@ -287,6 +287,19 @@ HTMLEscape( OutputRef& out, std::string_view str); +/** Character-to-entity table used by `HTMLEscape`. +*/ +inline constexpr std::pair +htmlEscapeEntities[] = { + {'&', "&"}, + {'<', "<"}, + {'>', ">"}, + {'"', """}, + {'\'', "'"}, + {'`', "`"}, + {'=', "="} +}; + /** \brief HTML escapes the specified string. * * This function HTML escapes the specified string, making it safe for diff --git a/src/lib/ConfigOptions.json b/src/lib/ConfigOptions.json index 79713b691f..533e8efa0d 100644 --- a/src/lib/ConfigOptions.json +++ b/src/lib/ConfigOptions.json @@ -397,13 +397,8 @@ { "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 generator can create different types of documentation such as HTML, XML, and AsciiDoc.", - "type": "enum", - "values": [ - "adoc", - "html", - "xml" - ], + "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`; addon-defined generators can be added by dropping a template folder under /generator//.", + "type": "string", "default": "adoc" }, { diff --git a/src/lib/Gen/adoc/AdocGenerator.cpp b/src/lib/Gen/adoc/AdocGenerator.cpp index 3a74512a4b..934f89602a 100644 --- a/src/lib/Gen/adoc/AdocGenerator.cpp +++ b/src/lib/Gen/adoc/AdocGenerator.cpp @@ -11,11 +11,17 @@ #include "AdocGenerator.hpp" #include "AdocEscape.hpp" -#include +#include namespace mrdocs { namespace adoc { +AdocGenerator:: +AdocGenerator() + : HandlebarsGenerator("adoc", "adoc", "Asciidoc") +{ +} + void AdocGenerator:: escape(OutputRef& os, std::string_view const str) const diff --git a/src/lib/Gen/adoc/AdocGenerator.hpp b/src/lib/Gen/adoc/AdocGenerator.hpp index 382e231c50..525543f69c 100644 --- a/src/lib/Gen/adoc/AdocGenerator.hpp +++ b/src/lib/Gen/adoc/AdocGenerator.hpp @@ -13,9 +13,7 @@ #ifndef MRDOCS_LIB_GEN_ADOC_ADOCGENERATOR_HPP #define MRDOCS_LIB_GEN_ADOC_ADOCGENERATOR_HPP -#include #include -#include namespace mrdocs::adoc { @@ -23,24 +21,7 @@ class AdocGenerator final : public hbs::HandlebarsGenerator { public: - std::string_view - id() const noexcept override - { - return "adoc"; - } - - std::string_view - fileExtension() const noexcept override - { - return "adoc"; - } - - - std::string_view - displayName() const noexcept override - { - return "Asciidoc"; - } + AdocGenerator(); void escape(OutputRef& os, std::string_view str) const override; diff --git a/src/lib/Gen/hbs/AddonGenerators.cpp b/src/lib/Gen/hbs/AddonGenerators.cpp new file mode 100644 index 0000000000..de66a6307c --- /dev/null +++ b/src/lib/Gen/hbs/AddonGenerators.cpp @@ -0,0 +1,198 @@ +// +// 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 "AddonGenerators.hpp" +#include "AddonPaths.hpp" +#include "HandlebarsGenerator.hpp" +#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 {}; +} + +// Decide whether `dir` is an addon-defined generator and, if so, install +// a corresponding HandlebarsGenerator into the global registry. +// +// The generator registry is process-global and is not cleared between +// runs in the same process. In the test executable this means once a +// generator id has been installed by one test, every later test sees +// it: the `findGenerator` check below makes subsequent registrations +// of the same id a no-op (first writer wins). Two fixtures that ship +// generator directories with the same id will not get two competing +// installations; the second is silently skipped. +Expected +maybeRegister(std::filesystem::path const& dir) +{ + std::string const name = dir.filename().string(); + if (findGenerator(name)) + { + return {}; + } + // The presence of an `mrdocs-generator.yml` file 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. + std::string const yamlPath = files::appendPath( + dir.string(), std::string(metadataFileName)); + if (!files::exists(yamlPath)) + { + return {}; + } + MRDOCS_TRY(EscapeMap escapeMap, loadGeneratorMetadata(yamlPath)); + + return installGenerator( + std::make_unique( + name, name, name, std::move(escapeMap))); +} + +// Scan a single /generator/ directory. +Expected +scanGeneratorDir(std::string_view generatorDir) +{ + 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; + } + MRDOCS_TRY(maybeRegister(it->path())); + } + return {}; +} + +} // (anon) + +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; +} + +Expected +discoverAddonGenerators(Config::Settings const& settings) +{ + std::vector const roots = addon_paths::addonRoots(settings); + for (std::string const& root : roots) + { + std::string const dir = files::appendPath(root, "generator"); + if (!files::exists(dir)) + { + continue; + } + MRDOCS_TRY(scanGeneratorDir(dir)); + } + return {}; +} + +} // namespace mrdocs::hbs diff --git a/src/lib/Gen/hbs/AddonGenerators.hpp b/src/lib/Gen/hbs/AddonGenerators.hpp new file mode 100644 index 0000000000..7448a99a04 --- /dev/null +++ b/src/lib/Gen/hbs/AddonGenerators.hpp @@ -0,0 +1,60 @@ +// +// 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_HBS_ADDONGENERATORS_HPP +#define MRDOCS_LIB_GEN_HBS_ADDONGENERATORS_HPP + +#include +#include +#include +#include + +namespace mrdocs::hbs { + +/** Discover addon-defined Handlebars generators and install them. + + For each configured addon root, walk the immediate subdirectories of + /generator/. A subdirectory is treated as an + addon-defined generator when: + + 1. No generator with id `` is already registered (so the + built-in `html` and `adoc` generators take precedence over their + addon directories of the same name). + + 2. It ships an `mrdocs-generator.yml` file. The file's presence is + the explicit opt-in; directories that hold only shared assets + (the built-in `common/` is the canonical example) don't declare + a manifest and are skipped. + + 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 + /mrdocs-generator.yml (see the file format documentation). + + Should be called once after the configuration is resolved and before + a generator is looked up by id. +*/ +Expected +discoverAddonGenerators(Config::Settings const& settings); + +/** Load mrdocs-generator.yml and return the resulting `EscapeMap`. + + 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; an empty + key is a hard error. Unknown top-level keys are ignored so future + schema additions are non-breaking. +*/ +Expected +loadGeneratorMetadata(std::string_view yamlPath); + +} // namespace mrdocs::hbs + +#endif diff --git a/src/lib/Gen/hbs/AddonPaths.hpp b/src/lib/Gen/hbs/AddonPaths.hpp index c0b2d4e373..e8d09b8e46 100644 --- a/src/lib/Gen/hbs/AddonPaths.hpp +++ b/src/lib/Gen/hbs/AddonPaths.hpp @@ -4,6 +4,7 @@ // SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception // // Copyright (c) 2025 Alan de Freitas (alandefreitas@gmail.com) +// Copyright (c) 2026 Gennaro Prota (gennaro.prota@gmail.com) // // Official repository: https://github.com/cppalliance/mrdocs // @@ -21,6 +22,36 @@ namespace mrdocs::hbs::addon_paths { +/** Returns the list of addon root directories from the configuration. + + This function collects all valid addon root paths by checking + the primary addons directory and any supplemental addon directories + specified in the settings. + + @param settings The configuration settings containing addon paths. + @return A vector of existing addon root directory paths. The primary + addons directory (if it exists) appears first, followed by + any existing supplemental addon directories in their + configured order. +*/ +inline std::vector +addonRoots(Config::Settings const& settings) +{ + std::vector roots; + roots.reserve(1 + settings.addonsSupplemental.size()); + + if (files::exists(settings.addons)) + roots.push_back(settings.addons); + + for (auto const& supplemental : settings.addonsSupplemental) + { + if (files::exists(supplemental)) + roots.push_back(supplemental); + } + return roots; +} + + /** Returns directories containing Handlebars partial templates. For each addon root, this function looks for partial templates in: @@ -133,7 +164,7 @@ findFile( std::string_view subdir, std::string_view filename) { - auto roots = mrdocs::addonRoots(config); + auto roots = addonRoots(config.settings()); for (auto it = roots.rbegin(); it != roots.rend(); ++it) { std::string candidate = files::appendPath(*it, "generator", generator, subdir, filename); diff --git a/src/lib/Gen/hbs/Builder.cpp b/src/lib/Gen/hbs/Builder.cpp index 87d963fd4a..f6610b93ef 100644 --- a/src/lib/Gen/hbs/Builder.cpp +++ b/src/lib/Gen/hbs/Builder.cpp @@ -454,7 +454,7 @@ Builder( namespace fs = std::filesystem; auto const& config = domCorpus->config; - auto const roots = addonRoots(config); + auto const roots = addon_paths::addonRoots(config.settings()); auto const partialDirs = addon_paths::partialDirs(roots, domCorpus.fileExtension); auto const helperDirs = addon_paths::helperDirs(roots, domCorpus.fileExtension); auto const layoutDirs = addon_paths::layoutDirs(roots, domCorpus.fileExtension); diff --git a/src/lib/Gen/hbs/HandlebarsGenerator.cpp b/src/lib/Gen/hbs/HandlebarsGenerator.cpp index f749b50681..1291d775a1 100644 --- a/src/lib/Gen/hbs/HandlebarsGenerator.cpp +++ b/src/lib/Gen/hbs/HandlebarsGenerator.cpp @@ -6,6 +6,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 // @@ -103,6 +104,19 @@ createExecutors( // //------------------------------------------------ +HandlebarsGenerator:: +HandlebarsGenerator( + std::string const& id, + std::string const& fileExtension, + std::string const& displayName, + EscapeMap escapeMap) + : escapeMap_(std::move(escapeMap)) + , id_(id) + , fileExtension_(fileExtension) + , displayName_(displayName) +{ +} + Expected HandlebarsGenerator:: build( @@ -238,11 +252,82 @@ buildOne( }); } +void +EscapeMap:: +set(std::string_view source, std::string_view replacement) +{ + if (source.size() == 1) + { + set(source[0], replacement); + return; + } + auto& bucket = multiByte_[static_cast(source[0])]; + // Update in place when the same source is registered twice. + for (auto& entry : bucket) + { + if (entry.first == source) + { + entry.second.assign(replacement); + return; + } + } + bucket.emplace_back(std::string(source), std::string(replacement)); +} + +void +EscapeMap:: +apply(OutputRef& out, std::string_view str) const +{ + std::size_t i = 0; + while (i < str.size()) + { + auto const byte = static_cast(str[i]); + // Multi-byte path: only entered when this byte has at least + // one multi-byte rule registered. The longest match wins, so + // a `**` rule takes precedence over a `*` rule at the same + // position. + auto const& bucket = multiByte_[byte]; + if (!bucket.empty()) + { + std::string const* longestRepl = nullptr; + std::size_t longestLen = 0; + std::size_t const remaining = str.size() - i; + for (auto const& [pattern, repl] : bucket) + { + if (pattern.size() <= remaining && + pattern.size() > longestLen && + str.compare(i, pattern.size(), pattern) == 0) + { + longestRepl = &repl; + longestLen = pattern.size(); + } + } + if (longestRepl) + { + out << *longestRepl; + i += longestLen; + continue; + } + } + // Single-byte fallback: array lookup, no allocation. + std::string const& r = singleByte_[byte]; + if (r.empty()) + { + out << str[i]; + } + else + { + out << r; + } + ++i; + } +} + void HandlebarsGenerator:: escape(OutputRef& out, std::string_view str) const { - out << str; + escapeMap_.apply(out, str); } std::string diff --git a/src/lib/Gen/hbs/HandlebarsGenerator.hpp b/src/lib/Gen/hbs/HandlebarsGenerator.hpp index 3996e34135..c2240f9fda 100644 --- a/src/lib/Gen/hbs/HandlebarsGenerator.hpp +++ b/src/lib/Gen/hbs/HandlebarsGenerator.hpp @@ -5,6 +5,7 @@ // SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception // // 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 // @@ -16,7 +17,11 @@ #include #include #include +#include +#include +#include #include +#include namespace mrdocs { @@ -24,6 +29,73 @@ class OutputRef; namespace hbs { +/** Pattern-replacement table used to escape rendered output values. + + A single-byte source is stored in a 256-entry array indexed by the + `unsigned char` value of the source. A multi-byte source goes into a + bucket keyed by its first byte; each bucket is empty for bytes that + have no multi-byte rule, so the walk pays nothing for the multi-byte + machinery in the common case. When the bucket is non-empty, the + longest matching pattern wins; if no multi-byte pattern matches at + the current position, the single-byte rule (if any) applies. A byte + with no rule at all passes through unchanged. + + Multi-byte support exists so that a format can distinguish, e.g., + Markdown's `**bold**` from a literal `*`, or RST's ``` ``literal`` ``` + from `*emphasis*`. It also accommodates UTF-8 codepoints past ASCII, + which are indexed by byte everywhere else but want to be replaced as + a whole. +*/ +class EscapeMap +{ + // Single-byte rules. Index by `unsigned char`; empty string means + // "no rule for this byte" (pass through). + std::array singleByte_; + + // Multi-byte rules bucketed by first byte. Each bucket holds + // (pattern, replacement) pairs where `pattern.size() >= 2` and + // `pattern[0]` equals the bucket index. Buckets are typically + // empty, so the walk's "is there anything to check here?" test is + // a single null check per input byte. + std::array>, 256> + multiByte_; + +public: + /** Replace a single byte with `replacement` whenever it appears + in escaped text. + + @param c The byte to replace. + @param replacement The string to emit in its place. + */ + void + set(char c, std::string_view replacement) + { + singleByte_[static_cast(c)] = replacement; + } + + /** Replace a pattern of one or more bytes with `replacement` + whenever it appears in escaped text. + + Single-byte patterns are stored on the fast single-byte path + and behave identically to `set(char, string_view)`. Multi-byte + patterns are stored in a bucket keyed by their first byte; + when an existing pattern with the same source is set again, + the replacement is updated in place. + + Behavior is undefined when `source` is empty. + + @param source The byte sequence to replace. + @param replacement The string to emit in its place. + */ + void + set(std::string_view source, std::string_view replacement); + + /** Append the escaped form of `str` to `out`. + */ + void + apply(OutputRef& out, std::string_view str) const; +}; + class HandlebarsGenerator : public Generator { @@ -46,10 +118,57 @@ class HandlebarsGenerator bool hasDefaultStyles = false; }; +protected: + /** Escape table for rendered output. Subclasses populate it in + their constructor; the base class drives `escape()` from it. + */ + EscapeMap escapeMap_; + private: + std::string id_; + std::string fileExtension_; + std::string displayName_; + Expected prepareStylesheets(Config const& config) const; public: + /** Construct a Handlebars-based generator from data. + + Used both by the built-in subclasses (which pass their fixed + identity strings and populate `escapeMap_` in the body) and by + the addon-discovery path, which can build a generator entirely + from a template directory without writing a new C++ subclass. + + @param id Stable identifier (matches `mrdocs.yml`'s `generator:`). + @param fileExtension Output file extension (e.g. "html", "adoc"). + @param displayName Human-readable name shown in messages. + @param escapeMap Character-replacement table; empty means + rendered output passes through unchanged. + */ + HandlebarsGenerator( + std::string const& id, + std::string const& fileExtension, + std::string const& displayName, + EscapeMap escapeMap = {}); + + std::string_view + id() const noexcept override + { + return id_; + } + + std::string_view + fileExtension() const noexcept override + { + return fileExtension_; + } + + std::string_view + displayName() const noexcept override + { + return displayName_; + } + Expected build( std::string_view outputPath, @@ -74,7 +193,16 @@ class HandlebarsGenerator std::string_view fileName, Corpus const& corpus) const; - /** Output an escaped string to the output stream. + /** Append the escaped form of `str` to `os`. + + The default implementation drives the result from the + generator's `escapeMap_`, which is the path used by + addon-defined generators (their map comes from + `mrdocs-generator.yml`). The built-in `adoc` and `html` + generators override this with their own hand-written + switches; the array lookup the default uses is slightly + slower than a compiled switch, and those generators are + on the hot path. */ virtual void diff --git a/src/lib/Gen/html/HTMLGenerator.cpp b/src/lib/Gen/html/HTMLGenerator.cpp index f2cfe4ebc0..9342006d5d 100644 --- a/src/lib/Gen/html/HTMLGenerator.cpp +++ b/src/lib/Gen/html/HTMLGenerator.cpp @@ -11,10 +11,17 @@ #include "HTMLGenerator.hpp" #include +#include namespace mrdocs { namespace html { +HTMLGenerator:: +HTMLGenerator() + : HandlebarsGenerator("html", "html", "HTML") +{ +} + void HTMLGenerator:: escape(OutputRef& os, std::string_view str) const diff --git a/src/lib/Gen/html/HTMLGenerator.hpp b/src/lib/Gen/html/HTMLGenerator.hpp index 5d95944193..d10b81704c 100644 --- a/src/lib/Gen/html/HTMLGenerator.hpp +++ b/src/lib/Gen/html/HTMLGenerator.hpp @@ -23,23 +23,7 @@ class HTMLGenerator final : public hbs::HandlebarsGenerator { public: - std::string_view - id() const noexcept override - { - return "html"; - } - - std::string_view - fileExtension() const noexcept override - { - return "html"; - } - - std::string_view - displayName() const noexcept override - { - return "HTML"; - } + HTMLGenerator(); void escape(OutputRef& os, std::string_view str) const override; diff --git a/src/lib/Support/Handlebars.cpp b/src/lib/Support/Handlebars.cpp index 41ccb9bf72..417e6038c5 100644 --- a/src/lib/Support/Handlebars.cpp +++ b/src/lib/Support/Handlebars.cpp @@ -303,18 +303,10 @@ HTMLEscape( OutputRef& out, std::string_view str) { + // Entity table lives in the public header so the HTML generator's + // `EscapeMap` can share it. Source convention follows handlebars.js: // https://github.com/handlebars-lang/handlebars.js/blob/master/lib/handlebars/utils.js - static constexpr std::pair - escapeMap[] = { - {'&', "&"}, - {'<', "<"}, - {'>', ">"}, - {'"', """}, - {'\'', "'"}, - {'`', "`"}, - {'=', "="} - }; - static constexpr auto badChars = std::views::keys(escapeMap); + static constexpr auto badChars = std::views::keys(htmlEscapeEntities); for (auto c : str) { if (auto it = std::ranges::find(badChars, c); it != badChars.end()) diff --git a/src/lib/Support/Path.cpp b/src/lib/Support/Path.cpp index 38fc0adbf9..f35861ae29 100644 --- a/src/lib/Support/Path.cpp +++ b/src/lib/Support/Path.cpp @@ -206,7 +206,7 @@ getFileText( std::istreambuf_iterator it(file); std::istreambuf_iterator const end; std::string text(it, end); - if(! file.good()) + if(file.fail() && ! file.eof()) return Unexpected(formatError("getFileText(\"{}\") returned \"{}\"", pathName, std::error_code(errno, std::generic_category()))); return text; diff --git a/src/test/Support/TestLayout.cpp b/src/test/Support/TestLayout.cpp index fc0e3d7b69..399195d58e 100644 --- a/src/test/Support/TestLayout.cpp +++ b/src/test/Support/TestLayout.cpp @@ -28,26 +28,46 @@ pathWithExtension( } // (anon) -/** Build the per-file layout and normalized settings with mode validation. */ -Expected -resolveTestLayout( +/** Read any per-file mrdocs.yml on top of the directory-level settings. */ +Expected +loadTestSettings( llvm::StringRef filePath, Config::Settings const& dirSettings, - llvm::StringRef generatorExtension, - ReferenceDirectories const& dirs, - Action action) + ReferenceDirectories const& dirs) { - Config::Settings fileSettings = dirSettings; - auto configPath = files::withExtension(filePath, "yml"); - bool const hasFileConfig = files::exists(configPath); - if (hasFileConfig) + LoadedTestSettings result; + result.settings = dirSettings; + result.dirMultipage = dirSettings.multipage; + std::string const configPath = files::withExtension(filePath, "yml"); + result.hasFileConfig = files::exists(configPath); + if (result.hasFileConfig) { - if (auto exp = Config::Settings::load_file(fileSettings, configPath, dirs); !exp) + Expected const exp = Config::Settings::load_file( + result.settings, configPath, dirs); + if (!exp) { return Unexpected(exp.error()); } } + return result; +} +/** Build the layout, prepare multipage outputs, and normalize settings. + The split from loadTestSettings lets the caller run addon-generator + discovery in between, so the chosen generator's file extension is + known when paths are computed. +*/ +Expected +buildTestLayout( + llvm::StringRef filePath, + LoadedTestSettings loaded, + llvm::StringRef generatorExtension, + ReferenceDirectories const& dirs, + Action action) +{ + bool const dirMultipage = loaded.dirMultipage; + Config::Settings fileSettings = std::move(loaded.settings); + bool const hasFileConfig = loaded.hasFileConfig; bool const hasTagfileOverride = !fileSettings.tagfile.empty(); TestLayout layout; @@ -118,7 +138,7 @@ resolveTestLayout( return Unexpected(Error("multipage tests require a per-file mrdocs.yml with multipage: true")); } - if (dirSettings.multipage) + if (dirMultipage) { return Unexpected(Error("multipage defaults must remain disabled at the directory level")); } diff --git a/src/test/Support/TestLayout.hpp b/src/test/Support/TestLayout.hpp index 6fab65b6b9..0d3784aa4b 100644 --- a/src/test/Support/TestLayout.hpp +++ b/src/test/Support/TestLayout.hpp @@ -43,11 +43,42 @@ struct ResolvedLayout TestLayout layout; }; -/** Resolve per-test settings + layout, enforcing single vs multipage rules. */ -Expected -resolveTestLayout( +/** Settings produced by loadTestSettings before the layout is built. +*/ +struct LoadedTestSettings +{ + Config::Settings settings; + /// True if a per-file mrdocs.yml was found and merged. + bool hasFileConfig = false; + /// Snapshot of the directory-level multipage flag before merging. + /// Used to enforce that multipage may only be enabled at the + /// per-file level. + bool dirMultipage = false; +}; + +/** Load any per-file mrdocs.yml on top of the directory-level settings. + + No layout work is done here: the per-file settings are needed before + the test's generator is known, so addon discovery can run against + the merged addons paths. +*/ +Expected +loadTestSettings( llvm::StringRef filePath, Config::Settings const& dirSettings, + ReferenceDirectories const& dirs); + +/** Build the per-file layout from already-loaded settings. + + Computes expected-output paths, applies multipage handling (creating + the temporary output directory and adjusting the settings' output and + tagfile fields), normalizes the settings, and validates the + single vs multipage invariants. +*/ +Expected +buildTestLayout( + llvm::StringRef filePath, + LoadedTestSettings loaded, llvm::StringRef generatorExtension, ReferenceDirectories const& dirs, Action action); diff --git a/src/test/TestRunner.cpp b/src/test/TestRunner.cpp index c3694e2fa5..841c9b7c47 100644 --- a/src/test/TestRunner.cpp +++ b/src/test/TestRunner.cpp @@ -5,6 +5,7 @@ // // Copyright (c) 2023 Vinnie Falco (vinnie.falco@gmail.com) // Copyright (c) 2023 Alan de Freitas (alandefreitas@gmail.com) +// Copyright (c) 2026 Gennaro Prota (gennaro.prota@gmail.com) // // Official repository: https://github.com/cppalliance/mrdocs // @@ -17,6 +18,7 @@ #include "Support/Comparison.hpp" #include #include +#include #include #include #include @@ -37,9 +39,10 @@ namespace mrdocs { TestRunner:: TestRunner(std::string_view generator) - : gen_(findGenerator(generator)) + : genId_(generator) { - MRDOCS_ASSERT(gen_ != nullptr); + // The generator is looked up per-test in `handleFile`; after that, + // test's mrdocs.yml has been loaded and addon discovery has run. } namespace { @@ -165,8 +168,37 @@ handleFile( if (!ensureRegularCpp(filePath)) return; - auto resolved = resolveTestLayout( - filePath, dirSettings, gen_->fileExtension(), dirs_, testArgs.action); + // Load the per-file mrdocs.yml first so addon-defined generators + // contributed via addons-supplemental are visible to discovery + // before the chosen generator is looked up. + // + // The generator registry is process-global and persists across + // tests. `discoverAddonGenerators` is idempotent (it skips ids + // already installed), so re-running it per fixture is safe; but + // it also means the first fixture that registers a given id + // wins, and a later fixture that ships a generator directory + // with the same id will see its own contents quietly ignored. + Expected loaded = + loadTestSettings(filePath, dirSettings, dirs_); + if (!loaded) + { + return report::error("{}: \"{}\"", loaded.error(), filePath); + } + Expected discovered = + hbs::discoverAddonGenerators(loaded->settings); + if (!discovered) + { + return report::error("{}: \"{}\"", discovered.error(), filePath); + } + Generator const* gen = findGenerator(genId_); + if (!gen) + { + return report::error( + "{}: the Generator \"{}\" was not found", filePath, genId_); + } + + Expected resolved = buildTestLayout( + filePath, *std::move(loaded), gen->fileExtension(), dirs_, testArgs.action); if (!resolved) { return report::error("{}: \"{}\"", resolved.error(), filePath); @@ -194,7 +226,7 @@ handleFile( db, config, defaultIncludePaths); - handleCompilationDatabase(filePath, compilations, config, layout); + handleCompilationDatabase(filePath, *gen, compilations, config, layout); }; runWith({ "clang", "-std=c++23" }); @@ -204,6 +236,7 @@ handleFile( void TestRunner::handleCompilationDatabase( llvm::StringRef filePath, + Generator const& gen, MrDocsCompilationDatabase const& compilations, std::shared_ptr const& config, TestLayout const& layout) @@ -219,7 +252,7 @@ TestRunner::handleCompilationDatabase( { test_support::SinglePageArgs args{ layout, - *gen_, + gen, **corpus, filePath, testArgs.action, @@ -237,7 +270,7 @@ TestRunner::handleCompilationDatabase( { test_support::MultipageArgs args{ layout, - *gen_, + gen, **corpus, testArgs.action, testArgs.forceOption.getValue(), diff --git a/src/test/TestRunner.hpp b/src/test/TestRunner.hpp index b7a02809d4..869f82d78b 100644 --- a/src/test/TestRunner.hpp +++ b/src/test/TestRunner.hpp @@ -5,6 +5,7 @@ // // Copyright (c) 2023 Vinnie Falco (vinnie.falco@gmail.com) // Copyright (c) 2023 Alan de Freitas (alandefreitas@gmail.com) +// Copyright (c) 2026 Gennaro Prota (gennaro.prota@gmail.com) // // Official repository: https://github.com/cppalliance/mrdocs // @@ -55,7 +56,10 @@ struct TestResults class TestRunner { ThreadPool threadPool_; - Generator const* gen_; + /// Id of the chosen generator. Resolved per-test (after each test's + /// settings load) so that addon-defined generators contributed via + /// addons-supplemental are picked up correctly. + std::string genId_; ReferenceDirectories dirs_; /** Run a single .cpp test file with inherited directory settings. */ @@ -80,6 +84,7 @@ class TestRunner void handleCompilationDatabase( llvm::StringRef filePath, + Generator const& gen, MrDocsCompilationDatabase const& compilations, std::shared_ptr const& config, TestLayout const& layout); diff --git a/src/test/lib/Gen/hbs/AddonGenerators.cpp b/src/test/lib/Gen/hbs/AddonGenerators.cpp new file mode 100644 index 0000000000..f9d01554d7 --- /dev/null +++ b/src/test/lib/Gen/hbs/AddonGenerators.cpp @@ -0,0 +1,237 @@ +// +// 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 + +namespace mrdocs::hbs { + +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())); +} + +// Apply `map` to `input` and return the escaped result, so tests can +// observe an `EscapeMap`'s contents through its public surface. +std::string +applyEscape(EscapeMap const& map, std::string_view input) +{ + std::string out; + OutputRef ref(out); + map.apply(ref, input); + return out; +} + +} // (anon) + +struct AddonGeneratorsTest +{ + // + // loadGeneratorMetadata + // + + void + testLoadEmptyFile() + { + ScopedTempDirectory td("mrdocs-addongen"); + BOOST_TEST(td); + std::string const path = + files::appendPath(td.path(), "g.yml"); + writeFile(path, ""); + + Expected result = loadGeneratorMetadata(path); + BOOST_TEST(result.has_value()); + if (result) + { + // Empty map: every char passes through. + BOOST_TEST(applyEscape(*result, "abc*_") == "abc*_"); + } + } + + void + testLoadNoEscapeKey() + { + ScopedTempDirectory td("mrdocs-addongen"); + BOOST_TEST(td); + std::string const path = + files::appendPath(td.path(), "g.yml"); + // Top-level mapping with an unknown key is fine: the schema + // explicitly tolerates extra keys for forward compatibility. + writeFile(path, "displayName: Markdown\n"); + + Expected result = loadGeneratorMetadata(path); + BOOST_TEST(result.has_value()); + if (result) + { + BOOST_TEST(applyEscape(*result, "abc") == "abc"); + } + } + + void + testLoadValidEscape() + { + ScopedTempDirectory td("mrdocs-addongen"); + BOOST_TEST(td); + std::string const path = + files::appendPath(td.path(), "g.yml"); + // Single-quoted YAML scalars treat backslash literally, so the + // value '\*' is the two-character string \*. + writeFile(path, + "escape:\n" + " '*': '\\*'\n" + " '_': '\\_'\n"); + + Expected result = loadGeneratorMetadata(path); + BOOST_TEST(result.has_value()); + if (result) + { + BOOST_TEST(applyEscape(*result, "*foo_bar*") == "\\*foo\\_bar\\*"); + BOOST_TEST(applyEscape(*result, "no specials") == "no specials"); + } + } + + void + testLoadNonMappingTopLevel() + { + ScopedTempDirectory td("mrdocs-addongen"); + BOOST_TEST(td); + std::string const path = + files::appendPath(td.path(), "g.yml"); + // Top-level scalar is rejected. + writeFile(path, "just a string\n"); + + Expected result = loadGeneratorMetadata(path); + BOOST_TEST(!result.has_value()); + } + + void + testLoadNonMappingEscape() + { + ScopedTempDirectory td("mrdocs-addongen"); + BOOST_TEST(td); + std::string const path = + files::appendPath(td.path(), "g.yml"); + // 'escape:' must be a mapping, not a scalar. + writeFile(path, "escape: nope\n"); + + Expected result = loadGeneratorMetadata(path); + BOOST_TEST(!result.has_value()); + } + + void + testLoadMultibyteKey() + { + ScopedTempDirectory td("mrdocs-addongen"); + BOOST_TEST(td); + std::string const path = + files::appendPath(td.path(), "g.yml"); + // Markdown wants `**bold**` to be a distinct token from a + // literal `*`. A two-byte rule for `**` plus a one-byte rule + // for `*` covers both, with the multi-byte rule taking + // precedence at a position where both could apply. + writeFile(path, + "escape:\n" + " '**': ''\n" + " '*': ''\n"); + + Expected result = loadGeneratorMetadata(path); + BOOST_TEST(result.has_value()); + if (result) + { + BOOST_TEST(applyEscape(*result, "**foo**") == "foo"); + BOOST_TEST(applyEscape(*result, "*bar*") == "bar"); + // A leftover lone `*` after a `**` match falls back to the + // single-byte rule. + BOOST_TEST(applyEscape(*result, "***") == ""); + } + } + + void + testLoadUtf8Codepoint() + { + ScopedTempDirectory td("mrdocs-addongen"); + BOOST_TEST(td); + std::string const path = + files::appendPath(td.path(), "g.yml"); + // 'é' is two bytes in UTF-8 (0xC3 0xA9). The whole sequence + // becomes the multi-byte source so the replacement happens + // as a unit instead of byte by byte. + writeFile(path, + "escape:\n" + " '\xC3\xA9': 'e'\n"); + + Expected result = loadGeneratorMetadata(path); + BOOST_TEST(result.has_value()); + if (result) + { + BOOST_TEST(applyEscape(*result, "caf\xC3\xA9") == "cafe"); + } + } + + void + testLoadEmptyKey() + { + ScopedTempDirectory td("mrdocs-addongen"); + BOOST_TEST(td); + std::string const path = + files::appendPath(td.path(), "g.yml"); + writeFile(path, + "escape:\n" + " '': 'x'\n"); + + Expected result = loadGeneratorMetadata(path); + BOOST_TEST(!result.has_value()); + } + + void + testLoadMissingFile() + { + ScopedTempDirectory td("mrdocs-addongen"); + BOOST_TEST(td); + std::string const path = + files::appendPath(td.path(), "does-not-exist.yml"); + + Expected result = loadGeneratorMetadata(path); + BOOST_TEST(!result.has_value()); + } + + void + run() + { + testLoadEmptyFile(); + testLoadNoEscapeKey(); + testLoadValidEscape(); + testLoadNonMappingTopLevel(); + testLoadNonMappingEscape(); + testLoadMultibyteKey(); + testLoadUtf8Codepoint(); + testLoadEmptyKey(); + testLoadMissingFile(); + } +}; + +TEST_SUITE( + AddonGeneratorsTest, + "clang.mrdocs.hbs.AddonGenerators"); + +} // namespace mrdocs::hbs diff --git a/src/tool/GenerateAction.cpp b/src/tool/GenerateAction.cpp index 3bf50b9bbf..080aead969 100644 --- a/src/tool/GenerateAction.cpp +++ b/src/tool/GenerateAction.cpp @@ -6,6 +6,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 // @@ -14,6 +15,7 @@ #include "ToolCompilationDatabase.hpp" #include #include +#include #include #include #include @@ -45,6 +47,17 @@ DoGenerateAction( std::shared_ptr config, ConfigImpl::load(publicSettings, dirs, threadPool)); + // -------------------------------------------------------------- + // + // 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. + MRDOCS_TRY(hbs::discoverAddonGenerators(config->settings())); + // -------------------------------------------------------------- // // Load generator @@ -53,10 +66,10 @@ DoGenerateAction( auto& settings = config->settings(); MRDOCS_TRY( Generator const& generator, - findGenerator(to_string(settings.generator)), + findGenerator(settings.generator), formatError( "the Generator \"{}\" was not found", - to_string(config->settings().generator))); + settings.generator)); // -------------------------------------------------------------- // diff --git a/test-files/template-only-generators/mock-md/addons/generator/mock-md/layouts/index.mock-md.hbs b/test-files/template-only-generators/mock-md/addons/generator/mock-md/layouts/index.mock-md.hbs new file mode 100644 index 0000000000..8105b4125f --- /dev/null +++ b/test-files/template-only-generators/mock-md/addons/generator/mock-md/layouts/index.mock-md.hbs @@ -0,0 +1 @@ +{{symbol.name}}: {{#each symbol.doc.brief.children}}{{literal}}{{/each}} diff --git a/test-files/template-only-generators/mock-md/addons/generator/mock-md/layouts/wrapper.mock-md.hbs b/test-files/template-only-generators/mock-md/addons/generator/mock-md/layouts/wrapper.mock-md.hbs new file mode 100644 index 0000000000..2a631060d4 --- /dev/null +++ b/test-files/template-only-generators/mock-md/addons/generator/mock-md/layouts/wrapper.mock-md.hbs @@ -0,0 +1 @@ +{{{contents}}} diff --git a/test-files/template-only-generators/mock-md/addons/generator/mock-md/mrdocs-generator.yml b/test-files/template-only-generators/mock-md/addons/generator/mock-md/mrdocs-generator.yml new file mode 100644 index 0000000000..38f79f3777 --- /dev/null +++ b/test-files/template-only-generators/mock-md/addons/generator/mock-md/mrdocs-generator.yml @@ -0,0 +1,3 @@ +escape: + '_': '\_' + 'TODO': '[!]' diff --git a/test-files/template-only-generators/mock-md/mrdocs.yml b/test-files/template-only-generators/mock-md/mrdocs.yml new file mode 100644 index 0000000000..af837fa4e7 --- /dev/null +++ b/test-files/template-only-generators/mock-md/mrdocs.yml @@ -0,0 +1,7 @@ +addons-supplemental: + - addons +generator: mock-md +multipage: false +show-namespaces: false +warn-if-undocumented: false +source-root: . diff --git a/test-files/template-only-generators/mock-md/simple.cpp b/test-files/template-only-generators/mock-md/simple.cpp new file mode 100644 index 0000000000..c5ce15ab3a --- /dev/null +++ b/test-files/template-only-generators/mock-md/simple.cpp @@ -0,0 +1,8 @@ +// A trivial input that exercises the addon-defined mock-md generator. +// The function name contains an underscore so the single-byte escape +// rule '_' -> '\_' fires on it; the doc-comment's brief begins with +// the literal token TODO so the multi-byte rule 'TODO' -> '[!]' fires +// there during rendering. + +/// TODO write me +void my_function(); diff --git a/test-files/template-only-generators/mock-md/simple.mock-md b/test-files/template-only-generators/mock-md/simple.mock-md new file mode 100644 index 0000000000..3024f52614 --- /dev/null +++ b/test-files/template-only-generators/mock-md/simple.mock-md @@ -0,0 +1,2 @@ +my\_function: [!] write me + diff --git a/util/generate-config-info.py b/util/generate-config-info.py index c9ecb3004c..90c8154e92 100644 --- a/util/generate-config-info.py +++ b/util/generate-config-info.py @@ -63,7 +63,6 @@ def get_flat_suboptions(option_name, options): def get_valid_enum_categories(): valid_enum_cats = { - 'generator': ["adoc", "html", "xml"], 'log-level': ["trace", "debug", "info", "warn", "error", "fatal"], 'base-member-inheritance': ["never", "reference", "copy-dependencies", "copy-all"], 'sort-symbol-by': ["name", "location"]