Skip to content

Commit c385baf

Browse files
dmealingclaude
andcommitted
feat(java-render): TemplateGenerator factory + EmittedFile + walk record
Java port of the rc.12 cross-port factory. Lives in the render module (where Provider + Renderer + RenderRequest already live), not in codegen-mustache — the render module has no metadata dependency, so the factory takes a generic <R> root type that the walk callback knows the actual type of. This keeps the render module's dependency graph unchanged. Does NOT implement the legacy com.metaobjects.generator.Generator interface — that interface has void execute(MetaDataLoader) which doesn't fit the declarative 'return List<EmittedFile>' cross-port shape. Adopters who want Maven plugin integration can wrap this in their own legacy-Generator-conformant glue. Maven plugin surface is a follow-up. 4 unit tests cover per-entity, aggregator, and format-driven escaping patterns (text vs html). Cross-port byte equivalence verified via the conformance adapter (next commit). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7338058 commit c385baf

4 files changed

Lines changed: 196 additions & 0 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.metaobjects.render.templategen;
2+
3+
/**
4+
* One emitted file produced by {@link TemplateGenerator}.
5+
* Cross-port mirror of TS {@code EmittedFile}, Python {@code EmittedFile},
6+
* C# {@code record EmittedFile(string Path, string Content)}.
7+
*
8+
* <p>This is NOT the legacy {@code com.metaobjects.generator.Generator} contract
9+
* (which writes via {@code execute()} side-effects); this record exists because
10+
* the cross-port template-generator factory returns its files declaratively.
11+
*/
12+
public record EmittedFile(String path, String content) {}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package com.metaobjects.render.templategen;
2+
3+
import com.metaobjects.render.Provider;
4+
import com.metaobjects.render.RenderRequest;
5+
import com.metaobjects.render.Renderer;
6+
7+
import java.util.ArrayList;
8+
import java.util.List;
9+
import java.util.function.Function;
10+
11+
/**
12+
* Java port of the rc.12 cross-port {@code templateGenerator()} factory.
13+
*
14+
* <p>Walks a caller-supplied root via the walk callback, renders the shared
15+
* Mustache template via {@link Renderer#render}, and returns
16+
* {@code List<EmittedFile>} — one entry per walk result.
17+
*
18+
* <p>The root type is generic ({@code <R>}) so this factory does not pull
19+
* the metadata package into the render module's dependency graph — the
20+
* walk callback knows the actual root type at the call site (typically
21+
* {@code com.metaobjects.MetaRoot}, but conformance tests can use any
22+
* test-shape).
23+
*
24+
* <p>Design: {@code spec/design-docs/2026-05-28-cross-port-template-generator.md}.
25+
* Cross-port byte-equivalence verified via the shared fixture corpus at
26+
* {@code fixtures/render-conformance/template-generator/}.
27+
*
28+
* <p>Note: this factory does NOT implement the legacy
29+
* {@code com.metaobjects.generator.Generator} interface — that interface
30+
* has {@code void execute(MetaDataLoader)} which doesn't fit the
31+
* declarative "return files" cross-port shape. Adopters who want Maven
32+
* plugin integration can wrap this factory in their own
33+
* legacy-Generator-conformant glue.
34+
*/
35+
public final class TemplateGenerator {
36+
37+
private TemplateGenerator() {}
38+
39+
/** Convenience static factory mirroring TS/Python/C# signatures. */
40+
public static <R> List<EmittedFile> generate(
41+
String name,
42+
String template,
43+
Function<R, List<TemplateWalkResult>> walk,
44+
Provider provider,
45+
String format,
46+
R root) {
47+
Renderer renderer = new Renderer();
48+
List<EmittedFile> out = new ArrayList<>();
49+
for (TemplateWalkResult entry : walk.apply(root)) {
50+
RenderRequest req = new RenderRequest(
51+
/* template */ null,
52+
/* ref */ template,
53+
/* payload */ entry.data(),
54+
/* provider */ provider,
55+
/* format */ format,
56+
/* verify */ null,
57+
/* maxChars */ null);
58+
String content = renderer.render(req);
59+
out.add(new EmittedFile(entry.outputPath(), content));
60+
}
61+
return out;
62+
}
63+
64+
/** Overload defaulting format to "text". */
65+
public static <R> List<EmittedFile> generate(
66+
String name,
67+
String template,
68+
Function<R, List<TemplateWalkResult>> walk,
69+
Provider provider,
70+
R root) {
71+
return generate(name, template, walk, provider, "text", root);
72+
}
73+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.metaobjects.render.templategen;
2+
3+
/**
4+
* One walk entry: a payload + the output path the rendered template should
5+
* be emitted to (relative to whatever output root the caller chooses).
6+
*/
7+
public record TemplateWalkResult(Object data, String outputPath) {}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package com.metaobjects.render.templategen;
2+
3+
import com.metaobjects.render.InMemoryProvider;
4+
import com.metaobjects.render.Provider;
5+
import org.junit.Test;
6+
7+
import java.util.LinkedHashMap;
8+
import java.util.List;
9+
import java.util.Map;
10+
11+
import static org.junit.Assert.assertEquals;
12+
import static org.junit.Assert.assertNotEquals;
13+
import static org.junit.Assert.assertTrue;
14+
15+
/**
16+
* Unit tests for the Java TemplateGenerator factory.
17+
* Mirrors C# TemplateGeneratorTests, Python test_template_generator.py,
18+
* TS template-generator.test.ts.
19+
*
20+
* <p>These tests use plain Map-based "root" payloads instead of
21+
* com.metaobjects.MetaRoot because (a) the factory is generic over root
22+
* type, and (b) the render module doesn't depend on the metadata module.
23+
* The cross-port conformance harness in TemplateGeneratorConformanceTest
24+
* uses the same approach.
25+
*/
26+
public class TemplateGeneratorTest {
27+
28+
private static Provider provider(Map<String, String> map) {
29+
return new InMemoryProvider(map);
30+
}
31+
32+
@Test
33+
public void perEntityWalkEmitsOneFilePerEntity() {
34+
// The "root" here is a simple list of entities — mirrors what a real
35+
// MetaRoot.getChildrenOfType("object") would yield, without dragging
36+
// in the metadata module.
37+
List<String> entityNames = List.of("Post");
38+
39+
List<EmittedFile> files = TemplateGenerator.generate(
40+
"hello",
41+
"custom/hello",
42+
(List<String> names) -> names.stream()
43+
.map(n -> new TemplateWalkResult(Map.of("name", n), n + ".txt"))
44+
.toList(),
45+
provider(Map.of("custom/hello", "Hello {{name}}!\n")),
46+
entityNames);
47+
48+
assertEquals(1, files.size());
49+
assertEquals("Post.txt", files.get(0).path());
50+
assertEquals("Hello Post!\n", files.get(0).content());
51+
}
52+
53+
@Test
54+
public void aggregatorWalkEmitsSingleFileFromAllEntities() {
55+
List<String> entityNames = List.of("Post", "Comment");
56+
57+
List<EmittedFile> files = TemplateGenerator.generate(
58+
"index",
59+
"custom/index",
60+
(List<String> names) -> {
61+
List<Map<String, String>> entities = names.stream()
62+
.map(n -> Map.of("name", n))
63+
.toList();
64+
return List.of(new TemplateWalkResult(
65+
Map.of("entities", entities), "index.txt"));
66+
},
67+
provider(Map.of("custom/index",
68+
"Entities:\n{{#entities}}- {{name}}\n{{/entities}}")),
69+
entityNames);
70+
71+
assertEquals(1, files.size());
72+
assertEquals("index.txt", files.get(0).path());
73+
assertEquals("Entities:\n- Post\n- Comment\n", files.get(0).content());
74+
}
75+
76+
@Test
77+
public void formatTextDoesNotEscapeHtml() {
78+
List<EmittedFile> files = TemplateGenerator.generate(
79+
"raw-text",
80+
"custom/raw",
81+
(Object root) -> List.of(new TemplateWalkResult(
82+
Map.of("snippet", "<p>hi</p>"), "out.txt")),
83+
provider(Map.of("custom/raw", "{{snippet}}\n")),
84+
"text",
85+
new Object());
86+
assertEquals("<p>hi</p>\n", files.get(0).content());
87+
}
88+
89+
@Test
90+
public void formatHtmlEscapesHtmlInPayload() {
91+
List<EmittedFile> files = TemplateGenerator.generate(
92+
"raw-html",
93+
"custom/raw",
94+
(Object root) -> List.of(new TemplateWalkResult(
95+
Map.of("snippet", "<p>hi</p>"), "out.html")),
96+
provider(Map.of("custom/raw", "{{snippet}}\n")),
97+
"html",
98+
new Object());
99+
String content = files.get(0).content();
100+
assertNotEquals("<p>hi</p>\n", content);
101+
assertTrue("expected HTML escape in: " + content,
102+
content.contains("&lt;") || content.contains("&#60;"));
103+
}
104+
}

0 commit comments

Comments
 (0)