Skip to content

Commit 47db0f5

Browse files
committed
feat: support data-driven Handlebars generators
An <addon>/generator/<name>/ directory that ships an mrdocs-generator.yml file is now installed as an additional `HandlebarsGenerator` at config-resolve time. The manifest's mere presence is the explicit opt-in; its content is read for escape rules. An empty file is valid. This way, a generator can be added without writing any C++.
1 parent 37b33c1 commit 47db0f5

7 files changed

Lines changed: 515 additions & 17 deletions

File tree

docs/mrdocs.schema.json

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -252,13 +252,9 @@
252252
},
253253
"generator": {
254254
"default": "adoc",
255-
"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.",
256-
"enum": [
257-
"adoc",
258-
"html",
259-
"xml"
260-
],
261-
"title": "Generator used to create the documentation"
255+
"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 <addon>/generator/<name>/.",
256+
"title": "Generator used to create the documentation",
257+
"type": "string"
262258
},
263259
"global-namespace-index": {
264260
"default": true,

src/lib/ConfigOptions.json

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -397,13 +397,8 @@
397397
{
398398
"name": "generator",
399399
"brief": "Generator used to create the documentation",
400-
"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.",
401-
"type": "enum",
402-
"values": [
403-
"adoc",
404-
"html",
405-
"xml"
406-
],
400+
"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 <addon>/generator/<name>/.",
401+
"type": "string",
407402
"default": "adoc"
408403
},
409404
{
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
//
2+
// Licensed under the Apache License v2.0 with LLVM Exceptions.
3+
// See https://llvm.org/LICENSE.txt for license information.
4+
// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
5+
//
6+
// Copyright (c) 2026 Gennaro Prota (gennaro.prota@gmail.com)
7+
//
8+
// Official repository: https://github.com/cppalliance/mrdocs
9+
//
10+
11+
#include "DataDrivenGenerators.hpp"
12+
#include "AddonPaths.hpp"
13+
#include "HandlebarsGenerator.hpp"
14+
#include <mrdocs/Generator.hpp>
15+
#include <mrdocs/Support/Path.hpp>
16+
#include <llvm/ADT/SmallString.h>
17+
#include <llvm/Support/Casting.h>
18+
#include <llvm/Support/SourceMgr.h>
19+
#include <llvm/Support/YAMLParser.h>
20+
#include <filesystem>
21+
#include <memory>
22+
#include <string>
23+
#include <string_view>
24+
25+
namespace mrdocs::hbs {
26+
27+
namespace {
28+
29+
constexpr std::string_view metadataFileName = "mrdocs-generator.yml";
30+
31+
// Populate `map` from a YAML mapping whose entries are non-empty
32+
// byte-sequence keys mapped to replacement strings. An empty key
33+
// is a hard error.
34+
Expected<void>
35+
populateEscapeFromMapping(
36+
llvm::yaml::MappingNode& node,
37+
EscapeMap& map,
38+
std::string_view yamlPath)
39+
{
40+
for (llvm::yaml::KeyValueNode& entry : node)
41+
{
42+
llvm::yaml::ScalarNode* keyNode =
43+
llvm::dyn_cast_or_null<llvm::yaml::ScalarNode>(entry.getKey());
44+
llvm::yaml::ScalarNode* valNode =
45+
llvm::dyn_cast_or_null<llvm::yaml::ScalarNode>(entry.getValue());
46+
if (!keyNode || !valNode)
47+
{
48+
return Unexpected(formatError(
49+
"{}: each 'escape' entry must be a scalar->scalar mapping",
50+
yamlPath));
51+
}
52+
llvm::SmallString<8> keyBuf;
53+
llvm::SmallString<32> valBuf;
54+
llvm::StringRef const keyStr = keyNode->getValue(keyBuf);
55+
llvm::StringRef const valStr = valNode->getValue(valBuf);
56+
if (keyStr.empty())
57+
{
58+
return Unexpected(formatError(
59+
"{}: escape key must not be empty",
60+
yamlPath));
61+
}
62+
map.set(
63+
std::string_view(keyStr.data(), keyStr.size()),
64+
std::string_view(valStr.data(), valStr.size()));
65+
}
66+
return {};
67+
}
68+
69+
// Install a HandlebarsGenerator for the data-driven format in `dir`,
70+
// when `dir` opts in by shipping an `mrdocs-generator.yml`.
71+
//
72+
// The presence of the manifest is the explicit opt-in: a directory
73+
// under <addons>/generator/ becomes a generator only when it ships
74+
// this file. Directories that hold shared assets (the built-in
75+
// `common/` is the canonical example) simply don't declare a manifest,
76+
// and discovery skips them.
77+
//
78+
// The generator registry is process-global and is not cleared between
79+
// runs in the same process. `installGenerator` fails when the id is
80+
// already taken, whether by a built-in or by a generator an earlier
81+
// addon root installed under the same name. That is the
82+
// first-writer-wins layering we want, so a duplicate id is a silent
83+
// skip rather than an error (a null generator is the only other
84+
// failure it reports, and we never pass one). In the test executable
85+
// this also means the first test to install an id wins for the rest
86+
// of the process; two fixtures cannot ship competing generators of
87+
// the same name.
88+
Expected<void>
89+
maybeRegister(std::filesystem::path const& dir)
90+
{
91+
std::string const yamlPath = files::appendPath(
92+
dir.string(), std::string(metadataFileName));
93+
if (!files::exists(yamlPath))
94+
{
95+
return {};
96+
}
97+
std::string const name = dir.filename().string();
98+
MRDOCS_TRY(EscapeMap escapeMap, loadGeneratorMetadata(yamlPath));
99+
(void)installGenerator(
100+
std::make_unique<HandlebarsGenerator>(
101+
name, name, name, std::move(escapeMap)));
102+
return {};
103+
}
104+
105+
// Scan a single <root>/generator/ directory.
106+
Expected<void>
107+
scanGeneratorDir(std::string_view generatorDir)
108+
{
109+
namespace fs = std::filesystem;
110+
std::error_code iterEc;
111+
fs::directory_iterator const end{};
112+
for (fs::directory_iterator it(generatorDir, iterEc);
113+
!iterEc && it != end;
114+
it.increment(iterEc))
115+
{
116+
std::error_code typeEc;
117+
if (!it->is_directory(typeEc))
118+
{
119+
continue;
120+
}
121+
MRDOCS_TRY(maybeRegister(it->path()));
122+
}
123+
return {};
124+
}
125+
126+
} // (anon)
127+
128+
Expected<EscapeMap>
129+
loadGeneratorMetadata(std::string_view yamlPath)
130+
{
131+
MRDOCS_TRY(std::string text, files::getFileText(yamlPath));
132+
llvm::SourceMgr sm;
133+
llvm::yaml::Stream stream(text, sm);
134+
135+
EscapeMap map;
136+
llvm::yaml::document_iterator docIt = stream.begin();
137+
if (docIt == stream.end())
138+
{
139+
return map;
140+
}
141+
llvm::yaml::Node* const rootNode = docIt->getRoot();
142+
if (rootNode == nullptr ||
143+
llvm::isa<llvm::yaml::NullNode>(rootNode))
144+
{
145+
// Empty document: file with no content, only comments, or a
146+
// literal `null`. All of these mean "no rules".
147+
return map;
148+
}
149+
llvm::yaml::MappingNode* const root =
150+
llvm::dyn_cast<llvm::yaml::MappingNode>(rootNode);
151+
if (!root)
152+
{
153+
return Unexpected(formatError(
154+
"{}: top-level YAML node must be a mapping", yamlPath));
155+
}
156+
157+
for (llvm::yaml::KeyValueNode& pair : *root)
158+
{
159+
llvm::yaml::ScalarNode* const keyNode =
160+
llvm::dyn_cast_or_null<llvm::yaml::ScalarNode>(pair.getKey());
161+
if (!keyNode)
162+
{
163+
continue;
164+
}
165+
llvm::SmallString<16> keyBuf;
166+
if (keyNode->getValue(keyBuf) != "escape")
167+
{
168+
continue;
169+
}
170+
llvm::yaml::MappingNode* const escNode =
171+
llvm::dyn_cast_or_null<llvm::yaml::MappingNode>(pair.getValue());
172+
if (!escNode)
173+
{
174+
return Unexpected(formatError(
175+
"{}: 'escape' must be a mapping", yamlPath));
176+
}
177+
MRDOCS_TRY(populateEscapeFromMapping(*escNode, map, yamlPath));
178+
}
179+
return map;
180+
}
181+
182+
Expected<void>
183+
discoverDataDrivenGenerators(Config::Settings const& settings)
184+
{
185+
std::vector<std::string> const roots = addon_paths::addonRoots(settings);
186+
for (std::string const& root : roots)
187+
{
188+
std::string const dir = files::appendPath(root, "generator");
189+
if (!files::exists(dir))
190+
{
191+
continue;
192+
}
193+
MRDOCS_TRY(scanGeneratorDir(dir));
194+
}
195+
return {};
196+
}
197+
198+
} // namespace mrdocs::hbs
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
//
2+
// Licensed under the Apache License v2.0 with LLVM Exceptions.
3+
// See https://llvm.org/LICENSE.txt for license information.
4+
// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
5+
//
6+
// Copyright (c) 2026 Gennaro Prota (gennaro.prota@gmail.com)
7+
//
8+
// Official repository: https://github.com/cppalliance/mrdocs
9+
//
10+
11+
#ifndef MRDOCS_LIB_GEN_HBS_DATADRIVENGENERATORS_HPP
12+
#define MRDOCS_LIB_GEN_HBS_DATADRIVENGENERATORS_HPP
13+
14+
#include <lib/Gen/hbs/HandlebarsGenerator.hpp>
15+
#include <mrdocs/Config.hpp>
16+
#include <mrdocs/Support/Error.hpp>
17+
#include <string_view>
18+
19+
namespace mrdocs::hbs {
20+
21+
/** Discover data-driven Handlebars generators and install them.
22+
23+
For each configured addon root, walk the immediate subdirectories of
24+
<root>/generator/. A subdirectory <name> is treated as a
25+
data-driven generator when:
26+
27+
1. No generator with id `<name>` is already registered (so the
28+
built-in `html` and `adoc` generators take precedence over their
29+
addon directories of the same name).
30+
31+
2. It ships an `mrdocs-generator.yml` file. The file's presence is
32+
the explicit opt-in; directories that hold only shared assets
33+
(the built-in `common/` is the canonical example) don't declare
34+
a manifest and are skipped.
35+
36+
For each accepted directory, a `HandlebarsGenerator` is constructed
37+
with id, file extension, and display name all set to `<name>`, and
38+
installed into the global registry. Escape rules are read from
39+
<name>/mrdocs-generator.yml (see the file format documentation).
40+
41+
Should be called once after the configuration is resolved and before
42+
a generator is looked up by id.
43+
*/
44+
Expected<void>
45+
discoverDataDrivenGenerators(Config::Settings const& settings);
46+
47+
/** Load mrdocs-generator.yml and return the resulting `EscapeMap`.
48+
49+
The file is expected to contain a top-level mapping. The optional
50+
'escape:' key holds a sub-mapping from byte-sequence keys to
51+
replacement strings. Keys may be one or more bytes long; an empty
52+
key is a hard error. Unknown top-level keys are ignored so future
53+
schema additions are non-breaking.
54+
*/
55+
Expected<EscapeMap>
56+
loadGeneratorMetadata(std::string_view yamlPath);
57+
58+
} // namespace mrdocs::hbs
59+
60+
#endif

0 commit comments

Comments
 (0)