diff --git a/proguard.conf b/proguard.conf index ee5d9127..ae3da8cb 100644 --- a/proguard.conf +++ b/proguard.conf @@ -16,6 +16,11 @@ public static ** valueOf(java.lang.String); } +# The web export uses some tricky reflection +-keep class guideme.internal.web.* { + *; +} + # Keep the site export -keep class guideme.internal.web.StaticSiteGenerator { static void main(java.lang.String[]); diff --git a/src/main/java/guideme/internal/siteexport/SiteExporter.java b/src/main/java/guideme/internal/siteexport/SiteExporter.java index fcc54a7d..c0bd9d50 100644 --- a/src/main/java/guideme/internal/siteexport/SiteExporter.java +++ b/src/main/java/guideme/internal/siteexport/SiteExporter.java @@ -13,6 +13,7 @@ import guideme.internal.siteexport.mdastpostprocess.PageExportPostProcessor; import guideme.internal.util.Platform; import guideme.navigation.NavigationNode; +import guideme.siteexport.AdditionalResourceExporter; import guideme.siteexport.ExportableResourceProvider; import guideme.siteexport.RecipeExporter; import guideme.siteexport.ResourceExporter; @@ -418,6 +419,14 @@ public void export() throws Exception { ExportableResourceProvider.visit(compiledPage.document(), SiteExporter.this); } + for (var additionalResourceExporter : guide.getExtensions().get(AdditionalResourceExporter.EXTENSION_POINT)) { + additionalResourceExporter.addResources(guide, this); + } + + for (var entry : extraData.entrySet()) { + indexWriter.addModData(entry.getKey().toString(), entry.getValue()); + } + dumpRecipes(indexWriter); processItems(client, indexWriter, outputFolder); diff --git a/src/main/java/guideme/internal/siteexport/model/FluidInfoJson.java b/src/main/java/guideme/internal/siteexport/model/FluidInfoJson.java index 411b3535..968f381e 100644 --- a/src/main/java/guideme/internal/siteexport/model/FluidInfoJson.java +++ b/src/main/java/guideme/internal/siteexport/model/FluidInfoJson.java @@ -1,7 +1,19 @@ package guideme.internal.siteexport.model; -public class FluidInfoJson { +import guideme.siteexport.web.ExportedFluidInfo; + +public class FluidInfoJson implements ExportedFluidInfo { public String id; public String displayName; public String icon; + + @Override + public String icon() { + return icon; + } + + @Override + public String displayName() { + return displayName; + } } diff --git a/src/main/java/guideme/internal/siteexport/model/ItemInfoJson.java b/src/main/java/guideme/internal/siteexport/model/ItemInfoJson.java index 7a96a296..f468752a 100644 --- a/src/main/java/guideme/internal/siteexport/model/ItemInfoJson.java +++ b/src/main/java/guideme/internal/siteexport/model/ItemInfoJson.java @@ -1,10 +1,20 @@ package guideme.internal.siteexport.model; -import guideme.siteexport.ExportedItemInfo; +import guideme.siteexport.web.ExportedItemInfo; public class ItemInfoJson implements ExportedItemInfo { public String id; public String displayName; public String rarity; public String icon; + + @Override + public String icon() { + return icon; + } + + @Override + public String displayName() { + return displayName; + } } diff --git a/src/main/java/guideme/internal/web/AttributeMapper.java b/src/main/java/guideme/internal/web/AttributeMapper.java index ba07c1cc..3c97537c 100644 --- a/src/main/java/guideme/internal/web/AttributeMapper.java +++ b/src/main/java/guideme/internal/web/AttributeMapper.java @@ -2,6 +2,7 @@ import guideme.libs.mdast.mdx.model.MdxJsxAttribute; import guideme.libs.mdast.mdx.model.MdxJsxElementFields; +import guideme.siteexport.DefaultValue; import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.RecordComponent; diff --git a/src/main/java/guideme/internal/web/CustomElementWebRenderingContextImpl.java b/src/main/java/guideme/internal/web/CustomElementWebRenderingContextImpl.java new file mode 100644 index 00000000..f0bac455 --- /dev/null +++ b/src/main/java/guideme/internal/web/CustomElementWebRenderingContextImpl.java @@ -0,0 +1,32 @@ +package guideme.internal.web; + +import guideme.libs.mdast.mdx.model.MdxJsxElementFields; +import guideme.libs.mdast.model.MdAstParent; +import guideme.siteexport.web.CustomElementWebRenderingContext; + +class CustomElementWebRenderingContextImpl extends WebRenderingContextImpl implements CustomElementWebRenderingContext { + private final MdxJsxElementFields fields; + + public CustomElementWebRenderingContextImpl(WebPageCompiler webPageCompiler, + WebPageCompileContext context, + MdxJsxElementFields fields, + MdAstParent node) { + super(context, webPageCompiler, node); + this.fields = fields; + } + + @Override + public String tagName() { + return fields.name(); + } + + @Override + public MdxJsxElementFields element() { + return fields; + } + + @Override + public T map(Class modelClass) { + return JsxAttributeMapper.map(fields, modelClass); + } +} diff --git a/src/main/java/guideme/internal/web/ExportedGuide.java b/src/main/java/guideme/internal/web/ExportedGuideImpl.java similarity index 91% rename from src/main/java/guideme/internal/web/ExportedGuide.java rename to src/main/java/guideme/internal/web/ExportedGuideImpl.java index 86c0eead..705e0a54 100644 --- a/src/main/java/guideme/internal/web/ExportedGuide.java +++ b/src/main/java/guideme/internal/web/ExportedGuideImpl.java @@ -2,10 +2,12 @@ import com.google.gson.JsonElement; import guideme.internal.siteexport.model.ExportedPageJson; +import guideme.internal.siteexport.model.FluidInfoJson; import guideme.internal.siteexport.model.IndexModel; import guideme.internal.siteexport.model.ItemInfoJson; import guideme.internal.siteexport.model.NavigationNodeJson; import guideme.internal.siteexport.model.SiteExportJson; +import guideme.siteexport.web.ExportedGuide; import java.net.URI; import java.nio.file.Path; import java.util.ArrayList; @@ -16,7 +18,7 @@ import java.util.function.BiConsumer; import org.jspecify.annotations.Nullable; -class ExportedGuide { +class ExportedGuideImpl implements ExportedGuide { private final IndexModel index; private final SiteExportJson json; private final Map pageByItemIndex = new HashMap<>(); @@ -30,7 +32,7 @@ class ExportedGuide { private final Map> recipesByResult = new HashMap<>(); - public ExportedGuide(IndexModel index, SiteExportJson json) { + public ExportedGuideImpl(IndexModel index, SiteExportJson json) { this.index = index; this.json = json; @@ -91,6 +93,7 @@ private void visitIndex(String indexName, BiConsumer visito } } + @Override public String getDefaultNamespace() { return json.defaultNamespace; } @@ -211,12 +214,13 @@ public String getPageUrlForItem(String itemId) { return this.pageByItemIndex.get(itemId); } - @Nullable - public ItemInfoJson tryGetItemInfo(String itemId) { + @Override + public @Nullable ItemInfoJson tryGetItemInfo(String itemId) { itemId = this.resolveId(itemId); return json.items.get(itemId); } + @Override public ItemInfoJson getItemInfo(String itemId) { var item = tryGetItemInfo(itemId); if (item == null) { @@ -225,6 +229,26 @@ public ItemInfoJson getItemInfo(String itemId) { return item; } + @Override + public @Nullable FluidInfoJson tryGetFluidInfo(String fluidId) { + fluidId = this.resolveId(fluidId); + return json.fluids.get(fluidId); + } + + @Override + public FluidInfoJson getFluidInfo(String fluidId) { + var fluid = tryGetFluidInfo(fluidId); + if (fluid == null) { + throw new IllegalArgumentException("Couldn't find fluid '" + fluidId + "'"); + } + return fluid; + } + + @Override + public Object getExtraData(String identifier) { + return json.modData.get(identifier); + } + public String getGuideTitle() { return "Applied Energistics 2"; } diff --git a/src/main/java/guideme/internal/web/ExportedRecipe.java b/src/main/java/guideme/internal/web/ExportedRecipe.java index 2c99107a..b8dbe29f 100644 --- a/src/main/java/guideme/internal/web/ExportedRecipe.java +++ b/src/main/java/guideme/internal/web/ExportedRecipe.java @@ -4,7 +4,7 @@ import com.google.gson.JsonObject; import java.util.Map; -record ExportedRecipe(String id, JsonObject recipe) implements guideme.siteexport.ExportedRecipe { +record ExportedRecipe(String id, JsonObject recipe) implements guideme.siteexport.web.ExportedRecipe { public String type() { return recipe.getAsJsonPrimitive("type").getAsString(); } diff --git a/src/main/java/guideme/internal/web/GameSceneAttributes.java b/src/main/java/guideme/internal/web/GameSceneAttributes.java index 0d331d89..ee49b4a3 100644 --- a/src/main/java/guideme/internal/web/GameSceneAttributes.java +++ b/src/main/java/guideme/internal/web/GameSceneAttributes.java @@ -1,5 +1,6 @@ package guideme.internal.web; +import guideme.siteexport.DefaultValue; import org.jspecify.annotations.Nullable; public record GameSceneAttributes( diff --git a/src/main/java/guideme/internal/web/HtmlUtils.java b/src/main/java/guideme/internal/web/HtmlUtils.java index 283850d8..0209acfc 100644 --- a/src/main/java/guideme/internal/web/HtmlUtils.java +++ b/src/main/java/guideme/internal/web/HtmlUtils.java @@ -1,9 +1,5 @@ package guideme.internal.web; -import java.util.Map; -import java.util.Set; -import org.jspecify.annotations.Nullable; - final class HtmlUtils { static String escapeHtml(String text) { if (text == null) { @@ -17,7 +13,7 @@ static String escapeHtml(String text) { .replace("'", "'"); } - static String escapeAttribute(String text) { + public static String escapeAttribute(String text) { if (text == null) { return ""; } @@ -27,32 +23,6 @@ static String escapeAttribute(String text) { .replace("'", "'"); } - private static final Set VOID_ELEMENTS = Set.of("area", "base", "br", "col", "embed", "hr", "img", "input", - "link", "meta", "source", "track", "wbr"); - - static String createHtmlElement(String tag, Map attributes) { - return createHtmlElement(tag, attributes, null); - } - - static String createHtmlElement(String tag, Map attributes, @Nullable String content) { - StringBuilder sb = new StringBuilder(); - sb.append("<").append(tag); - for (var entry : attributes.entrySet()) { - sb.append(" ").append(entry.getKey()).append("=\"") - .append(escapeAttribute(String.valueOf(entry.getValue()))).append("\""); - } - if (VOID_ELEMENTS.contains(tag) && content == null) { - sb.append("/>"); - } else { - sb.append(">"); - if (content != null) { - sb.append(content); - } - sb.append(""); - } - return sb.toString(); - } - static String guiScaledDimension(Number value) { return "calc(" + value + "px * var(--gui-scale))"; } diff --git a/src/main/java/guideme/internal/web/LayoutPlaceholders.java b/src/main/java/guideme/internal/web/LayoutPlaceholders.java index 5b0abf97..4edf0908 100644 --- a/src/main/java/guideme/internal/web/LayoutPlaceholders.java +++ b/src/main/java/guideme/internal/web/LayoutPlaceholders.java @@ -1,9 +1,10 @@ package guideme.internal.web; +import guideme.siteexport.web.HTMLFragment; import java.nio.file.Path; record LayoutPlaceholders( Path destinationFolder, String pageTitle, - String pageContent) { + HTMLFragment pageContent) { } diff --git a/src/main/java/guideme/internal/web/RecipeRenderers.java b/src/main/java/guideme/internal/web/RecipeRenderers.java index ce1f6b1e..1c078d07 100644 --- a/src/main/java/guideme/internal/web/RecipeRenderers.java +++ b/src/main/java/guideme/internal/web/RecipeRenderers.java @@ -1,12 +1,10 @@ package guideme.internal.web; -import static guideme.internal.web.HtmlUtils.createHtmlElement; - -import guideme.siteexport.ExportedRecipe; -import guideme.siteexport.RecipeWebRenderer; -import guideme.siteexport.RecipeWebRenderingContext; +import guideme.siteexport.web.ExportedRecipe; +import guideme.siteexport.web.HTMLNode; +import guideme.siteexport.web.RecipeWebRenderer; +import guideme.siteexport.web.RecipeWebRenderingContext; import java.util.List; -import java.util.Map; import java.util.Set; final class CraftingRecipeRenderer implements RecipeWebRenderer { @@ -48,10 +46,10 @@ public void render(RecipeWebRenderingContext builder, ExportedRecipe recipe) { } builder.recipeBox(craftingStation, "Smelting", recipe.resultItem()) - .rawHtml(createHtmlElement( - "div", - Map.of("class", "smelting-input-box"), - builder.slotHtml(ingredient) + "\n" + createHtmlElement("div", Map.of("class", "fire")))) + .append(HTMLNode.tag("div") + .setClassName("smelting-input-box") + .append(builder.slotHtml(ingredient)) + .append(HTMLNode.tag("div").setClassName("fire"))) .arrow() .slot(recipe.resultItem()) .build(); diff --git a/src/main/java/guideme/internal/web/RecipeWebRenderingContextImpl.java b/src/main/java/guideme/internal/web/RecipeWebRenderingContextImpl.java index ede33d48..cf19d0a9 100644 --- a/src/main/java/guideme/internal/web/RecipeWebRenderingContextImpl.java +++ b/src/main/java/guideme/internal/web/RecipeWebRenderingContextImpl.java @@ -1,63 +1,65 @@ package guideme.internal.web; -import static guideme.internal.web.HtmlUtils.createHtmlElement; - import guideme.internal.siteexport.model.ItemInfoJson; import guideme.libs.mdast.model.MdAstParent; -import guideme.siteexport.ExportedItemInfo; -import guideme.siteexport.RecipeWebRenderingContext; +import guideme.siteexport.web.HTMLFragment; +import guideme.siteexport.web.HTMLNode; +import guideme.siteexport.web.HTMLTag; +import guideme.siteexport.web.RecipeWebRenderingContext; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; -import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; import org.jspecify.annotations.Nullable; -class RecipeWebRenderingContextImpl implements RecipeWebRenderingContext { +class RecipeWebRenderingContextImpl extends WebRenderingContextImpl implements RecipeWebRenderingContext { private final WebPageCompiler webPageCompiler; private final WebPageCompileContext context; private final MdAstParent node; private final ExportedRecipe recipe; - private final StringBuilder result = new StringBuilder(); + private final Consumer output; public RecipeWebRenderingContextImpl(WebPageCompiler webPageCompiler, WebPageCompileContext context, MdAstParent node, - ExportedRecipe recipe) { + ExportedRecipe recipe, + Consumer output) { + super(context, webPageCompiler, node); this.webPageCompiler = webPageCompiler; this.context = context; this.node = node; this.recipe = recipe; - } - - public String getResult() { - return result.toString(); + this.output = output; } @Override - public ExportedItemInfo getExportedItem(String itemId) { - return context.guide().getItemInfo(itemId); - } - - @Override - public String slotHtml(String itemId) { + public HTMLNode slotHtml(String itemId) { return slotHtml(List.of(itemId)); } @Override - public String slotHtml(List itemIds) { + public HTMLNode slotHtml(List itemIds) { return createRecipeIngredient(context, node, itemIds); } @Override - public String arrowHtml() { + public HTMLNode arrowHtml() { return createRecipeArrow(); } @Override - public RecipeBoxBuilder recipeBox(String craftingStationItemId, String header, String tooltipItemId) { + public RecipeBoxBuilder recipeBox(String craftingStationItemId, String title, String tooltipItemId) { var tooltipItem = context.guide().getItemInfo(tooltipItemId); + return recipeBox( + createRecipeDisplayName(context, node, craftingStationItemId, title, tooltipItem)); + } - var recipeBoxContent = new StringBuilder(); - recipeBoxContent.append(createRecipeDisplayName(context, node, craftingStationItemId, header, tooltipItem)); + @Override + public RecipeBoxBuilder recipeBox(HTMLNode header) { + + var recipeBoxContent = new HTMLFragment(); + recipeBoxContent.append(header); return new RecipeBoxBuilder() { @Override @@ -84,26 +86,74 @@ public RecipeBoxBuilder arrow() { } @Override - public RecipeBoxBuilder slot(String itemId) { - recipeBoxContent.append(createRecipeIngredient(context, node, List.of(itemId))); + public RecipeBoxBuilder slot(List itemId) { + recipeBoxContent.append(createRecipeIngredient(context, node, itemId)); + return this; + } + + @Override + public RecipeBoxBuilder append(HTMLNode node) { + recipeBoxContent.append(node); return this; } @Override - public RecipeBoxBuilder rawHtml(String html) { - recipeBoxContent.append(html); + public RecipeBoxBuilder grid(int columns, int rows, Consumer customizer) { + var gridContent = new HashMap(); + + var gridBuilder = new GridBuilder() { + @Override + public GridBuilder image(int column, int row, String assetName) { + Objects.checkIndex(column, columns); + Objects.checkIndex(row, rows); + return set(column, row, HTMLNode.tag("img") + .setAttribute("alt", "") + .setAttribute("src", context.resolveAssetPath(assetName))); + } + + @Override + public GridBuilder slot(int column, int row, List itemId) { + Objects.checkIndex(column, columns); + Objects.checkIndex(row, rows); + return set(column, row, slotHtml(itemId)); + } + + @Override + public GridBuilder set(int column, int row, HTMLNode node) { + Objects.checkIndex(column, columns); + Objects.checkIndex(row, rows); + gridContent.put(row * columns + column, node); + return this; + } + }; + customizer.accept(gridBuilder); + + var gridContainer = HTMLNode.tag("div") + .setClassName("grid") + .setAttribute("style", "grid-template-columns: repeat(" + columns + ", auto);"); + for (int i = 0; i < (columns * rows); i++) { + var content = gridContent.get(i); + if (content == null) { + gridContainer.append(HTMLNode.tag("span")); + } else { + gridContainer.append(content); + } + } + + recipeBoxContent.append(gridContainer); return this; } @Override public void build() { - result.append(createHtmlElement("div", Map.of("class", "recipe-container"), createMinecraftFrame( - createRecipeBoxLayout(recipeBoxContent.toString())))); + output.accept(HTMLNode.tag("div") + .setClassName("recipe-container") + .append(createMinecraftFrame().append(createRecipeBoxLayout().append(recipeBoxContent)))); } }; } - private String createRecipeIngredientGrid(WebPageCompileContext context, MdAstParent node, + private HTMLTag createRecipeIngredientGrid(WebPageCompileContext context, MdAstParent node, ExportedRecipe recipe) { var shapeless = recipe.recipe().getAsJsonPrimitive("shapeless").getAsBoolean(); var ingredientsJson = recipe.recipe().getAsJsonArray("ingredients"); @@ -124,7 +174,7 @@ private String createRecipeIngredientGrid(WebPageCompileContext context, MdAstPa return compileRecipeIngredientGrid(context, node, ingredients, shapeless, width); } - private String compileRecipeIngredientGrid( + private HTMLTag compileRecipeIngredientGrid( WebPageCompileContext context, MdAstParent node, List> ingredients, @@ -175,50 +225,52 @@ private String compileRecipeIngredientGrid( processedIngredients = sparseIngredients; } - StringBuilder slotsHtml = new StringBuilder(); + HTMLTag gridContainer = HTMLNode.tag("div").setClassName(className); for (var ingredient : processedIngredients) { - slotsHtml.append(createRecipeIngredient(context, node, ingredient)); + gridContainer.append(createRecipeIngredient(context, node, ingredient)); } - - return createHtmlElement("div", Map.of("class", className), slotsHtml.toString()); + return gridContainer; } - private String createRecipeIngredient(WebPageCompileContext context, MdAstParent node, List itemIds) { + private HTMLNode createRecipeIngredient(WebPageCompileContext context, MdAstParent node, List itemIds) { if (itemIds.isEmpty()) { - return createHtmlElement("div", Map.of("class", "empty-ingredient-box")); + return HTMLNode.tag("div").setClassName("empty-ingredient-box"); } else if (itemIds.size() == 1) { - return createHtmlElement("div", Map.of("class", "ingredient-box"), - webPageCompiler.createItemIcon(context, node, itemIds.getFirst(), false)); + return HTMLNode.tag("div") + .setClassName("ingredient-box") + .append(webPageCompiler.createItemIcon(context, node, itemIds.getFirst(), false)); } else { - var ingredientList = new StringBuilder(); + var cyclingIngredientBox = HTMLNode.tag("div").setClassName("ingredient-box cycling"); for (var itemId : itemIds) { - ingredientList.append(webPageCompiler.createItemIcon(context, node, itemId, false)).append('\n'); + cyclingIngredientBox.append(webPageCompiler.createItemIcon(context, node, itemId, false)); } - return createHtmlElement("div", Map.of("class", "ingredient-box cycling"), ingredientList.toString()); + return cyclingIngredientBox; } } - private String createRecipeArrow() { - return createHtmlElement("svg", Map.of("class", "recipe-arrow", "viewBox", "0 0 85 50"), - createHtmlElement("path", - Map.of("d", "M 0 20 H 60 V 0 L 85 25 L 60 50 L 60 30 L 0 30 Z", "fill", "#8b8b8b"))); + private HTMLNode createRecipeArrow() { + return HTMLNode.tag("svg") + .setClassName("recipe-arrow") + .setAttribute("viewBox", "0 0 85 50") + .append(HTMLNode.tag("path") + .setAttribute("d", "M 0 20 H 60 V 0 L 85 25 L 60 50 L 60 30 L 0 30 Z") + .setAttribute("fill", "#8b8b8b")); } - private String createRecipeDisplayName(WebPageCompileContext context, MdAstParent node, String iconId, + private HTMLTag createRecipeDisplayName(WebPageCompileContext context, MdAstParent node, String iconId, String title, ItemInfoJson resultItem) { - return createHtmlElement( - "div", - Map.of("title", resultItem.displayName), - webPageCompiler.createItemIcon(context, node, iconId, true) + "\n" + - title + "\n"); + return HTMLNode.tag("div") + .setAttribute("title", resultItem.displayName) + .append(webPageCompiler.createItemIcon(context, node, iconId, true)) + .append(title); } - private String createMinecraftFrame(String content) { - return createHtmlElement("div", Map.of("class", "minecraft-frame"), content); + private HTMLTag createMinecraftFrame() { + return HTMLNode.tag("div").setClassName("minecraft-frame"); } - private String createRecipeBoxLayout(String content) { - return createHtmlElement("div", Map.of("class", "recipeBoxLayout"), content); + private HTMLTag createRecipeBoxLayout() { + return HTMLNode.tag("div").setClassName("recipeBoxLayout"); } } diff --git a/src/main/java/guideme/internal/web/StaticSiteGenerator.java b/src/main/java/guideme/internal/web/StaticSiteGenerator.java index cb6bb6f0..0ef1f426 100644 --- a/src/main/java/guideme/internal/web/StaticSiteGenerator.java +++ b/src/main/java/guideme/internal/web/StaticSiteGenerator.java @@ -125,7 +125,7 @@ private IndexModel readIndex() { } } - private ExportedGuide readGuide(IndexModel index) { + private ExportedGuideImpl readGuide(IndexModel index) { var path = options.dataFolder.resolve(index.guideDataPath()); SiteExportJson model; try (var input = Files.newInputStream(path); @@ -138,7 +138,7 @@ private ExportedGuide readGuide(IndexModel index) { } catch (IOException e) { throw new RuntimeException("Failed to read the index file " + path, e); } - return new ExportedGuide(index, model); + return new ExportedGuideImpl(index, model); } public static void main(String[] args) { diff --git a/src/main/java/guideme/internal/web/WebAssetsBundle.java b/src/main/java/guideme/internal/web/WebAssetsBundle.java index b0db60fe..04b84222 100644 --- a/src/main/java/guideme/internal/web/WebAssetsBundle.java +++ b/src/main/java/guideme/internal/web/WebAssetsBundle.java @@ -1,8 +1,10 @@ package guideme.internal.web; -import static guideme.internal.web.HtmlUtils.createHtmlElement; import static guideme.internal.web.HtmlUtils.escapeHtml; +import guideme.siteexport.web.HTMLFragment; +import guideme.siteexport.web.HTMLNode; +import guideme.siteexport.web.HTMLTag; import java.io.FileNotFoundException; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -13,10 +15,8 @@ import java.nio.file.StandardCopyOption; import java.nio.file.attribute.BasicFileAttributes; import java.util.HashSet; -import java.util.Map; import java.util.Set; import java.util.regex.Pattern; -import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -115,26 +115,29 @@ public String realizeLayoutTemplate(WebPageCompileContext context, LayoutPlaceho var footer = buildFooter(context); return layoutTemplate - .replace(PLACEHOLDER_PAGE_CONTENT, placeholders.pageContent()) + .replace(PLACEHOLDER_PAGE_CONTENT, placeholders.pageContent().outerHtml()) .replace(PLACEHOLDER_PAGE_TITLE, placeholders.pageTitle()) .replace(PLACEHOLDER_PAGE_TITLE_TEXT, titleText) .replace(PLACEHOLDER_RELATIVE_PATH_TO_ROOT, relativePathToRoot) .replace(PLACEHOLDER_GUIDE_TITLE, escapeHtml(context.guide().getGuideTitle())) - .replace(PLACEHOLDER_GUIDE_NAVBAR, guideNavbar) - .replace(PLACEHOLDER_FOOTER, footer); + .replace(PLACEHOLDER_GUIDE_NAVBAR, guideNavbar.outerHtml()) + .replace(PLACEHOLDER_FOOTER, footer.outerHtml()); } - private static @NonNull String buildFooter(WebPageCompileContext context) { - String footerContent = "Minecraft " + context.guide().getGameMajorVersion(); + private static HTMLTag buildFooter(WebPageCompileContext context) { + var fragment = new HTMLFragment(); + fragment.append("Minecraft " + context.guide().getGameMajorVersion()); + String changeVersionUrl = context.options().changeVersionUrl(); if (changeVersionUrl != null) { - footerContent += " [" + createHtmlElement("a", Map.of( - "href", changeVersionUrl), "change") + "]"; + fragment.append(" ["); + fragment.append(HTMLNode.tag("a") + .setAttribute("href", changeVersionUrl).append("change")); + fragment.append("]"); } - return createHtmlElement( - "div", - Map.of("class", "version-picker"), - footerContent); + + return HTMLNode.tag("div", fragment) + .setClassName("version-picker"); } public void copyToOutputFolder() throws IOException { diff --git a/src/main/java/guideme/internal/web/WebGuideNavBar.java b/src/main/java/guideme/internal/web/WebGuideNavBar.java index 125059b7..14162306 100644 --- a/src/main/java/guideme/internal/web/WebGuideNavBar.java +++ b/src/main/java/guideme/internal/web/WebGuideNavBar.java @@ -1,12 +1,10 @@ package guideme.internal.web; -import static guideme.internal.web.HtmlUtils.createHtmlElement; - import guideme.internal.siteexport.model.NavigationNodeJson; -import java.util.HashMap; +import guideme.siteexport.web.HTMLFragment; +import guideme.siteexport.web.HTMLNode; +import guideme.siteexport.web.HTMLTag; import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; class WebGuideNavBar { private final WebPageCompileContext context; @@ -22,56 +20,59 @@ private boolean isSelfOrChildCurrentPage(NavigationNodeJson node) { return node.children.stream().anyMatch(this::isSelfOrChildCurrentPage); } - private String generateLink(NavigationNodeJson node) { - var text = HtmlUtils.escapeHtml(node.title); - + private HTMLNode generateLink(NavigationNodeJson node) { if (!node.hasPage) { - return text; + return HTMLNode.text(node.title); + } + + var link = HTMLNode.tag("a"); + if (node.pageId.equals(context.pageId())) { + link.setClassName("active"); } + link.setAttribute("href", context.getRelativePagePath(node.pageId)); - var linkContent = new StringBuilder(); if (node.icon != null) { var itemInfo = context.guide().getItemInfo(node.icon); - linkContent.append(createHtmlElement("img", - Map.of("src", context.resolveAssetPath(itemInfo.icon), "alt", "", "class", "item-icon"))); + link.append(HTMLNode.tag("img") + .setClassName("item-icon") + .setAttribute("src", context.resolveAssetPath(itemInfo.icon)) + .setAttribute("alt", "")); } if (!node.children.isEmpty()) { - linkContent.append(createHtmlElement("svg", Map.of(), createHtmlElement("path", Map.of()))); + link.append(HTMLNode.tag("svg").append(HTMLNode.tag("path"))); } - linkContent.append(text); + link.append(node.title); - var href = context.getRelativePagePath(node.pageId); - var attrs = new HashMap(Map.of("href", href)); - if (node.pageId.equals(context.pageId())) { - attrs.put("class", "active"); - } - - return createHtmlElement("a", attrs, linkContent.toString()); + return link; } - private String generateLevel(List nodes) { - if (nodes.isEmpty()) { - return ""; - } + private HTMLFragment generateLevel(List nodes) { + var fragment = new HTMLFragment(); - return nodes.stream().map(node -> { + for (var node : nodes) { if (!node.children.isEmpty()) { // Expanded by default if any of the current nodes descendents is the current page boolean expanded = isSelfOrChildCurrentPage(node); - var childrenHtml = createHtmlElement("div", Map.of("class", "sublevel"), generateLevel(node.children)); - return createHtmlElement("details", expanded ? Map.of("open", "") : Map.of(), - createHtmlElement("summary", Map.of(), generateLink(node)) + "\n" + childrenHtml); + var details = HTMLNode.tag("details"); + if (expanded) { + details.setAttribute("open", null); + } + details.append(HTMLNode.tag("summary", generateLink(node))) + .append(HTMLNode.tag("div", generateLevel(node.children)).setClassName("sublevel")); + fragment.append(details); } else { - return generateLink(node); + fragment.append(generateLink(node)); } - }).collect(Collectors.joining("\n")); + } + return fragment; } - public static String generate(WebPageCompileContext context) { + public static HTMLTag generate(WebPageCompileContext context) { var navbar = new WebGuideNavBar(context); - return createHtmlElement("div", Map.of("class", "navbar"), - navbar.generateLevel(context.guide().getRootNavigationNodes())); + return HTMLNode.tag("div") + .setClassName("navbar") + .append(navbar.generateLevel(context.guide().getRootNavigationNodes())); } } diff --git a/src/main/java/guideme/internal/web/WebPageCompileContext.java b/src/main/java/guideme/internal/web/WebPageCompileContext.java index 8dae2d98..190f8978 100644 --- a/src/main/java/guideme/internal/web/WebPageCompileContext.java +++ b/src/main/java/guideme/internal/web/WebPageCompileContext.java @@ -8,7 +8,7 @@ record WebPageCompileContext( StaticSiteGenerator.Options options, - ExportedGuide guide, + ExportedGuideImpl guide, String pageId, ExportedPageJson page, WebPageCompiler.TemplateContainer templates) { diff --git a/src/main/java/guideme/internal/web/WebPageCompiler.java b/src/main/java/guideme/internal/web/WebPageCompiler.java index 7f35fb5d..b73663a9 100644 --- a/src/main/java/guideme/internal/web/WebPageCompiler.java +++ b/src/main/java/guideme/internal/web/WebPageCompiler.java @@ -1,10 +1,5 @@ package guideme.internal.web; -import static guideme.internal.web.HtmlUtils.createHtmlElement; -import static guideme.internal.web.HtmlUtils.escapeAttribute; -import static guideme.internal.web.HtmlUtils.escapeHtml; -import static guideme.internal.web.HtmlUtils.guiScaledDimension; - import com.google.gson.Gson; import guideme.compiler.tags.MdxAttrs; import guideme.internal.siteexport.model.ItemInfoJson; @@ -37,9 +32,23 @@ import guideme.libs.mdast.model.MdAstStrong; import guideme.libs.mdast.model.MdAstText; import guideme.libs.mdast.model.MdAstThematicBreak; +import guideme.libs.micromark.extensions.gfm.Align; import guideme.scene.annotation.InWorldBoxAnnotation; import guideme.scene.annotation.InWorldLineAnnotation; -import guideme.siteexport.RecipeWebRenderer; +import guideme.siteexport.DefaultValue; +import guideme.siteexport.web.CustomElementWebRenderer; +import guideme.siteexport.web.HTMLFragment; +import guideme.siteexport.web.HTMLNode; +import guideme.siteexport.web.HTMLTag; +import guideme.siteexport.web.HTMLText; +import guideme.siteexport.web.RecipeWebRenderer; +import net.minecraft.util.StringRepresentable; +import org.apache.commons.lang3.tuple.Pair; +import org.joml.Vector3f; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.util.ArrayList; @@ -50,24 +59,22 @@ import java.util.Map; import java.util.Objects; import java.util.ServiceLoader; +import java.util.function.Consumer; import java.util.regex.Pattern; -import java.util.stream.Collectors; -import net.minecraft.util.StringRepresentable; -import org.apache.commons.lang3.tuple.Pair; -import org.joml.Vector3f; -import org.jspecify.annotations.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; + +import static guideme.internal.web.HtmlUtils.guiScaledDimension; class WebPageCompiler { private static final Logger LOG = LoggerFactory.getLogger(WebPageCompiler.class); - private final ExportedGuide guide; + private final ExportedGuideImpl guide; private final WebAssetsBundle webAssetsBundle; private final StaticSiteGenerator.Options options; private final Map recipeRenderersByType = new HashMap<>(); + private final Map customRendererByName = new HashMap<>(); - public WebPageCompiler(ExportedGuide guide, WebAssetsBundle webAssetsBundle, StaticSiteGenerator.Options options) { + public WebPageCompiler(ExportedGuideImpl guide, WebAssetsBundle webAssetsBundle, + StaticSiteGenerator.Options options) { this.guide = guide; this.webAssetsBundle = webAssetsBundle; this.options = options; @@ -77,6 +84,12 @@ public WebPageCompiler(ExportedGuide guide, WebAssetsBundle webAssetsBundle, Sta new SmeltingRecipeRenderer(), new SmithingRecipeRenderer())); registerRecipeRenderers(ServiceLoader.load(RecipeWebRenderer.class)); + + for (var renderer : ServiceLoader.load(CustomElementWebRenderer.class)) { + for (var tagName : renderer.getTagNames()) { + customRendererByName.put(tagName, renderer); + } + } } private void registerRecipeRenderers(Iterable renderers) { @@ -100,15 +113,19 @@ public void compile(String pageId) { var compiled = compilePage(context); var pagePath = context.resolveOutputPath(guide.getPageBasePath(pageId) + ".html"); + var content = compiled.content; + + // Append the page templates + for (var template : context.templates().templates) { + content.append(template); + } + var pageHtml = webAssetsBundle.realizeLayoutTemplate( context, new LayoutPlaceholders( pagePath.getParent(), compiled.title, - compiled.content)); - - // Append the page templates - pageHtml = pageHtml.replace("", String.join("", context.templates().templates) + ""); + content)); Files.writeString(pagePath, pageHtml, StandardCharsets.UTF_8); @@ -120,31 +137,22 @@ public void compile(String pageId) { // ==================== Compilation Methods ==================== - public record CompiledPage(@Nullable String title, String content) { + public record CompiledPage(@Nullable String title, HTMLFragment content) { } - private String compileChildren(WebPageCompileContext context, MdAstParent parent) { + HTMLFragment compileChildren(WebPageCompileContext context, MdAstParent parent) { return compileChildren(context, parent.children(), parent); } - private String compileChildren(WebPageCompileContext context, List children, MdAstParent parent) { - List elements = new ArrayList<>(); + HTMLFragment compileChildren(WebPageCompileContext context, List children, MdAstParent parent) { + var elements = new HTMLFragment(); for (Object child : children) { if (child instanceof MdAstNode node) { - String compiled = compileContent(context, node, parent); - if (compiled != null && !compiled.isEmpty()) { - elements.add(compiled); - } + compileContent(context, node, parent, elements::append); } } - if (elements.isEmpty()) { - return ""; - } else if (elements.size() == 1) { - return elements.get(0); - } else { - return String.join("", elements); - } + return elements; } private void assertNodeType(MdAstNode node, String expectedType) { @@ -154,16 +162,14 @@ private void assertNodeType(MdAstNode node, String expectedType) { } } - private String compileHeading(WebPageCompileContext context, MdAstHeading node) { - String tag = "h" + node.depth; - String content = compileChildren(context, node); - return createElement(tag, null, content); + private HTMLTag compileHeading(WebPageCompileContext context, MdAstHeading node) { + return HTMLNode.tag("h" + node.depth, compileChildren(context, node)); } - private final Pattern DOUBLE_QUOTE_STRING = Pattern.compile("^\\s*\"([^\"]+)\"\\s*$"); - private final Pattern SINGLE_QUOTE_STRING = Pattern.compile("^\\s*'([^']+)'\\s*$"); + private static final Pattern DOUBLE_QUOTE_STRING = Pattern.compile("^\\s*\"([^\"]+)\"\\s*$"); + private static final Pattern SINGLE_QUOTE_STRING = Pattern.compile("^\\s*'([^']+)'\\s*$"); - private String compileTextExpression(WebPageCompileContext context, MdAstNode node) { + private HTMLNode compileTextExpression(WebPageCompileContext context, MdAstNode node) { // We support simple strings, but not actual JS programs // This assumes the node has a 'value' field - we'll need to handle this carefully if (node instanceof MdAstLiteral literal) { @@ -171,12 +177,12 @@ private String compileTextExpression(WebPageCompileContext context, MdAstNode no var m = DOUBLE_QUOTE_STRING.matcher(value); if (m.matches()) { - return escapeHtml(m.group(1)); + return HTMLNode.text(m.group(1)); } m = SINGLE_QUOTE_STRING.matcher(value); if (m.matches()) { - return escapeHtml(m.group(1)); + return HTMLNode.text(m.group(1)); } return compileError(node, "Unsupported JSX expression: " + value); @@ -185,85 +191,58 @@ private String compileTextExpression(WebPageCompileContext context, MdAstNode no return compileError(node, "Unsupported JSX expression node type"); } - private String compileContent(WebPageCompileContext context, MdAstNode node, MdAstParent parent) { + private void compileContent(WebPageCompileContext context, MdAstNode node, MdAstParent parent, + Consumer output) { String type = node.type(); switch (type) { // We do not support definitions or footnote definitions - case MdAstDefinition.TYPE: - case "footnoteDefinition": - case MdAstYamlFrontmatter.TYPE: + case MdAstDefinition.TYPE, "footnoteDefinition", MdAstYamlFrontmatter.TYPE -> { // ignore frontmatter, handled already in ExportedPage - return null; - - case MdAstHeading.TYPE: - return compileHeading(context, (MdAstHeading) node); - - ////////////////////////// Phrasing Content - case MdAstBreak.TYPE: - return "
"; - - case MdAstImage.TYPE: - return compileImage(context, (MdAstImage) node); - - case MdAstStrong.TYPE: - return createElement("strong", null, compileChildren(context, (MdAstStrong) node)); - - case MdAstLink.TYPE: - return compileLink(context, (MdAstLink) node); - - case MdAstDelete.TYPE: - return createElement("del", null, compileChildren(context, (MdAstDelete) node)); - - case MdAstEmphasis.TYPE: - return createElement("em", null, compileChildren(context, (MdAstEmphasis) node)); - - case MdAstText.TYPE: - return escapeHtml(((MdAstText) node).value); - - case MdAstInlineCode.TYPE: + } + case MdAstHeading.TYPE -> output.accept(compileHeading(context, (MdAstHeading) node)); + + // ====================== Phrasing Content + case MdAstBreak.TYPE -> output.accept(HTMLNode.tag("br")); + case MdAstImage.TYPE -> output.accept(compileImage(context, (MdAstImage) node)); + case MdAstStrong.TYPE -> + output.accept(HTMLNode.tag("strong", compileChildren(context, (MdAstStrong) node))); + case MdAstLink.TYPE -> output.accept(compileLink(context, (MdAstLink) node)); + case MdAstDelete.TYPE -> output.accept(HTMLNode.tag("del", compileChildren(context, (MdAstDelete) node))); + case MdAstEmphasis.TYPE -> + output.accept(HTMLNode.tag("em", compileChildren(context, (MdAstEmphasis) node))); + case MdAstText.TYPE -> output.accept(HTMLNode.text(((MdAstText) node).value)); + case MdAstInlineCode.TYPE -> { String codeValue = ((MdAstInlineCode) node).value.replaceAll("\r?\n|\r", " "); - return createElement("code", null, escapeHtml(codeValue)); - - ////////////////////////// Block Content - case MdAstThematicBreak.TYPE: - return "
"; - - case MdAstParagraph.TYPE: - return createElement("p", null, compileChildren(context, (MdAstParagraph) node)); - - case MdAstBlockquote.TYPE: - return createElement("blockquote", null, compileChildren(context, (MdAstBlockquote) node)); + output.accept(HTMLNode.tag("code").append(codeValue)); + } - case MdAstCode.TYPE: + // ====================== Block Content + case MdAstThematicBreak.TYPE -> output.accept(HTMLNode.tag("hr")); + case MdAstParagraph.TYPE -> + output.accept(HTMLNode.tag("p", compileChildren(context, (MdAstParagraph) node))); + case MdAstBlockquote.TYPE -> + output.accept(HTMLNode.tag("blockquote", compileChildren(context, (MdAstBlockquote) node))); + case MdAstCode.TYPE -> { MdAstCode codeNode = (MdAstCode) node; String className = codeNode.lang != null ? "language-" + codeNode.lang : null; - String codeElement = createElement("code", className, escapeHtml(codeNode.value)); - return createElement("pre", null, codeElement); - - case MdAstList.TYPE: - return compileList(context, (MdAstList) node); - - case MdAstListItem.TYPE: - return compileListItem(context, (MdAstListItem) node, parent); - - case GfmTable.TYPE: - return compileTable(context, (GfmTable) node); + var codeElement = HTMLNode.tag("code") + .setClassName(className) + .append(codeNode.value); + output.accept(HTMLNode.tag("pre", codeElement)); + } + case MdAstList.TYPE -> output.accept(compileList(context, (MdAstList) node)); + case MdAstListItem.TYPE -> output.accept(compileListItem(context, (MdAstListItem) node, parent)); + case GfmTable.TYPE -> output.accept(compileTable(context, (GfmTable) node)); // Expressions like "{' '}" - case "mdxFlowExpression": - case "mdxTextExpression": - return compileTextExpression(context, node); + case "mdxFlowExpression", "mdxTextExpression" -> output.accept(compileTextExpression(context, node)); // Text- and Block-Level JSX or HTML Element - case MdxJsxFlowElement.TYPE: - return compileCustomElement(context, (MdxJsxFlowElement) node); + case MdxJsxFlowElement.TYPE -> compileCustomElement(context, (MdxJsxFlowElement) node, output); + case MdxJsxTextElement.TYPE -> compileCustomElement(context, (MdxJsxTextElement) node, output); - case MdxJsxTextElement.TYPE: - return compileCustomElement(context, (MdxJsxTextElement) node); - - default: - return compileError(node, "Unhandled node type"); + default -> output.accept(compileError(node, "Unhandled node type")); } } @@ -281,7 +260,7 @@ private CompiledPage compilePage(WebPageCompileContext context) { MdAstAnyContent child = clonedChildren.get(i); if (child instanceof MdAstHeading heading) { if (heading.depth == 1) { - title = compileHeading(context, heading); + title = compileHeading(context, heading).textContent(); // Wrap the existing heading such that it can be re-enabled for mobile clients MdxJsxFlowElement wrapper = new MdxJsxFlowElement(); @@ -304,28 +283,24 @@ private CompiledPage compilePage(WebPageCompileContext context) { MdAstRoot tempRoot = new MdAstRoot(); tempRoot.children().addAll(clonedChildren); - String content = compileChildren(context, tempRoot); + var content = compileChildren(context, tempRoot); return new CompiledPage(title, content); } // ==================== Helper Methods ==================== - private String createElement(String tag, @Nullable String className, String content) { - var attributes = new HashMap(); - if (className != null) { - attributes.put("class", className); - } - return createHtmlElement(tag, attributes, content); - } - - String compileError(MdAstNode node, String message) { + HTMLTag compileError(MdAstNode node, String message) { LOG.warn("Compilation error at {}: {}", node, message); - return "Error: " + escapeHtml(message) + ""; + return HTMLNode.tag("span") + .setStyles(Map.of( + "color", "red", + "font-weight", "bold")) + .append("Error: " + message); } // Placeholder methods - these need to be implemented based on the corresponding TypeScript files - private String compileLink(WebPageCompileContext context, MdAstLink node) { + private HTMLTag compileLink(WebPageCompileContext context, MdAstLink node) { var href = node.url; var title = Objects.requireNonNullElse(node.title, ""); @@ -333,8 +308,10 @@ private String compileLink(WebPageCompileContext context, MdAstLink node) { // Internal vs. external links if (href.indexOf("://") > 0 || href.indexOf("//") == 0) { - return "" + content - + ""; + return HTMLNode.tag("a") + .setAttribute("href", href) + .setAttribute("title", title) + .append(content); } // Split fragment+url @@ -353,58 +330,62 @@ private String compileLink(WebPageCompileContext context, MdAstLink node) { url += "#" + urlParts[1]; } - return "" + content + ""; + return HTMLNode.tag("a") + .setAttribute("href", url) + .setAttribute("title", title) + .append(content); } - private String compileImage(WebPageCompileContext context, MdAstImage node) { + private HTMLTag compileImage(WebPageCompileContext context, MdAstImage node) { // TODO: Implement based on image.tsx String src = node.url; String alt = node.alt != null ? node.alt : ""; - return "\"""; + return HTMLNode.tag("img") + .setAttribute("src", context.resolveAssetPath(src)) + .setAttribute("alt", alt); } - private String compileList(WebPageCompileContext context, MdAstList node) { + private HTMLTag compileList(WebPageCompileContext context, MdAstList node) { // TODO: Implement based on list.tsx String tag = node.ordered ? "ol" : "ul"; - String content = compileChildren(context, node); - return createElement(tag, null, content); + var content = compileChildren(context, node); + return HTMLNode.tag(tag, content); } - private String compileListItem(WebPageCompileContext context, MdAstListItem node, MdAstParent parent) { + private HTMLTag compileListItem(WebPageCompileContext context, MdAstListItem node, MdAstParent parent) { // TODO: Implement based on listItem.tsx - String content = compileChildren(context, node); - return createElement("li", null, content); + var content = compileChildren(context, node); + return HTMLNode.tag("li", content); } - private String compileTable(WebPageCompileContext context, GfmTable table) { + private HTMLTag compileTable(WebPageCompileContext context, GfmTable table) { - var errors = new StringBuilder(); + var errors = new HTMLFragment(); + + var rows = getFilteredChildren(table, GfmTableRow.class, errors::append); + + var tableTag = HTMLNode.tag("table"); - var rows = getFilteredChildren(table, GfmTableRow.class, errors); - var tableContent = new StringBuilder(); if (!rows.isEmpty()) { // Generate a one-row thead for the first table row - tableContent.append( - createHtmlElement( + tableTag.append( + HTMLNode.tag( "thead", - Map.of(), compileTableRow(context, rows.removeFirst(), table))); } if (!rows.isEmpty()) { - tableContent.append( - createHtmlElement( - "tbody", - Map.of(), - rows.stream().map(row -> compileTableRow(context, row, table)) - .collect(Collectors.joining("\n")))); + var tbody = HTMLNode.tag("tbody"); + for (var row : rows) { + tbody.append(compileTableRow(context, row, table)); + } + tableTag.append(tbody); } - return createHtmlElement("table", Map.of(), tableContent.toString()); + return tableTag; } - private String compileTableRow( + private HTMLTag compileTableRow( WebPageCompileContext context, GfmTableRow node, GfmTable parent) { @@ -413,31 +394,36 @@ private String compileTableRow( // Generate a body row when without parent. var rowIndex = siblings != null ? siblings.indexOf(node) : 1; var tagName = rowIndex == 0 ? "th" : "td"; - var align = parent != null && parent.type().equals("table") ? parent.align : null; - var length = align != null ? align.size() : node.children().size(); + var cellAlignments = parent != null && parent.type().equals("table") ? parent.align : null; + var length = cellAlignments != null ? cellAlignments.size() : node.children().size(); var cellIndex = -1; - var cells = new StringBuilder(); + var row = HTMLNode.tag("tr"); while (++cellIndex < length) { - // Note: can also be undefined. - var cell = cellIndex < node.children().size() ? node.children().get(cellIndex) : null; - var properties = new HashMap(); - if (align != null) { - properties.put("align", align.get(cellIndex)); + var cellTag = HTMLNode.tag(tagName); + if (cellAlignments != null) { + var align = cellAlignments.get(cellIndex); + if (align != Align.NONE) { + cellTag.setAttribute("align", align.name().toLowerCase(Locale.ROOT)); + } } - var cellContent = cell != null ? compileChildren(context, cell) : ""; - cells.append(createHtmlElement(tagName, properties, cellContent)); + // Note: can also be undefined. + var cellNodes = cellIndex < node.children().size() ? node.children().get(cellIndex) : null; + if (cellNodes != null) { + cellTag.append(compileChildren(context, cellNodes)); + } + row.append(cellTag); } - return createHtmlElement("tr", Map.of(), cells.toString()); + return row; } - private List getFilteredChildren(MdAstParent parent, Class childType, StringBuilder errors) { + private List getFilteredChildren(MdAstParent parent, Class childType, Consumer errors) { var rows = new ArrayList(); for (var child : parent.children()) { if (!childType.isInstance(child)) { - errors.append( + errors.accept( compileError(parent, "Unsupported child-node for " + parent.type() + ": " + child.type())); continue; } @@ -446,74 +432,85 @@ private List getFilteredChildren(MdAstParent parent, Class childTyp return rows; } - private String compileCustomElement(WebPageCompileContext context, MdxJsxFlowElement node) { - String tag = node.name() != null && !node.name().isEmpty() ? node.name() : "div"; + private void compileCustomElement(WebPageCompileContext context, MdxJsxFlowElement node, + Consumer output) { + String tag = (node.name() != null && !node.name().isEmpty()) ? node.name() : "div"; + // Direct translation of html tags based on the heuristic that lowercase tags are HTML tags if (tag.toLowerCase(Locale.ROOT).equals(tag)) { - String content = compileChildren(context, node); - return createElement(tag, null, content); + output.accept(HTMLNode.tag(tag, compileChildren(context, node))); + } else { + compileCustomElement(context, node, node, output); } - - return compileCustomElement(context, node, node); } - private String compileCustomElement(WebPageCompileContext context, MdxJsxTextElement node) { - String tag = node.name() != null && !node.name().isEmpty() ? node.name() : "span"; + private void compileCustomElement(WebPageCompileContext context, MdxJsxTextElement node, + Consumer output) { + String tagName = node.name() != null && !node.name().isEmpty() ? node.name() : "span"; - if (tag.toLowerCase(Locale.ROOT).equals(tag)) { - String content = compileChildren(context, node); + if (tagName.toLowerCase(Locale.ROOT).equals(tagName)) { + var tag = HTMLNode.tag(tagName).append(compileChildren(context, node)); - var attributes = new HashMap(); for (var attribute : node.attributes()) { if (attribute instanceof MdxJsxAttribute jsxAttribute) { if (jsxAttribute.hasStringValue()) { - attributes.put(jsxAttribute.name, jsxAttribute.getStringValue()); + tag.setAttribute(jsxAttribute.name, jsxAttribute.getStringValue()); } else if (jsxAttribute.getExpressionValue().isBlank()) { - attributes.put(jsxAttribute.name, true); // for tags of the style + tag.setAttribute(jsxAttribute.name, null); // for tags of the style } else { - return compileError(node, "Unsupported attribute type"); + output.accept(compileError(node, "Unsupported attribute type")); + return; } } else { - return compileError(node, "Unsupported attribute type"); + output.accept(compileError(node, "Unsupported attribute type")); + return; } } // Translate some source links automatically - if (tag.equals("video")) { - var src = attributes.get("src"); + if (tagName.equals("video")) { + var src = tag.attribute("src"); if (src != null) { - attributes.put("src", context.resolveAssetPath(src.toString())); + tag.setAttribute("src", context.resolveAssetPath(src)); } } - return createHtmlElement(tag, attributes, content); + output.accept(tag); + } else { + compileCustomElement(context, node, node, output); } - - return compileCustomElement(context, node, node); } - private String compileCustomElement(WebPageCompileContext context, MdxJsxElementFields jsxElement, - MdAstParent node) { - return switch (jsxElement.name()) { - case "BlockImage" -> compileBlockImage(context, jsxElement, node); - case "CategoryIndex" -> compileCategoryIndex(context, jsxElement, node); - case "SubPages" -> compileSubPages(context, jsxElement, node); - case "Column" -> compileColumn(context, jsxElement, node); - case "ItemLink" -> compileItemLink(context, jsxElement, node); - case "ItemImage" -> compileItemImage(context, jsxElement, node); - case "ItemIcon" -> compileItemIcon(context, jsxElement, node); - case "ItemGrid" -> compileItemGrid(context, jsxElement, node); - case "Recipe" -> compileRecipe(context, jsxElement, node); - case "RecipeFor" -> compileRecipeFor(context, jsxElement, node); - case "RecipesFor" -> compileRecipesFor(context, jsxElement, node); - case "Row" -> compileRow(context, jsxElement, node); - case "GameScene" -> compileGameScene(context, jsxElement, node); - default -> compileError(node, "Unhandled custom element: " + jsxElement.name()); - }; - } - - private String compileBlockImage(WebPageCompileContext context, MdxJsxElementFields jsxElement, - MdAstParent node) { + private void compileCustomElement(WebPageCompileContext context, + MdxJsxElementFields jsxElement, + MdAstParent node, + Consumer output) { + var customRenderer = customRendererByName.get(jsxElement.name()); + if (customRenderer != null) { + customRenderer.render(new CustomElementWebRenderingContextImpl(this, context, jsxElement, node), output); + } else { + HTMLNode tag = switch (jsxElement.name()) { + case "BlockImage" -> compileBlockImage(context, jsxElement, node); + case "CategoryIndex" -> compileCategoryIndex(context, jsxElement, node); + case "SubPages" -> compileSubPages(context, jsxElement, node); + case "Column" -> compileColumn(context, jsxElement, node); + case "ItemLink" -> compileItemLink(context, jsxElement, node); + case "ItemImage" -> compileItemImage(context, jsxElement, node); + case "ItemIcon" -> compileItemIcon(context, jsxElement, node); + case "ItemGrid" -> compileItemGrid(context, jsxElement, node); + case "Recipe" -> compileRecipe(context, jsxElement, node); + case "RecipeFor" -> compileRecipeFor(context, jsxElement, node); + case "RecipesFor" -> compileRecipesFor(context, jsxElement, node); + case "Row" -> compileRow(context, jsxElement, node); + case "GameScene" -> compileGameScene(context, jsxElement, node); + default -> compileError(node, "Unhandled custom element: " + jsxElement.name()); + }; + output.accept(tag); + } + } + + private HTMLNode compileBlockImage(WebPageCompileContext context, MdxJsxElementFields jsxElement, + MdAstParent node) { // These are compiled during export var src2x = MdxAttrs.getString(jsxElement, "src@2", null); var src4x = MdxAttrs.getString(jsxElement, "src@4", null); @@ -533,20 +530,21 @@ private String compileBlockImage(WebPageCompileContext context, MdxJsxElementFie var asset2x = context.resolveAssetPath(src2x); var asset4x = context.resolveAssetPath(src4x); var asset8x = context.resolveAssetPath(src8x); - return createHtmlElement("img", Map.of( - "srcset", String.format(Locale.ROOT, "%s, %s 2x, %s 4x", asset2x, asset4x, asset8x), - "src", asset2x, - "style", "width: " + guiScaledDimension(width) + "; height: " + guiScaledDimension(height))); + return HTMLNode.tag("img") + .setAttribute("srcset", String.format(Locale.ROOT, "%s, %s 2x, %s 4x", asset2x, asset4x, asset8x)) + .setAttribute("src", asset2x) + .setStyles(Map.of("width", guiScaledDimension(width), "height", guiScaledDimension(height))); } - private String compileItemLink(WebPageCompileContext context, MdxJsxElementFields jsxElement, MdAstParent node) { + private HTMLNode compileItemLink(WebPageCompileContext context, MdxJsxElementFields jsxElement, + MdAstParent node) { var tooltipMode = MdxAttrs.getEnum(jsxElement, "tooltip", TooltipMode.ICON); var id = MdxAttrs.getString(jsxElement, "id", null); // Markdown Formatting can insert whitespace into MDX attributes id = id.replaceAll("\\s+", ""); - String innerContent = null; + HTMLFragment innerContent = new HTMLFragment(); if (!node.children().isEmpty()) { innerContent = compileChildren(context, node); } @@ -554,8 +552,8 @@ private String compileItemLink(WebPageCompileContext context, MdxJsxElementField return createItemLink(context, node, id, tooltipMode, innerContent); } - private String createItemLink(WebPageCompileContext context, MdAstParent node, String id, - TooltipMode tooltipMode, @Nullable String innerContent) { + private HTMLNode createItemLink(WebPageCompileContext context, MdAstParent node, String id, + TooltipMode tooltipMode, @Nullable HTMLFragment innerContent) { id = guide.resolveId(id); @@ -566,36 +564,40 @@ private String createItemLink(WebPageCompileContext context, MdAstParent node } if (innerContent == null) { - innerContent = itemInfo.displayName; + innerContent = new HTMLFragment(); + innerContent.append(HTMLNode.text(itemInfo.displayName)); } // Do not render a link if we're already on that page, or there is no link - String content; + HTMLTag content; if (pageId == null || context.pageId().equals(pageId)) { - content = createHtmlElement("span", Map.of("class", "item-link"), innerContent); + content = HTMLNode.tag("span").setClassName("item-link") + .append(innerContent); } else { - content = createHtmlElement("a", Map.of("href", guide.getRelativePagePath(pageId, context.pageId())), - innerContent); + content = HTMLNode.tag("a") + .setAttribute("href", guide.getRelativePagePath(pageId, context.pageId())) + .append(innerContent); } return makeItemTooltip(context, itemInfo, tooltipMode, content); } - private String compileItemImage(WebPageCompileContext context, MdxJsxElementFields jsxElement, - MdAstParent node) { + private HTMLNode compileItemImage(WebPageCompileContext context, MdxJsxElementFields jsxElement, + MdAstParent node) { var id = MdxAttrs.getString(jsxElement, "id", null); var scale = MdxAttrs.getFloat(jsxElement, "scale", 1.0f); var itemInfo = guide.getItemInfo(id); - return createHtmlElement("img", Map.of( - "width", Math.round(32 * scale), - "height", Math.round(32 * scale), - "src", context.resolveAssetPath(itemInfo.icon), - "alt", "", - "aria-description", itemInfo.displayName)); + return HTMLNode.tag("img") + .setAttribute("width", Math.round(32 * scale)) + .setAttribute("height", Math.round(32 * scale)) + .setAttribute("src", context.resolveAssetPath(itemInfo.icon)) + .setAttribute("alt", "") + .setAttribute("aria-description", itemInfo.displayName); } - private String compileItemIcon(WebPageCompileContext context, MdxJsxElementFields jsxElement, MdAstParent node) { + private HTMLNode compileItemIcon(WebPageCompileContext context, MdxJsxElementFields jsxElement, + MdAstParent node) { var id = MdxAttrs.getString(jsxElement, "id", null); if (id == null) { return compileError(node, "ItemIcon is missing id property"); @@ -605,28 +607,30 @@ private String compileItemIcon(WebPageCompileContext context, MdxJsxElementField return createItemIcon(context, node, id, nolink); } - String createItemIcon(WebPageCompileContext context, MdAstParent node, String id, boolean nolink) { + HTMLNode createItemIcon(WebPageCompileContext context, MdAstParent node, String id, boolean nolink) { var itemInfo = guide.getItemInfo(id); - var icon = createHtmlElement("img", Map.of( - "src", context.resolveAssetPath(itemInfo.icon), - "alt", "", - "aria-description", itemInfo.displayName, - "class", "item-icon")); + var icon = HTMLNode.tag("img") + .setClassName("item-icon") + .setAttribute("src", context.resolveAssetPath(itemInfo.icon)) + .setAttribute("alt", "") + .setAttribute("aria-description", itemInfo.displayName) + .setAttribute("class", "item-icon"); if (!nolink) { - return createItemLink(context, node, id, TooltipMode.TEXT, icon); + return createItemLink(context, node, id, TooltipMode.TEXT, new HTMLFragment(icon)); } else { return icon; } } - private String compileItemGrid(WebPageCompileContext context, MdxJsxElementFields jsxElement, MdAstParent node) { - return createHtmlElement("div", Map.of( - "class", "layout-item-grid"), compileChildren(context, node)); + private HTMLNode compileItemGrid(WebPageCompileContext context, MdxJsxElementFields jsxElement, + MdAstParent node) { + return HTMLNode.tag("div", compileChildren(context, node)) + .setClassName("layout-item-grid"); } - private String compileRecipe(WebPageCompileContext context, MdxJsxElementFields jsxElement, MdAstParent node) { + private HTMLTag compileRecipe(WebPageCompileContext context, MdxJsxElementFields jsxElement, MdAstParent node) { var id = MdxAttrs.getString(jsxElement, "id", null); if (id == null) { return compileError(node, "Missing id"); @@ -636,11 +640,13 @@ private String compileRecipe(WebPageCompileContext context, MdxJsxElementFields return compileError(node, "Missing recipe: " + id); } - return compileRecipe(context, node, recipe); + var container = HTMLNode.tag("div").setClassName("recipe-container"); + compileRecipeInner(context, node, recipe, container::append); + return container; } - private String compileRecipeFor(WebPageCompileContext context, MdxJsxElementFields jsxElement, - MdAstParent node) { + private HTMLTag compileRecipeFor(WebPageCompileContext context, MdxJsxElementFields jsxElement, + MdAstParent node) { var id = MdxAttrs.getString(jsxElement, "id", null); if (id == null) { return compileError(node, "Missing id"); @@ -652,12 +658,13 @@ private String compileRecipeFor(WebPageCompileContext context, MdxJsxElementFiel return compileError(node, "No recipes for " + id); } - return createHtmlElement("div", Map.of("class", "recipe-container"), - compileRecipe(context, node, recipes.getFirst())); + var container = HTMLNode.tag("div").setClassName("recipe-container"); + compileRecipeInner(context, node, recipes.getFirst(), container::append); + return container; } - private String compileRecipesFor(WebPageCompileContext context, MdxJsxElementFields jsxElement, - MdAstParent node) { + private HTMLTag compileRecipesFor(WebPageCompileContext context, MdxJsxElementFields jsxElement, + MdAstParent node) { var id = MdxAttrs.getString(jsxElement, "id", null); if (id == null) { return compileError(node, "Missing id"); @@ -669,24 +676,27 @@ private String compileRecipesFor(WebPageCompileContext context, MdxJsxElementFie return compileError(node, "No recipes for " + id); } - return createHtmlElement("div", Map.of("class", "recipe-container"), recipes.stream().map(recipe -> { - return compileRecipe(context, node, recipe); - }).collect(Collectors.joining("\n"))); + var container = HTMLNode.tag("div").setClassName("recipe-container"); + for (var recipe : recipes) { + compileRecipeInner(context, node, recipe, container::append); + } + return container; } - private String compileRecipe(WebPageCompileContext context, MdAstParent node, ExportedRecipe recipe) { + private void compileRecipeInner(WebPageCompileContext context, MdAstParent node, ExportedRecipe recipe, + Consumer output) { var renderer = recipeRenderersByType.get(recipe.type()); if (renderer == null) { - return compileError(node, "Can't handle recipe type " + recipe.type()); + output.accept(compileError(node, "Can't handle recipe type " + recipe.type())); + return; } - var recipeRenderContext = new RecipeWebRenderingContextImpl(this, context, node, recipe); + var recipeRenderContext = new RecipeWebRenderingContextImpl(this, context, node, recipe, output); renderer.render(recipeRenderContext, recipe); - return recipeRenderContext.getResult(); } - private String compileCategoryIndex(WebPageCompileContext context, MdxJsxElementFields jsxElement, - MdAstParent node) { + private HTMLNode compileCategoryIndex(WebPageCompileContext context, MdxJsxElementFields jsxElement, + MdAstParent node) { var category = MdxAttrs.getString(jsxElement, "category", null); if (category == null) { return compileError(node, "Missing category attribute"); @@ -702,13 +712,18 @@ private String compileCategoryIndex(WebPageCompileContext context, MdxJsxElement .sorted(Comparator.comparing(p -> Objects.requireNonNullElse(p.getRight().title, ""))) .toList(); - return createHtmlElement("ul", Map.of(), pages.stream().map( - page -> createHtmlElement("li", Map.of(), createHtmlElement("a", - Map.of("href", context.getRelativePagePath(page.getKey())), escapeHtml(page.getValue().title)))) - .collect(Collectors.joining("\n"))); + var listTag = HTMLNode.tag("ul"); + for (var page : pages) { + var pageLink = HTMLNode.tag("a") + .setAttribute("href", context.getRelativePagePath(page.getKey())) + .append(page.getValue().title); + listTag.append(HTMLNode.tag("li", pageLink)); + } + return listTag; } - private String compileSubPages(WebPageCompileContext context, MdxJsxElementFields jsxElement, MdAstParent node) { + private HTMLNode compileSubPages(WebPageCompileContext context, MdxJsxElementFields jsxElement, + MdAstParent node) { record SubPagesAttributes( @Nullable String id, @DefaultValue("false") boolean icons, @@ -718,7 +733,7 @@ record SubPagesAttributes( var id = Objects.requireNonNullElse(attributes.id, context.pageId()); List navNodes; - if ("".equals(id)) { + if (id.isEmpty()) { navNodes = guide.getRootNavigationNodes(); } else { var pageNode = guide.findNavigationNodeForPage(id); @@ -736,39 +751,43 @@ record SubPagesAttributes( navNodes.sort(Comparator.comparing(n -> n.title)); } - return createHtmlElement("ul", Map.of("class", "sub-pages"), navNodes.stream().map( - n -> { - var linkText = new StringBuilder(); - if (attributes.icons && n.icon != null) { - var itemInfo = guide.getItemInfo(n.icon); - linkText.append(createHtmlElement("img", Map.of( - "alt", "", - "src", context.resolveAssetPath(itemInfo.icon), - "class", "page-icon"))); - } - linkText.append(createHtmlElement("a", Map.of("href", context.getRelativePagePath(n.pageId)), - escapeHtml(n.title))); - return createHtmlElement("li", Map.of(), linkText.toString()); - }).collect(Collectors.joining("\n"))); + var list = HTMLNode.tag("ul").setClassName("sub-pages"); + for (var n : navNodes) { + var listItem = HTMLNode.tag("li"); + + if (attributes.icons && n.icon != null) { + var itemInfo = guide.getItemInfo(n.icon); + listItem.append(HTMLNode.tag("img") + .setClassName("page-icon") + .setAttribute("alt", "") + .setAttribute("src", context.resolveAssetPath(itemInfo.icon))); + } + listItem.append(HTMLNode.tag("a") + .setAttribute("href", context.getRelativePagePath(n.pageId)) + .append(n.title)); + } + return list; } - private String compileColumn(WebPageCompileContext context, MdxJsxElementFields jsxElement, MdAstParent node) { - return createHtmlElement("div", Map.of( - "class", "layout-column"), compileChildren(context, node)); + private HTMLNode compileColumn(WebPageCompileContext context, MdxJsxElementFields jsxElement, MdAstParent node) { + return HTMLNode.tag("div") + .setClassName("layout-column") + .append(compileChildren(context, node)); } - private String compileRow(WebPageCompileContext context, MdxJsxElementFields jsxElement, MdAstParent node) { - return createHtmlElement("div", Map.of( - "class", "layout-row"), compileChildren(context, node)); + private HTMLNode compileRow(WebPageCompileContext context, MdxJsxElementFields jsxElement, MdAstParent node) { + return HTMLNode.tag("div") + .setClassName("layout-row") + .append(compileChildren(context, node)); } - private String compileGameScene(WebPageCompileContext context, MdxJsxElementFields jsxElement, - MdAstParent node) { + private HTMLTag compileGameScene(WebPageCompileContext context, MdxJsxElementFields jsxElement, + MdAstParent node) { var errors = new StringBuilder(); var attributes = JsxAttributeMapper.map(jsxElement, GameSceneAttributes.class); record ExportedInWorldAnnotation(String type, float[] minCorner, float[] maxCorner, String color, - float thickness, String contentTemplateId, boolean alwaysOnTop) { + float thickness, String contentTemplateId, boolean alwaysOnTop) { } record ExportedOverlayAnnotation(String type, float[] pos, String color, String contentTemplateId) { } @@ -788,29 +807,28 @@ record ExportedOverlayAnnotation(String type, float[] pos, String color, String var childTagName = Objects.requireNonNullElse(flowElement.name(), ""); switch (childTagName) { - // These tags are exported as part of the scene from the game - case "ImportStructure": - case "Block": - break; - case "BoxAnnotation": - record BoxAnnotationAttributes( + case "ImportStructure", "Block", "RemoveBlocks", "Entity", "IsometricCamera" -> { + // These tags are exported as part of the scene from the game + } + case "BoxAnnotation" -> { + record BoxAnnotation( Vector3f min, Vector3f max, @DefaultValue("white") String color, @DefaultValue(InWorldBoxAnnotation.DEFAULT_THICKNESS + "") float thickness, @DefaultValue("false") boolean alwaysOnTop) { } - var boxAttributes = JsxAttributeMapper.map(flowElement, BoxAnnotationAttributes.class); + var boxAttributes = JsxAttributeMapper.map(flowElement, BoxAnnotation.class); inWorldAnnotations.add(new ExportedInWorldAnnotation( "box", - new float[] { boxAttributes.min.x, boxAttributes.min.y, boxAttributes.min.z }, - new float[] { boxAttributes.max.x, boxAttributes.max.y, boxAttributes.max.z }, + new float[]{boxAttributes.min.x, boxAttributes.min.y, boxAttributes.min.z}, + new float[]{boxAttributes.max.x, boxAttributes.max.y, boxAttributes.max.z}, boxAttributes.color, boxAttributes.thickness, context.templates().create(compileChildren(context, flowElement)), boxAttributes.alwaysOnTop)); - break; - case "LineAnnotation": + } + case "LineAnnotation" -> { record LineAnnotation( Vector3f from, Vector3f to, @@ -821,74 +839,49 @@ record LineAnnotation( var lineAttributes = JsxAttributeMapper.map(flowElement, LineAnnotation.class); inWorldAnnotations.add(new ExportedInWorldAnnotation( "line", - new float[] { lineAttributes.from.x, lineAttributes.from.y, lineAttributes.from.z }, - new float[] { lineAttributes.to.x, lineAttributes.to.y, lineAttributes.to.z }, + new float[]{lineAttributes.from.x, lineAttributes.from.y, lineAttributes.from.z}, + new float[]{lineAttributes.to.x, lineAttributes.to.y, lineAttributes.to.z}, lineAttributes.color, lineAttributes.thickness, context.templates().create(compileChildren(context, flowElement)), lineAttributes.alwaysOnTop)); - break; - case "DiamondAnnotation": + } + case "DiamondAnnotation" -> { record DiamondAnnotation(Vector3f pos, @DefaultValue("transparent") String color) { } var diamondAttributes = JsxAttributeMapper.map(flowElement, DiamondAnnotation.class); overlayAnnotations.add(new ExportedOverlayAnnotation( "overlay", - new float[] { diamondAttributes.pos.x, diamondAttributes.pos.y, diamondAttributes.pos.z }, + new float[]{diamondAttributes.pos.x, diamondAttributes.pos.y, diamondAttributes.pos.z}, diamondAttributes.color, context.templates().create(compileChildren(context, flowElement)))); - break; - case "IsometricCamera": - // Already saved as part of the scene - break; - default: - errors.append(compileError(node, "Unsupported child tag " + childTagName)); - break; + } + default -> errors.append(compileError(node, "Unsupported child tag " + childTagName)); } } - Map attrs = new HashMap<>(Map.of( - "class", "game-scene", - "style", - "width: " + guiScaledDimension(attributes.width()) + "; height: " - + guiScaledDimension(attributes.height()), - "src", context.resolveAssetPath(attributes.placeholder()), - "data-scene-src", context.resolveAssetPath(attributes.src()), - "data-scene-width", attributes.width(), - "data-scene-height", attributes.height(), - "data-scene-zoom", attributes.zoom(), - "data-scene-interactive", attributes.interactive(), - "data-scene-in-world-annotations", new Gson().toJson(inWorldAnnotations), - "data-scene-overlay-annotations", new Gson().toJson(overlayAnnotations))); - - // Compute the relative path to the output folder to fixup asset links - attrs.put("data-scene-asset-prefix", context.getUrlPrefixToRoot()); + var placeholderImage = HTMLNode.tag("img") + .setClassName("game-scene") + .setStyles(Map.of( + "width", guiScaledDimension(attributes.width()), + "height", guiScaledDimension(attributes.height()))) + .setAttribute("src", context.resolveAssetPath(attributes.placeholder())) + .setAttribute("data-scene-src", context.resolveAssetPath(attributes.src())) + .setAttribute("data-scene-width", attributes.width()) + .setAttribute("data-scene-height", attributes.height()) + .setAttribute("data-scene-zoom", attributes.zoom()) + .setAttribute("data-scene-interactive", String.valueOf(attributes.interactive())) + .setAttribute("data-scene-in-world-annotations", new Gson().toJson(inWorldAnnotations)) + .setAttribute("data-scene-overlay-annotations", new Gson().toJson(overlayAnnotations)) + // Compute the relative path to the output folder to fixup asset links + .setAttribute("data-scene-asset-prefix", context.getUrlPrefixToRoot()); + if (attributes.background() != null) { - attrs.put("data-scene-background", attributes.background()); - } - return errors + "\n" + createHtmlElement("img", attrs, null); - } - -// -// const props = getAttributes(node) as ModelViewerProps; -// const errors: ReactNode[] = []; -// const extraProps: Partial = {}; - -// -// const result = ( -// -// ); -// if (errors.length > 0) { -// return React.createElement(React.Fragment, null, ...errors, result); -// } -// return result; -//} + placeholderImage.setAttribute("data-scene-background", attributes.background()); + } + + return placeholderImage; + } enum TooltipMode implements StringRepresentable { TEXT, @@ -900,26 +893,30 @@ public String getSerializedName() { } } - private String makeItemTooltip(WebPageCompileContext context, ItemInfoJson itemInfo, @Nullable TooltipMode mode, - String content) { + private HTMLTag makeItemTooltip(WebPageCompileContext context, + ItemInfoJson itemInfo, + @Nullable TooltipMode mode, + HTMLTag content) { mode = Objects.requireNonNullElse(mode, TooltipMode.ICON); - String tooltipContent; + HTMLTag tooltipContent; if (mode == TooltipMode.ICON) { - tooltipContent = createHtmlElement("img", Map.of( - "src", context.resolveAssetPath(itemInfo.icon), - "alt", "", - "aria-description", itemInfo.displayName, - "class", "item-icon"), null); + tooltipContent = HTMLNode.tag("img") + .setClassName("item-icon") + .setAttribute("src", context.resolveAssetPath(itemInfo.icon)) + .setAttribute("alt", "") + .setAttribute("aria-description", itemInfo.displayName); } else { - tooltipContent = createHtmlElement("span", Map.of("class", "item-name", "data-rarity", itemInfo.rarity), - itemInfo.displayName); + tooltipContent = HTMLNode.tag("span") + .setClassName("item-name") + .setAttribute("data-rarity", itemInfo.rarity) + .append(itemInfo.displayName); } var templateId = context.templates().create(tooltipContent); - return createHtmlElement("span", Map.of( - "class", "minecraft-tooltip", - "data-template", templateId), content); + return HTMLNode.tag("span", content) + .setClassName("minecraft-tooltip") + .setAttribute("data-template", templateId); } /** @@ -928,18 +925,28 @@ private String makeItemTooltip(WebPageCompileContext context, ItemInfoJson itemI */ static class TemplateContainer { private int counter = 1; - private final List templates = new ArrayList<>(); + private final List templates = new ArrayList<>(); private final Map templateContent = new HashMap<>(); - public String create(String content) { - var existingId = templateContent.get(content); + public String create(HTMLFragment content) { + return create(content.outerHtml()); + } + + public String create(HTMLNode content) { + return create(content.outerHtml()); + } + + private String create(String htmlContent) { + var existingId = templateContent.get(htmlContent); if (existingId != null) { return existingId; } var id = "tmpl-" + (counter++); - templates.add(createHtmlElement("template", Map.of("id", id), content)); - templateContent.put(content, id); + templates.add(HTMLNode.tag("template") + .setAttribute("id", id) + .append(HTMLText.text(htmlContent))); // TODO: Mark as raw HTML + templateContent.put(htmlContent, id); return id; } } diff --git a/src/main/java/guideme/internal/web/WebRenderingContextImpl.java b/src/main/java/guideme/internal/web/WebRenderingContextImpl.java new file mode 100644 index 00000000..9d3a53e4 --- /dev/null +++ b/src/main/java/guideme/internal/web/WebRenderingContextImpl.java @@ -0,0 +1,46 @@ +package guideme.internal.web; + +import guideme.libs.mdast.model.MdAstNode; +import guideme.libs.mdast.model.MdAstParent; +import guideme.siteexport.WebRenderingContext; +import guideme.siteexport.web.ExportedGuide; +import guideme.siteexport.web.HTMLFragment; +import guideme.siteexport.web.HTMLTag; +import java.util.List; + +class WebRenderingContextImpl implements WebRenderingContext { + private final WebPageCompileContext context; + private final WebPageCompiler compiler; + private final MdAstParent node; + + public WebRenderingContextImpl(WebPageCompileContext context, WebPageCompiler compiler, MdAstParent node) { + this.context = context; + this.compiler = compiler; + this.node = node; + } + + @Override + public ExportedGuide guide() { + return context.guide(); + } + + @Override + public String getAssetUrl(String assetPath) { + return context.resolveAssetPath(assetPath); + } + + @Override + public HTMLTag compileError(String message) { + return compiler.compileError(node, message); + } + + @Override + public HTMLFragment compileChildren(MdAstParent parentNode) { + return compiler.compileChildren(context, parentNode); + } + + @Override + public HTMLFragment compile(MdAstNode node, MdAstParent parentNode) { + return compiler.compileChildren(context, List.of(node), parentNode); + } +} diff --git a/src/main/java/guideme/internal/web/DefaultValue.java b/src/main/java/guideme/siteexport/DefaultValue.java similarity index 64% rename from src/main/java/guideme/internal/web/DefaultValue.java rename to src/main/java/guideme/siteexport/DefaultValue.java index 66b10839..c2ce8e5f 100644 --- a/src/main/java/guideme/internal/web/DefaultValue.java +++ b/src/main/java/guideme/siteexport/DefaultValue.java @@ -1,8 +1,10 @@ -package guideme.internal.web; +package guideme.siteexport; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +import org.jetbrains.annotations.ApiStatus; +@ApiStatus.Experimental @Retention(RetentionPolicy.RUNTIME) public @interface DefaultValue { String value(); diff --git a/src/main/java/guideme/siteexport/RecipeWebRenderingContext.java b/src/main/java/guideme/siteexport/RecipeWebRenderingContext.java deleted file mode 100644 index e1713350..00000000 --- a/src/main/java/guideme/siteexport/RecipeWebRenderingContext.java +++ /dev/null @@ -1,50 +0,0 @@ -package guideme.siteexport; - -import java.util.List; -import org.jetbrains.annotations.ApiStatus; - -@ApiStatus.Experimental -@ApiStatus.NonExtendable -public interface RecipeWebRenderingContext { - ExportedItemInfo getExportedItem(String itemId); - - /** - * Add a standard recipe box, which has a Minecraft-style frame, and shows an item-icon for the crafting station in - * its header alongside a title. The name of an item will be shown as the tooltip. - */ - RecipeBoxBuilder recipeBox(String craftingStationItemId, String header, String tooltipItemId); - - String slotHtml(String itemId); - - String slotHtml(List itemIds); - - String arrowHtml(); - - interface RecipeBoxBuilder { - /** - * Creates a standard ingredient grid using the same logic used for Vanilla crafting recipes. The exported - * recipe is expected to have an {@code ingredient} list. If the {@code shapeless} field is set to {@code true}, - * then no width is required, but if it is missing or {@code false}, the width of the recipe grid must have been - * encoded in {@code width}. - */ - RecipeBoxBuilder craftingIngredientGrid(); - - RecipeBoxBuilder shapelessSlots(List> ingredients); - - RecipeBoxBuilder arrow(); - - /** - * Adds a result slot with the current recipes result. - */ - RecipeBoxBuilder resultSlot(); - - RecipeBoxBuilder slot(String itemId); - - /** - * Appends raw HTML. - */ - RecipeBoxBuilder rawHtml(String html); - - void build(); - } -} diff --git a/src/main/java/guideme/siteexport/ResourceExporter.java b/src/main/java/guideme/siteexport/ResourceExporter.java index a1c29f12..e07e68df 100644 --- a/src/main/java/guideme/siteexport/ResourceExporter.java +++ b/src/main/java/guideme/siteexport/ResourceExporter.java @@ -89,6 +89,8 @@ default String getPathRelativeFromOutputFolder(Path p) { * * @param key The key under which the data will be retrievable. * @param value The value to be serialized to JSON using GSON. + * + * @see guideme.siteexport.web.ExportedGuide#getExtraData(String); */ void addExtraData(Identifier key, Object value); diff --git a/src/main/java/guideme/siteexport/WebRenderingContext.java b/src/main/java/guideme/siteexport/WebRenderingContext.java new file mode 100644 index 00000000..1f7af00d --- /dev/null +++ b/src/main/java/guideme/siteexport/WebRenderingContext.java @@ -0,0 +1,38 @@ +package guideme.siteexport; + +import guideme.libs.mdast.model.MdAstNode; +import guideme.libs.mdast.model.MdAstParent; +import guideme.siteexport.web.ExportedGuide; +import guideme.siteexport.web.HTMLFragment; +import guideme.siteexport.web.HTMLTag; +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Experimental +@ApiStatus.NonExtendable +public interface WebRenderingContext { + /** + * Access to the exported guide data. + */ + ExportedGuide guide(); + + /** + * Resolves the path from the current page to the given asset, which is for example the path returned by an exported + * item icon. + */ + String getAssetUrl(String assetPath); + + /** + * Compile an error message to HTML. + */ + HTMLTag compileError(String message); + + /** + * {@return the children of this node compiled to HTML} + */ + HTMLFragment compileChildren(MdAstParent parentNode); + + /** + * {@return the given node compiled to HTML} + */ + HTMLFragment compile(MdAstNode node, MdAstParent parentNode); +} diff --git a/src/main/java/guideme/siteexport/web/CustomElementWebRenderer.java b/src/main/java/guideme/siteexport/web/CustomElementWebRenderer.java new file mode 100644 index 00000000..2a6780f1 --- /dev/null +++ b/src/main/java/guideme/siteexport/web/CustomElementWebRenderer.java @@ -0,0 +1,24 @@ +package guideme.siteexport.web; + +import guideme.compiler.TagCompiler; +import java.util.Set; +import java.util.function.Consumer; +import org.jetbrains.annotations.ApiStatus; + +/** + * This interface is loaded via service-loader and used to compile custom elements in the website renderer. + */ +@ApiStatus.Experimental +public interface CustomElementWebRenderer { + /** + * The tag names this compiler is responsible for. + * + * @see TagCompiler#getTagNames() + */ + Set getTagNames(); + + /** + * Compiles the given custom element to HTML for insertion into the resulting web page. + */ + void render(CustomElementWebRenderingContext context, Consumer output); +} diff --git a/src/main/java/guideme/siteexport/web/CustomElementWebRenderingContext.java b/src/main/java/guideme/siteexport/web/CustomElementWebRenderingContext.java new file mode 100644 index 00000000..f00c020e --- /dev/null +++ b/src/main/java/guideme/siteexport/web/CustomElementWebRenderingContext.java @@ -0,0 +1,34 @@ +package guideme.siteexport.web; + +import guideme.libs.mdast.mdx.model.MdxJsxElementFields; +import guideme.siteexport.DefaultValue; +import guideme.siteexport.WebRenderingContext; +import org.jetbrains.annotations.ApiStatus; + +/** + * Context for rendering a custom element with {@link CustomElementWebRenderer}. + */ +@ApiStatus.Experimental +@ApiStatus.NonExtendable +public interface CustomElementWebRenderingContext extends WebRenderingContext { + /** + * {@return the name of the tag being compiled} + */ + String tagName(); + + /** + * {@return the element being compiled} + */ + MdxJsxElementFields element(); + + /** + * Maps between a JSX tags attributes and a given record that models these attributes. + *

+ * Record components that are annotated with {@link org.jspecify.annotations.Nullable} are considered optional. + * Record components can be annotated with {@link DefaultValue} to give them a default value. + * + * @see DefaultValue + * @see org.jspecify.annotations.Nullable + */ + T map(Class modelClass); +} diff --git a/src/main/java/guideme/siteexport/web/ExportedFluidInfo.java b/src/main/java/guideme/siteexport/web/ExportedFluidInfo.java new file mode 100644 index 00000000..75f543bf --- /dev/null +++ b/src/main/java/guideme/siteexport/web/ExportedFluidInfo.java @@ -0,0 +1,14 @@ +package guideme.siteexport.web; + +import org.jetbrains.annotations.ApiStatus; + +/** + * The data model for fluid information that was previously exported. + */ +@ApiStatus.Experimental +@ApiStatus.NonExtendable +public interface ExportedFluidInfo { + String icon(); + + String displayName(); +} diff --git a/src/main/java/guideme/siteexport/web/ExportedGuide.java b/src/main/java/guideme/siteexport/web/ExportedGuide.java new file mode 100644 index 00000000..1a38ee69 --- /dev/null +++ b/src/main/java/guideme/siteexport/web/ExportedGuide.java @@ -0,0 +1,28 @@ +package guideme.siteexport.web; + +import guideme.internal.siteexport.model.FluidInfoJson; +import guideme.internal.siteexport.model.ItemInfoJson; +import net.minecraft.resources.Identifier; +import org.jspecify.annotations.Nullable; + +public interface ExportedGuide { + String getDefaultNamespace(); + + @Nullable + ItemInfoJson tryGetItemInfo(String itemId); + + ExportedItemInfo getItemInfo(String itemId); + + @Nullable + FluidInfoJson tryGetFluidInfo(String fluidId); + + ExportedFluidInfo getFluidInfo(String fluidId); + + /** + * {@return mod specific data that was exported alongside the guide} + * + * @see guideme.siteexport.ResourceExporter#addExtraData(Identifier, Object) + */ + @Nullable + Object getExtraData(String identifier); +} diff --git a/src/main/java/guideme/siteexport/ExportedItemInfo.java b/src/main/java/guideme/siteexport/web/ExportedItemInfo.java similarity index 72% rename from src/main/java/guideme/siteexport/ExportedItemInfo.java rename to src/main/java/guideme/siteexport/web/ExportedItemInfo.java index 8fda3854..d2024615 100644 --- a/src/main/java/guideme/siteexport/ExportedItemInfo.java +++ b/src/main/java/guideme/siteexport/web/ExportedItemInfo.java @@ -1,4 +1,4 @@ -package guideme.siteexport; +package guideme.siteexport.web; import org.jetbrains.annotations.ApiStatus; @@ -8,4 +8,7 @@ @ApiStatus.Experimental @ApiStatus.NonExtendable public interface ExportedItemInfo { + String icon(); + + String displayName(); } diff --git a/src/main/java/guideme/siteexport/ExportedRecipe.java b/src/main/java/guideme/siteexport/web/ExportedRecipe.java similarity index 97% rename from src/main/java/guideme/siteexport/ExportedRecipe.java rename to src/main/java/guideme/siteexport/web/ExportedRecipe.java index 7c35eb6c..27cc8e99 100644 --- a/src/main/java/guideme/siteexport/ExportedRecipe.java +++ b/src/main/java/guideme/siteexport/web/ExportedRecipe.java @@ -1,4 +1,4 @@ -package guideme.siteexport; +package guideme.siteexport.web; import java.util.List; import java.util.Map; diff --git a/src/main/java/guideme/siteexport/web/HTMLFragment.java b/src/main/java/guideme/siteexport/web/HTMLFragment.java new file mode 100644 index 00000000..d1292aa6 --- /dev/null +++ b/src/main/java/guideme/siteexport/web/HTMLFragment.java @@ -0,0 +1,44 @@ +package guideme.siteexport.web; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class HTMLFragment { + private final List nodes = new ArrayList<>(); + + public HTMLFragment() { + } + + public HTMLFragment(HTMLNode... nodes) { + Collections.addAll(this.nodes, nodes); + } + + public List nodes() { + return Collections.unmodifiableList(nodes); + } + + public boolean isEmpty() { + return nodes.isEmpty(); + } + + public void append(HTMLNode node) { + nodes.add(node); + } + + public void append(String text) { + nodes.add(HTMLNode.text(text)); + } + + public void append(HTMLFragment fragment) { + nodes.addAll(fragment.nodes); + } + + public String outerHtml() { + var result = new StringBuilder(); + for (var node : nodes) { + result.append(node.outerHtml()); + } + return result.toString(); + } +} diff --git a/src/main/java/guideme/siteexport/web/HTMLNode.java b/src/main/java/guideme/siteexport/web/HTMLNode.java new file mode 100644 index 00000000..b88261f5 --- /dev/null +++ b/src/main/java/guideme/siteexport/web/HTMLNode.java @@ -0,0 +1,84 @@ +package guideme.siteexport.web; + +import java.util.Arrays; +import java.util.List; +import org.jspecify.annotations.Nullable; + +public sealed abstract class HTMLNode permits HTMLTag, HTMLText { + + /** + * {@return the name of the HTML tag or an empty string for text nodes} + */ + public abstract String name(); + + @Nullable + public abstract String attribute(String name); + + public final boolean hasAttribute(String name) { + return attribute(name) != null; + } + + public abstract HTMLTag setAttribute(String name, String value); + + public final HTMLTag setAttribute(String name, float value) { + return setAttribute(name, "" + value); + } + + public final HTMLTag setAttribute(String name, int value) { + return setAttribute(name, "" + value); + } + + public static HTMLTag tag(String name) { + return new HTMLTag(name); + } + + public static HTMLTag tag(String name, HTMLFragment fragment) { + var tag = tag(name); + tag.append(fragment); + return tag; + } + + public static HTMLTag tag(String name, HTMLNode... children) { + return tag(name, Arrays.asList(children)); + } + + public static HTMLTag tag(String name, List children) { + var tag = tag(name); + for (var node : children) { + tag.append(node); + } + return tag; + } + + public static HTMLText text(String content) { + return new HTMLText(content); + } + + public abstract List children(); + + public static String escapeHtml(String text) { + if (text == null) { + return ""; + } + return text + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + + public static String escapeAttribute(String text) { + if (text == null) { + return ""; + } + return text + .replace("&", "&") + .replace("\"", """) + .replace("'", "'"); + } + + public abstract String textContent(); + + public abstract String outerHtml(); +} diff --git a/src/main/java/guideme/siteexport/web/HTMLTag.java b/src/main/java/guideme/siteexport/web/HTMLTag.java new file mode 100644 index 00000000..43ada5b6 --- /dev/null +++ b/src/main/java/guideme/siteexport/web/HTMLTag.java @@ -0,0 +1,120 @@ +package guideme.siteexport.web; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; +import org.jspecify.annotations.Nullable; + +public final class HTMLTag extends HTMLNode { + private static final Pattern VALID_TAG_NAME = Pattern.compile("^[a-zA-Z][a-zA-Z0-9-]*$"); + private final Map attributes = new LinkedHashMap<>(); + private final List children = new ArrayList<>(); + private String name; + + HTMLTag(String name) { + this.name = validatedName(name); + } + + private String validatedName(String name) { + name = Objects.requireNonNull(name, "tagName"); + if (!VALID_TAG_NAME.matcher(name).matches()) { + throw new IllegalArgumentException("Invalid HTML tag name: " + name); + } + return name; + } + + @Override + public String name() { + return name; + } + + public void setName(String name) { + this.name = validatedName(name); + } + + @Override + public @Nullable String attribute(String name) { + return attributes.get(name); + } + + @Override + public HTMLTag setAttribute(String name, @Nullable String value) { + attributes.put(name, value); + return this; + } + + public HTMLTag setClassName(String className) { + return setAttribute("class", className); + } + + @Override + public List children() { + return Collections.unmodifiableList(children); + } + + public HTMLTag append(HTMLFragment fragment) { + for (var node : fragment.nodes()) { + append(node); + } + return this; + } + + public HTMLTag append(String text) { + return append(HTMLNode.text(text)); + } + + public HTMLTag append(HTMLNode node) { + children.add(Objects.requireNonNull(node, "node")); + return this; + } + + public HTMLTag setStyles(Map styles) { + var styleString = new StringBuilder(); + for (var entry : styles.entrySet()) { + if (!styleString.isEmpty()) { + styleString.append("; "); + } + styleString.append(entry.getKey()).append(": ").append(entry.getValue()); + } + setAttribute("style", styleString.toString()); + return this; + } + + private static final Set VOID_ELEMENTS = Set.of("area", "base", "br", "col", "embed", "hr", "img", "input", + "link", "meta", "source", "track", "wbr"); + + @Override + public String outerHtml() { + StringBuilder sb = new StringBuilder(); + sb.append("<").append(name); + for (var entry : attributes.entrySet()) { + sb.append(" ").append(entry.getKey()).append("=\"") + .append(escapeAttribute(String.valueOf(entry.getValue()))) + .append("\""); + } + if (VOID_ELEMENTS.contains(name) && children.isEmpty()) { + sb.append("/>"); + } else { + sb.append(">"); + for (var child : children) { + sb.append(child.outerHtml()).append("\n"); + } + sb.append(""); + } + return sb.toString(); + } + + @Override + public String textContent() { + var result = new StringBuilder(); + for (HTMLNode child : children) { + result.append(child.textContent()); + } + return result.toString(); + } +} diff --git a/src/main/java/guideme/siteexport/web/HTMLText.java b/src/main/java/guideme/siteexport/web/HTMLText.java new file mode 100644 index 00000000..206609f6 --- /dev/null +++ b/src/main/java/guideme/siteexport/web/HTMLText.java @@ -0,0 +1,47 @@ +package guideme.siteexport.web; + +import java.util.List; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +public final class HTMLText extends HTMLNode { + private String content; + + public HTMLText(String content) { + this.content = Objects.requireNonNull(content, "content"); + } + + @Override + public String name() { + return ""; + } + + public String content() { + return content; + } + + @Override + public String textContent() { + return content(); + } + + @Override + public String outerHtml() { + return escapeHtml(textContent()); + } + + @Override + public @Nullable String attribute(String name) { + return null; + } + + @Override + public HTMLTag setAttribute(String name, @Nullable String value) { + throw new UnsupportedOperationException("Cannot set attributes on text nodes."); + } + + @Override + public List children() { + return List.of(); + } +} diff --git a/src/main/java/guideme/siteexport/RecipeWebRenderer.java b/src/main/java/guideme/siteexport/web/RecipeWebRenderer.java similarity index 93% rename from src/main/java/guideme/siteexport/RecipeWebRenderer.java rename to src/main/java/guideme/siteexport/web/RecipeWebRenderer.java index 4f2e9c18..412d18f9 100644 --- a/src/main/java/guideme/siteexport/RecipeWebRenderer.java +++ b/src/main/java/guideme/siteexport/web/RecipeWebRenderer.java @@ -1,5 +1,6 @@ -package guideme.siteexport; +package guideme.siteexport.web; +import guideme.siteexport.RecipeExporter; import java.util.Set; import org.jetbrains.annotations.ApiStatus; diff --git a/src/main/java/guideme/siteexport/web/RecipeWebRenderingContext.java b/src/main/java/guideme/siteexport/web/RecipeWebRenderingContext.java new file mode 100644 index 00000000..c348b7a7 --- /dev/null +++ b/src/main/java/guideme/siteexport/web/RecipeWebRenderingContext.java @@ -0,0 +1,80 @@ +package guideme.siteexport.web; + +import guideme.siteexport.WebRenderingContext; +import java.util.List; +import java.util.function.Consumer; +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Experimental +@ApiStatus.NonExtendable +public interface RecipeWebRenderingContext extends WebRenderingContext { + + /** + * Add a standard recipe box, which has a Minecraft-style frame, and shows an item-icon for the crafting station in + * its header alongside a title. The name of an item will be shown as the tooltip. + */ + RecipeBoxBuilder recipeBox(String craftingStationItemId, String title, String tooltipItemId); + + /** + * Add a standard recipe box, which has a Minecraft-style frame, and shows the given HTML in its header. + */ + RecipeBoxBuilder recipeBox(HTMLNode header); + + HTMLNode slotHtml(String itemId); + + HTMLNode slotHtml(List itemIds); + + HTMLNode arrowHtml(); + + interface RecipeBoxBuilder { + /** + * Creates a standard ingredient grid using the same logic used for Vanilla crafting recipes. The exported + * recipe is expected to have an {@code ingredient} list. If the {@code shapeless} field is set to {@code true}, + * then no width is required, but if it is missing or {@code false}, the width of the recipe grid must have been + * encoded in {@code width}. + */ + RecipeBoxBuilder craftingIngredientGrid(); + + RecipeBoxBuilder shapelessSlots(List> ingredients); + + RecipeBoxBuilder arrow(); + + /** + * Adds a result slot with the current recipes result. + */ + RecipeBoxBuilder resultSlot(); + + default RecipeBoxBuilder slot(String itemId) { + return slot(List.of(itemId)); + } + + RecipeBoxBuilder slot(List itemIds); + + /** + * Appends a HTML node. + */ + RecipeBoxBuilder append(HTMLNode node); + + /** + * Creates a flexible grid where each cell can be filled individually. + * + * @param columns The number of columns in the grid. + * @param rows The number of rows in the grid. + */ + RecipeBoxBuilder grid(int columns, int rows, Consumer customizer); + + void build(); + } + + interface GridBuilder { + GridBuilder image(int column, int row, String assetName); + + default GridBuilder slot(int column, int row, String itemId) { + return slot(column, row, List.of(itemId)); + } + + GridBuilder slot(int column, int row, List itemId); + + GridBuilder set(int column, int row, HTMLNode node); + } +} diff --git a/web/dist/assets/bundle-FVCODRC2.js b/web/dist/assets/bundle-FVCODRC2.js deleted file mode 100644 index e78e61cf..00000000 --- a/web/dist/assets/bundle-FVCODRC2.js +++ /dev/null @@ -1 +0,0 @@ -import{a as i}from"./chunk-ZM3PKBJN.js";function a(t){for(let o of t.querySelectorAll("img.game-scene"))import("./modelViewer-A42QTX7N.js").then(e=>{let{setupGameScene:r}=e;r(o).catch(n=>{console.error("Failed to set up game scene @ %o: %s",o,n)})}).catch(e=>{console.error("Failed to load module viewer scripts.",e)})}function l(t){let o=t.querySelector(".current");o?.classList.remove("current"),(o?.nextElementSibling??t.firstElementChild)?.classList.toggle("current",!0)}function s(){setInterval(()=>{for(let t of document.querySelectorAll(".ingredient-box.cycling"))t.classList.contains("is-cycling")||(t.classList.add("is-cycling"),t.querySelector(":first-child")?.classList.toggle("current",!0)),l(t)},1e3)}function c(){let t=document.querySelector("body > main");t?.querySelector(".navbar-burger")?.addEventListener("click",o=>{o.preventDefault(),t?.classList.toggle("menu-expanded")})}function p(){i("#page-content .minecraft-tooltip",{content(t){if(!(t instanceof HTMLElement))return"";let o=t.dataset.tooltipText;if(o)return o;let e=t.dataset.template;if(!e)return console.warn("Found element %o marked as tooltip without template.",t),"";let r=document.getElementById(e);return r?r.innerHTML:(console.warn("Found element %o with tooltip from template, which is missing.",t),"")},allowHTML:!0,inlinePositioning:!0})}document.addEventListener("DOMContentLoaded",function(){c(),p(),s();let t=document.querySelector("#page-content");t&&a(t)}); diff --git a/web/dist/assets/bundle-IRSJDK6M.css b/web/dist/assets/bundle-IRSJDK6M.css deleted file mode 100644 index 5b027a16..00000000 --- a/web/dist/assets/bundle-IRSJDK6M.css +++ /dev/null @@ -1 +0,0 @@ -body>main{width:100%;height:100%;display:grid;grid-template-columns:350px 1fr;grid-template-rows:max-content 1fr max-content;padding:var(--document-rect-margin);.navbar-burger{color:#eee;-moz-appearance:none;-webkit-appearance:none;appearance:none;background:none;cursor:pointer;display:block;position:relative;flex:0 auto;aspect-ratio:1;width:auto;height:calc(100% - 10px);box-sizing:border-box;border:1px solid currentColor;border-radius:8px;margin:5px}.navbar-burger>span{background-color:currentColor;display:block;height:1px;left:calc(50% - 8px);position:absolute;transform-origin:center;transition-duration:86ms;transition-property:background-color,opacity,transform;transition-timing-function:ease-out;width:16px}.navbar-burger>span:nth-child(1){top:calc(50% - 6px)}.navbar-burger>span:nth-child(2){top:calc(50% - 1px)}.navbar-burger>span:nth-child(3){top:calc(50% + 4px)}.is-active.navbar-burger>span:nth-child(1){transform:translateY(5px) rotate(45deg)}.is-active.navbar-burger>span:nth-child(2){opacity:0}.is-active.navbar-burger>span:nth-child(3){transform:translateY(-5px) rotate(-45deg)}.version-picker{text-align:center}.topLogoBar{display:flex}.logo{display:flex;place-items:center;color:var(--color-body-text);font-weight:700;cursor:pointer;flex:1 auto;line-height:1.5;padding:.5rem .75rem}.logo:hover{text-decoration:inherit}.logo>img{max-height:1.75rem;height:auto;width:auto;max-width:100%}.page-title{display:flex;border-bottom:calc(1px * var(--gui-scale)) solid var(--color-header1-separator)}.page-title>h1{border:none;margin:0;align-self:center}>article{padding:calc(5px * var(--gui-scale));background-color:#33333380;overflow-y:scroll;grid-row:span 2}>aside{background:linear-gradient(180deg,var(--color--navbar-bg-top) 0%,var(--color--navbar-bg-bottom) 100%);overflow-y:scroll;padding:5px}}@media(max-width:768px){body>main{grid-template-columns:1fr;grid-template-rows:max-content max-content 1fr max-content;--document-rect-margin: 0 !important;.page-title{display:none}>aside,>.version-picker{display:none}&.menu-expanded>aside,&.menu-expanded>.version-picker{display:initial}&.menu-expanded>article{display:none}}}@media(min-width:768px){body>main{.navbar-burger,.inlinePageTitle{display:none}}}.navbar{svg{width:1em;height:1em;fill:currentColor;path{d:path("M10 6 8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z")}}details[open] svg path{d:path("M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z")}img{width:auto;height:1em}summary>*{user-select:none;cursor:pointer}a,.expandableHeader{display:flex;color:var(--color-body-text);align-items:center;gap:4px;box-sizing:border-box;padding-left:4px}a:hover,.expandableHeader:hover{color:var(--color-link);background-color:var(--color--navbar-row-hover);text-decoration:none}a.active{background-color:var(--color--navbar-row-hover)}details>.sublevel{margin-left:24px;display:flex;flex-direction:column;gap:2px}.item-icon,.fluid-icon{width:auto;height:1em}summary::-webkit-details-marker,summary::marker{display:none;content:none}}.item-link{text-decoration:underline dotted}.item-name{padding-left:4px;padding-right:4px;font-weight:700;color:var(--text-color);font-family:var(--minecraft-font);text-shadow:.1em .1em 0 calc(var(--text-color) * .25)}.item-name[data-rarity=common]{--text-color: white}.item-name[data-rarity=uncommon]{--text-color: #ffff55}.item-name[data-rarity=rare]{--text-color: #55ffff}.item-name[data-rarity=epic]{--text-color: #ff55ff}@media screen and (max-width:1023px){.recipe-container{max-width:100vw}.recipe-container>div+div{margin-top:1rem}.recipe-container+.recipe-container{margin-top:1rem}.recipe-container>:first-child{display:flex;justify-content:center}.recipe-container .recipe-arrow{width:9vw;height:auto}}.recipeBoxLayout{display:inline-grid;grid-template-rows:auto auto;grid-template-columns:max-content 1fr max-content;row-gap:10px;column-gap:10px;align-items:center;padding:2px}.recipeBoxLayout>:first-child{grid-column:1 / span 3;color:#333;display:flex;flex-direction:row;align-items:center;gap:3px}.recipeBoxLayout>:first-child .item-icon,.recipeBoxLayout>:first-child .fluid-icon{width:calc(8px * var(--gui-scale));height:calc(8px * var(--gui-scale))}.fluidIcon{width:calc(8px * var(--gui-scale));height:calc(8px * var(--gui-scale))}.ingredients-box,.ingredients-box-shapeless-1col,.ingredients-box-shapeless-2col,.ingredients-box-shapeless-3col{display:inline-grid;align-self:center;justify-self:start}.ingredients-box{grid-template-rows:1fr 1fr 1fr;grid-template-columns:1fr 1fr 1fr}.ingredients-box-shapeless-1col{grid-auto-rows:1fr;grid-template-columns:1fr}.ingredients-box-shapeless-2col{grid-auto-rows:1fr;grid-template-columns:1fr 1fr}.ingredients-box-shapeless-3col{grid-auto-rows:1fr;grid-template-columns:1fr 1fr 1fr}.recipe-arrow{align-self:center;justify-self:center;width:85px;height:50px}.empty-ingredient-box,.ingredient-box{display:inline-block;width:calc(18px * var(--gui-scale));height:calc(18px * var(--gui-scale));box-sizing:border-box;border:calc(1px * var(--gui-scale)) solid;image-rendering:pixelated;border-image-source:url("./slot-RVUFXOMZ.png");border-image-slice:1 fill}.ingredient-box:hover{background-color:#aaa}.ingredient-box.cycling{&:not(.is-cycling){>:not(:first-child){display:none}}&.is-cycling{>*{display:none}>.current{display:initial}}}.smelting-input-box{display:flex;flex-direction:column}.smelting-input-box>:first-child{margin-bottom:5px;border-bottom-left-radius:0;border-bottom-right-radius:0}.smelting-input-box .fire{margin:calc(1px * var(--gui-scale));width:calc(16px * var(--gui-scale));height:calc(16px * var(--gui-scale));background-image:url("./smelt-ZK26WUYF.png");background-size:contain}.smelting-input-box>:last-child{border-top-left-radius:0;border-top-right-radius:0}.inscriberGrid{display:grid;grid-template-columns:auto auto max-content auto;grid-template-rows:auto auto auto;grid-column:1 / span 3;align-items:center;justify-items:center}.inscriberGrid>:nth-child(1){grid-row:1;grid-column:1}.inscriberGrid>:nth-child(2){grid-row:2;grid-column:2}.inscriberGrid>:nth-child(3){grid-row:3;grid-column:1}.inscriberGrid>:nth-child(4){grid-row:2;grid-column:4}.inscriberGrid>:nth-child(5){grid-row:1;grid-column:2;width:calc(17px * var(--gui-scale));height:auto;margin-top:calc(10px * var(--gui-scale))}.inscriberGrid>:nth-child(6){grid-row:3;grid-column:2;width:calc(17px * var(--gui-scale));height:auto;margin-bottom:calc(10px * var(--gui-scale))}.inscriberGrid>:nth-child(7){grid-row:2;grid-column:3;width:auto;height:calc(16px * var(--gui-scale))}img.game-scene{padding:16px}.game-scene-wrapper{display:inline-flex;flex-direction:row;--modelviewer-width: auto;--modelviewer-height: auto;--modelviewer-aspect-ratio: auto;background-color:var(--color--scene-background);border-radius:16px;.loading .controls{display:none}.controls{flex:0 1 auto;display:flex;flex-direction:column}.controls button{appearance:none;border:none;background:none}.controls button img{width:32px;height:32px;opacity:.8}.controls button:hover img{opacity:1}.controls button:active img{opacity:.6}.controls button:disabled img{opacity:.2}.root{position:relative}.root>img{position:absolute}.viewport{display:inline-block}.root,.root>.viewport{width:var(--modelviewer-width);height:auto!important;aspect-ratio:var(--modelviewer-aspect-ratio);overflow:hidden}}@font-face{font-family:Pixeloid Mono;src:url("./PixeloidMono-NQAXMAIO.woff2") format("woff2"),url("./PixeloidMono-3KCHGQZV.woff") format("woff");font-weight:400;font-style:normal;font-display:swap}@font-face{font-family:Pixeloid Sans;src:url("./PixeloidSans-Bold-7JWXYWBN.woff2") format("woff2"),url("./PixeloidSans-Bold-Q6ROW6NU.woff") format("woff");font-weight:700;font-style:normal;font-display:swap}@font-face{font-family:Pixeloid Sans;src:url("./PixeloidSans-SG3QN3JO.woff2") format("woff2"),url("./PixeloidSans-LRDUD24B.woff") format("woff");font-weight:400;font-style:normal;font-display:swap}*{font-synthesis:none;text-rendering:geometricPrecision;-webkit-font-smoothing:none;-moz-osx-font-smoothing:unset;font-smooth:never;-webkit-text-size-adjust:100%;box-sizing:border-box}ul{list-style-type:square}hr{height:calc(1px * var(--gui-scale))}:root{--color-body-text: rgb(210, 210, 210);--color-link: rgb(0, 213, 255);--color-error-text: rgb(255, 0, 0);--color-header1-separator: rgba(255, 255, 255, .5);--color-header2-separator: rgba(210, 210, 210, .5);--color--navbar-bg-top: rgba(0, 0, 0, .5);--color--navbar-bg-bottom: rgba(0, 0, 0, .25);--color--navbar-row-hover: rgb(33, 33, 33);--color--navbar-expand-arrow: rgb(238, 238, 238);--color--table-border: rgb(124, 124, 124);--color--icon-button-normal: rgb(200, 200, 200);--color--icon-button-disabled: rgb(64, 64, 64);--color--icon-button-hover: rgb(0, 213, 255);--color--in-world-block-highlight: #999999cc;--color--scene-background: rgba(0, 0, 0, .2);--gui-scale: 3;--document-rect-margin: calc(20px * var(--gui-scale));color:var(--color-body-text);background-image:url("./sky_stone_block-GPFGP7LQ.png");background-color:#404040;background-size:calc(32px * var(--gui-scale));background-blend-mode:multiply;image-rendering:pixelated;font-family:Inter,system-ui,Avenir,Helvetica,Arial,sans-serif;font-size:20px;line-height:1.5;font-weight:400;color-scheme:light dark;--minecraft-font: Pixeloid Sans, sans-serif}:root>*{image-rendering:unset}html,body,#root{margin:0;padding:0;width:100vw;height:100vh}h1,h2,h3,h4,h5,h6{font-size:1rem}th{font-weight:700;font-family:var(--minecraft-font)}h1{border-bottom:1px solid var(--color-header1-separator);font-size:1.3rem;color:#fff;font-family:var(--minecraft-font)}h2{border-bottom:1px solid var(--color-header2-separator);font-size:1.1rem;font-family:var(--minecraft-font)}h3{font-size:1rem;font-family:var(--minecraft-font)}h4{font-size:1.1rem;font-weight:700}h5{font-size:1rem;font-weight:700}h6{font-size:1rem}article>nav{overflow-y:scroll}a{font-weight:500;color:var(--color-link);text-decoration:inherit}a:hover{text-decoration:underline}.item-icon{width:calc(16px * var(--gui-scale));height:calc(16px * var(--gui-scale))}table{border-collapse:collapse}table td,table th{padding:6px}th{border-bottom:calc(1px * var(--gui-scale)) solid var(--color--table-border)}tr+tr td{border-top:1px solid var(--color--table-border)}td+td,th+th{border-left:1px solid var(--color--table-border)}.layout-column{display:flex;flex-direction:column;gap:15px}.layout-row{display:flex;flex-direction:row;gap:15px;flex-wrap:wrap}.layout-item-grid{display:flex;flex-direction:row;gap:5px;flex-wrap:wrap}.minecraft-frame{display:inline-block;border:calc(4px * var(--gui-scale)) solid;image-rendering:pixelated;border-image-source:url("./background-YRAQ3RNF.png");border-image-slice:4 fill}.sub-pages{.page-icon{height:1em;width:auto;vertical-align:sub;padding-right:5px}}.tippy-box[data-animation=fade][data-state=hidden]{opacity:0}[data-tippy-root]{max-width:calc(100vw - 10px)}.tippy-box{position:relative;background-color:#333;color:#fff;border-radius:4px;font-size:14px;line-height:1.4;white-space:normal;outline:0;transition-property:transform,visibility,opacity}.tippy-box[data-placement^=top]>.tippy-arrow{bottom:0}.tippy-box[data-placement^=top]>.tippy-arrow:before{bottom:-7px;left:0;border-width:8px 8px 0;border-top-color:initial;transform-origin:center top}.tippy-box[data-placement^=bottom]>.tippy-arrow{top:0}.tippy-box[data-placement^=bottom]>.tippy-arrow:before{top:-7px;left:0;border-width:0 8px 8px;border-bottom-color:initial;transform-origin:center bottom}.tippy-box[data-placement^=left]>.tippy-arrow{right:0}.tippy-box[data-placement^=left]>.tippy-arrow:before{border-width:8px 0 8px 8px;border-left-color:initial;right:-7px;transform-origin:center left}.tippy-box[data-placement^=right]>.tippy-arrow{left:0}.tippy-box[data-placement^=right]>.tippy-arrow:before{left:-7px;border-width:8px 8px 8px 0;border-right-color:initial;transform-origin:center right}.tippy-box[data-inertia][data-state=visible]{transition-timing-function:cubic-bezier(.54,1.5,.38,1.11)}.tippy-arrow{width:16px;height:16px;color:#333}.tippy-arrow:before{content:"";position:absolute;border-color:transparent;border-style:solid}.tippy-content{position:relative;padding:5px 9px;z-index:1} diff --git a/web/dist/assets/bundle-WYJTABYV.css b/web/dist/assets/bundle-WYJTABYV.css new file mode 100644 index 00000000..d978e1de --- /dev/null +++ b/web/dist/assets/bundle-WYJTABYV.css @@ -0,0 +1 @@ +body>main{width:100%;height:100%;display:grid;grid-template-columns:350px 1fr;grid-template-rows:max-content 1fr max-content;padding:var(--document-rect-margin);.navbar-burger{color:#eee;-moz-appearance:none;-webkit-appearance:none;appearance:none;background:none;cursor:pointer;display:block;position:relative;flex:0 auto;aspect-ratio:1;width:auto;height:calc(100% - 10px);box-sizing:border-box;border:1px solid currentColor;border-radius:8px;margin:5px}.navbar-burger>span{background-color:currentColor;display:block;height:1px;left:calc(50% - 8px);position:absolute;transform-origin:center;transition-duration:86ms;transition-property:background-color,opacity,transform;transition-timing-function:ease-out;width:16px}.navbar-burger>span:nth-child(1){top:calc(50% - 6px)}.navbar-burger>span:nth-child(2){top:calc(50% - 1px)}.navbar-burger>span:nth-child(3){top:calc(50% + 4px)}.is-active.navbar-burger>span:nth-child(1){transform:translateY(5px) rotate(45deg)}.is-active.navbar-burger>span:nth-child(2){opacity:0}.is-active.navbar-burger>span:nth-child(3){transform:translateY(-5px) rotate(-45deg)}.version-picker{text-align:center}.topLogoBar{display:flex}.logo{display:flex;place-items:center;color:var(--color-body-text);font-weight:700;cursor:pointer;flex:1 auto;line-height:1.5;padding:.5rem .75rem}.logo:hover{text-decoration:inherit}.logo>img{max-height:1.75rem;height:auto;width:auto;max-width:100%}.page-title{display:flex;border-bottom:calc(1px * var(--gui-scale)) solid var(--color-header1-separator)}.page-title>h1{border:none;margin:0;align-self:center}>article{padding:calc(5px * var(--gui-scale));background-color:#33333380;overflow-y:scroll;grid-row:span 2}>aside{background:linear-gradient(180deg,var(--color--navbar-bg-top) 0%,var(--color--navbar-bg-bottom) 100%);overflow-y:scroll;padding:5px}}@media(max-width:768px){body>main{grid-template-columns:1fr;grid-template-rows:max-content max-content 1fr max-content;--document-rect-margin: 0 !important;.page-title{display:none}>aside,>.version-picker{display:none}&.menu-expanded>aside,&.menu-expanded>.version-picker{display:initial}&.menu-expanded>article{display:none}}}@media(min-width:768px){body>main{.navbar-burger,.inlinePageTitle{display:none}}}.navbar{svg{width:1em;height:1em;fill:currentColor;path{d:path("M10 6 8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z")}}details[open] svg path{d:path("M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z")}img{width:auto;height:1em}summary>*{user-select:none;cursor:pointer}a,.expandableHeader{display:flex;color:var(--color-body-text);align-items:center;gap:4px;box-sizing:border-box;padding-left:4px}a:hover,.expandableHeader:hover{color:var(--color-link);background-color:var(--color--navbar-row-hover);text-decoration:none}a.active{background-color:var(--color--navbar-row-hover)}details>.sublevel{margin-left:24px;display:flex;flex-direction:column;gap:2px}.item-icon,.fluid-icon{width:auto;height:1em}summary::-webkit-details-marker,summary::marker{display:none;content:none}}.item-link{text-decoration:underline dotted}.item-name{padding-left:4px;padding-right:4px;font-weight:700;color:var(--text-color);font-family:var(--minecraft-font);text-shadow:.1em .1em 0 calc(var(--text-color) * .25)}.item-name[data-rarity=common]{--text-color: white}.item-name[data-rarity=uncommon]{--text-color: #ffff55}.item-name[data-rarity=rare]{--text-color: #55ffff}.item-name[data-rarity=epic]{--text-color: #ff55ff}@media screen and (max-width:1023px){.recipe-container{max-width:100vw}.recipe-container>div+div{margin-top:1rem}.recipe-container+.recipe-container{margin-top:1rem}.recipe-container>:first-child{display:flex;justify-content:center}.recipe-container .recipe-arrow{width:9vw;height:auto}}.recipeBoxLayout{display:inline-grid;grid-template-rows:auto auto;grid-template-columns:max-content 1fr max-content;row-gap:10px;column-gap:10px;align-items:center;padding:2px}.recipeBoxLayout>:first-child{grid-column:1 / span 3;color:#333;display:flex;flex-direction:row;align-items:center;gap:3px}.recipeBoxLayout>:first-child .item-icon,.recipeBoxLayout>:first-child .fluid-icon{width:calc(8px * var(--gui-scale));height:calc(8px * var(--gui-scale))}.recipeBoxLayout>.grid{display:grid;align-items:center}.fluidIcon{width:calc(8px * var(--gui-scale));height:calc(8px * var(--gui-scale))}.ingredients-box,.ingredients-box-shapeless-1col,.ingredients-box-shapeless-2col,.ingredients-box-shapeless-3col{display:inline-grid;align-self:center;justify-self:start}.ingredients-box{grid-template-rows:1fr 1fr 1fr;grid-template-columns:1fr 1fr 1fr}.ingredients-box-shapeless-1col{grid-auto-rows:1fr;grid-template-columns:1fr}.ingredients-box-shapeless-2col{grid-auto-rows:1fr;grid-template-columns:1fr 1fr}.ingredients-box-shapeless-3col{grid-auto-rows:1fr;grid-template-columns:1fr 1fr 1fr}.recipe-arrow{align-self:center;justify-self:center;width:85px;height:50px}.empty-ingredient-box,.ingredient-box{display:inline-block;width:calc(18px * var(--gui-scale));height:calc(18px * var(--gui-scale));box-sizing:border-box;border:calc(1px * var(--gui-scale)) solid;image-rendering:pixelated;border-image-source:url("./slot-RVUFXOMZ.png");border-image-slice:1 fill}.ingredient-box:hover{background-color:#aaa}.ingredient-box.cycling{&:not(.is-cycling){>:not(:first-child){display:none}}&.is-cycling{>*{display:none}>.current{display:initial}}}.smelting-input-box{display:flex;flex-direction:column}.smelting-input-box>:first-child{margin-bottom:5px;border-bottom-left-radius:0;border-bottom-right-radius:0}.smelting-input-box .fire{margin:calc(1px * var(--gui-scale));width:calc(16px * var(--gui-scale));height:calc(16px * var(--gui-scale));background-image:url("./smelt-ZK26WUYF.png");background-size:contain}.smelting-input-box>:last-child{border-top-left-radius:0;border-top-right-radius:0}.inscriberGrid{display:grid;grid-template-columns:auto auto max-content auto;grid-template-rows:auto auto auto;grid-column:1 / span 3;align-items:center;justify-items:center}.inscriberGrid>:nth-child(1){grid-row:1;grid-column:1}.inscriberGrid>:nth-child(2){grid-row:2;grid-column:2}.inscriberGrid>:nth-child(3){grid-row:3;grid-column:1}.inscriberGrid>:nth-child(4){grid-row:2;grid-column:4}.inscriberGrid>:nth-child(5){grid-row:1;grid-column:2;width:calc(17px * var(--gui-scale));height:auto;margin-top:calc(10px * var(--gui-scale))}.inscriberGrid>:nth-child(6){grid-row:3;grid-column:2;width:calc(17px * var(--gui-scale));height:auto;margin-bottom:calc(10px * var(--gui-scale))}.inscriberGrid>:nth-child(7){grid-row:2;grid-column:3;width:auto;height:calc(16px * var(--gui-scale))}img.game-scene{padding:16px}.game-scene-wrapper{display:inline-flex;flex-direction:row;--modelviewer-width: auto;--modelviewer-height: auto;--modelviewer-aspect-ratio: auto;background-color:var(--color--scene-background);border-radius:16px;.loading .controls{display:none}.controls{flex:0 1 auto;display:flex;flex-direction:column}.controls button{appearance:none;border:none;background:none}.controls button img{width:32px;height:32px;opacity:.8}.controls button:hover img{opacity:1}.controls button:active img{opacity:.6}.controls button:disabled img{opacity:.2}.root{position:relative}.root>img{position:absolute}.viewport{display:inline-block}.root,.root>.viewport{width:var(--modelviewer-width);height:auto!important;aspect-ratio:var(--modelviewer-aspect-ratio);overflow:hidden}}@font-face{font-family:Pixeloid Mono;src:url("./PixeloidMono-NQAXMAIO.woff2") format("woff2"),url("./PixeloidMono-3KCHGQZV.woff") format("woff");font-weight:400;font-style:normal;font-display:swap}@font-face{font-family:Pixeloid Sans;src:url("./PixeloidSans-Bold-7JWXYWBN.woff2") format("woff2"),url("./PixeloidSans-Bold-Q6ROW6NU.woff") format("woff");font-weight:700;font-style:normal;font-display:swap}@font-face{font-family:Pixeloid Sans;src:url("./PixeloidSans-SG3QN3JO.woff2") format("woff2"),url("./PixeloidSans-LRDUD24B.woff") format("woff");font-weight:400;font-style:normal;font-display:swap}*{font-synthesis:none;text-rendering:geometricPrecision;-webkit-font-smoothing:none;-moz-osx-font-smoothing:unset;font-smooth:never;-webkit-text-size-adjust:100%;box-sizing:border-box}ul{list-style-type:square}hr{height:calc(1px * var(--gui-scale))}:root{--color-body-text: rgb(210, 210, 210);--color-link: rgb(0, 213, 255);--color-error-text: rgb(255, 0, 0);--color-header1-separator: rgba(255, 255, 255, .5);--color-header2-separator: rgba(210, 210, 210, .5);--color--navbar-bg-top: rgba(0, 0, 0, .5);--color--navbar-bg-bottom: rgba(0, 0, 0, .25);--color--navbar-row-hover: rgb(33, 33, 33);--color--navbar-expand-arrow: rgb(238, 238, 238);--color--table-border: rgb(124, 124, 124);--color--icon-button-normal: rgb(200, 200, 200);--color--icon-button-disabled: rgb(64, 64, 64);--color--icon-button-hover: rgb(0, 213, 255);--color--in-world-block-highlight: #999999cc;--color--scene-background: rgba(0, 0, 0, .2);--gui-scale: 3;--document-rect-margin: calc(20px * var(--gui-scale));color:var(--color-body-text);background-image:url("./sky_stone_block-GPFGP7LQ.png");background-color:#404040;background-size:calc(32px * var(--gui-scale));background-blend-mode:multiply;image-rendering:pixelated;font-family:Inter,system-ui,Avenir,Helvetica,Arial,sans-serif;font-size:20px;line-height:1.5;font-weight:400;color-scheme:light dark;--minecraft-font: Pixeloid Sans, sans-serif}:root>*{image-rendering:unset}html,body,#root{margin:0;padding:0;width:100vw;height:100vh}h1,h2,h3,h4,h5,h6{font-size:1rem}th{font-weight:700;font-family:var(--minecraft-font)}h1{border-bottom:1px solid var(--color-header1-separator);font-size:1.3rem;color:#fff;font-family:var(--minecraft-font)}h2{border-bottom:1px solid var(--color-header2-separator);font-size:1.1rem;font-family:var(--minecraft-font)}h3{font-size:1rem;font-family:var(--minecraft-font)}h4{font-size:1.1rem;font-weight:700}h5{font-size:1rem;font-weight:700}h6{font-size:1rem}article>nav{overflow-y:scroll}a{font-weight:500;color:var(--color-link);text-decoration:inherit}a:hover{text-decoration:underline}.item-icon{width:calc(16px * var(--gui-scale));height:calc(16px * var(--gui-scale))}table{border-collapse:collapse}table td,table th{padding:6px}th{border-bottom:calc(1px * var(--gui-scale)) solid var(--color--table-border)}tr+tr td{border-top:1px solid var(--color--table-border)}td+td,th+th{border-left:1px solid var(--color--table-border)}.layout-column{display:flex;flex-direction:column;gap:15px}.layout-row{display:flex;flex-direction:row;gap:15px;flex-wrap:wrap}.layout-item-grid{display:flex;flex-direction:row;gap:5px;flex-wrap:wrap}.minecraft-frame{display:inline-block;border:calc(4px * var(--gui-scale)) solid;image-rendering:pixelated;border-image-source:url("./background-YRAQ3RNF.png");border-image-slice:4 fill}.sub-pages{.page-icon{height:1em;width:auto;vertical-align:sub;padding-right:5px}}.tippy-box[data-animation=fade][data-state=hidden]{opacity:0}[data-tippy-root]{max-width:calc(100vw - 10px)}.tippy-box{position:relative;background-color:#333;color:#fff;border-radius:4px;font-size:14px;line-height:1.4;white-space:normal;outline:0;transition-property:transform,visibility,opacity}.tippy-box[data-placement^=top]>.tippy-arrow{bottom:0}.tippy-box[data-placement^=top]>.tippy-arrow:before{bottom:-7px;left:0;border-width:8px 8px 0;border-top-color:initial;transform-origin:center top}.tippy-box[data-placement^=bottom]>.tippy-arrow{top:0}.tippy-box[data-placement^=bottom]>.tippy-arrow:before{top:-7px;left:0;border-width:0 8px 8px;border-bottom-color:initial;transform-origin:center bottom}.tippy-box[data-placement^=left]>.tippy-arrow{right:0}.tippy-box[data-placement^=left]>.tippy-arrow:before{border-width:8px 0 8px 8px;border-left-color:initial;right:-7px;transform-origin:center left}.tippy-box[data-placement^=right]>.tippy-arrow{left:0}.tippy-box[data-placement^=right]>.tippy-arrow:before{left:-7px;border-width:8px 8px 8px 0;border-right-color:initial;transform-origin:center right}.tippy-box[data-inertia][data-state=visible]{transition-timing-function:cubic-bezier(.54,1.5,.38,1.11)}.tippy-arrow{width:16px;height:16px;color:#333}.tippy-arrow:before{content:"";position:absolute;border-color:transparent;border-style:solid}.tippy-content{position:relative;padding:5px 9px;z-index:1} diff --git a/web/dist/index.txt b/web/dist/index.txt index 11f947ad..af5402ee 100644 --- a/web/dist/index.txt +++ b/web/dist/index.txt @@ -1,6 +1,6 @@ assets/background-YRAQ3RNF.png -assets/bundle-FVCODRC2.js -assets/bundle-IRSJDK6M.css +assets/bundle-IEHLY4O6.js +assets/bundle-WYJTABYV.css assets/chunk-ZM3PKBJN.js assets/decompressFallback-VGYIC7XH.js assets/diamond-7X2HHREI.png diff --git a/web/dist/templates/layout.html b/web/dist/templates/layout.html index 8513ca15..ec9d67ee 100644 --- a/web/dist/templates/layout.html +++ b/web/dist/templates/layout.html @@ -4,9 +4,9 @@ {{PAGE_TITLE_TEXT}} - + - +

@@ -37,6 +37,6 @@ {{FOOTER}}
- + diff --git a/web/src/index.ts b/web/src/index.ts index 4a910d08..7d21368d 100644 --- a/web/src/index.ts +++ b/web/src/index.ts @@ -2,42 +2,139 @@ import "./index.css"; import "tippy.js/dist/tippy.css"; import tippy from "tippy.js"; +function isInternalLink(link: URL): boolean { + const pathToRoot = document.body.dataset.pathToRoot; + const baseUrl = new URL(pathToRoot ?? '/', location.href); + return link.host === baseUrl.host && link.pathname.startsWith(baseUrl.pathname); +} + +function resolveRelativeLinks(container: ParentNode) { + container.querySelectorAll('a[href]').forEach(link => { + const href = link.getAttribute('href'); + if (!href || href.startsWith('#')) { + return; // Skip anchor-only links + } + + try { + const absoluteUrl = new URL(href, window.location.href); + link.setAttribute('href', absoluteUrl.href); + } catch (e) { + console.warn('Invalid link %o:', link, href); + } + }); +} + +async function handleNavigationAsync(targetUrl: URL, pushState: boolean = true) { + const fetchUrl = new URL(targetUrl); + fetchUrl.hash = ''; + + console.info("Fetching %s", fetchUrl); + const response = await fetch(fetchUrl, { + mode: "same-origin" + }); + if (!response.ok) { + throw new Error("Failed to fetch " + fetchUrl + ": HTTP Status " + response.status); + } + + const domParser = new DOMParser(); + const newDocument = domParser.parseFromString(await response.text(), "text/html"); + const newPageContent = newDocument.querySelector("#page-content"); + if (!newPageContent) { + throw new Error("Fetched page does not include page content"); + } + const currentPageContent = document.querySelector("#page-content"); + if (!currentPageContent) { + return; + } + document.adoptNode(newPageContent); + currentPageContent.replaceWith(newPageContent); + document.title = newDocument.title; + fixupPageContent(newPageContent); + + if (pushState) { + // Use response.url to follow redirects + history.pushState({url: response.url}, '', response.url); + } + + newPageContent.parentElement?.scrollTo(0, 0); +} + +function handleNavigation(targetUrl: URL, pushState: boolean = true) { + handleNavigationAsync(targetUrl, pushState) + .catch(err => { + console.error(`Navigating to ${targetUrl} failed: ${err}`); + window.location.href = targetUrl.toString(); + }); +} + +function hookNavigation() { + document.addEventListener("click", e => { + if (e.target instanceof Element) { + const closestLink = e.target?.closest("a"); + if (closestLink) { + // Expand the navbar as needed + closestLink.closest("summary")?.click(); + + const targetUrl = new URL(closestLink.href); + if (targetUrl.host === location.host && targetUrl.pathname === location.pathname) { + return; // Let anchor navigation handle it + } + + if (isInternalLink(targetUrl)) { + console.log("Clicked internal link %s", targetUrl); + e.preventDefault(); + handleNavigation(targetUrl); + } + } + } + }, { + capture: true + }); + + // Handle browser back/forward buttons + window.addEventListener('popstate', function (e) { + if (e.state && e.state.url) { + handleNavigation(e.state.url, false); + } + }); +} + function fixupPageContent(root: Element) { - for (const gameSceneEl of root.querySelectorAll("img.game-scene")) { - - import("./model-viewer/modelViewer.ts").then(module => { - const { setupGameScene } = module; - setupGameScene(gameSceneEl as HTMLElement).catch((err) => { - console.error("Failed to set up game scene @ %o: %s", gameSceneEl, err); - }); - }).catch(err => { - console.error('Failed to load module viewer scripts.', err); - }) - } + for (const gameSceneEl of root.querySelectorAll("img.game-scene")) { + + import("./model-viewer/modelViewer.ts").then(module => { + const {setupGameScene} = module; + setupGameScene(gameSceneEl as HTMLElement).catch((err) => { + console.error("Failed to set up game scene @ %o: %s", gameSceneEl, err); + }); + }).catch(err => { + console.error('Failed to load module viewer scripts.', err); + }) + } } function cycleChildren(container: Element) { - const current = container.querySelector(".current"); - current?.classList.remove("current"); - const nextEl = current?.nextElementSibling ?? container.firstElementChild; - nextEl?.classList.toggle("current", true); + const current = container.querySelector(".current"); + current?.classList.remove("current"); + const nextEl = current?.nextElementSibling ?? container.firstElementChild; + nextEl?.classList.toggle("current", true); } function setupCyclingIngredients() { - setInterval(() => { - for (const ingredientBox of document.querySelectorAll( - ".ingredient-box.cycling", - )) { - // We run 1s after the page load, and should cycle immediately to the next element - if (!ingredientBox.classList.contains("is-cycling")) { - ingredientBox.classList.add("is-cycling"); - ingredientBox - .querySelector(":first-child") - ?.classList.toggle("current", true); - } - cycleChildren(ingredientBox); - } - }, 1000); + setInterval(() => { + for (const ingredientBox of document.querySelectorAll( + ".ingredient-box.cycling", + )) { + // We run 1s after the page load, and should cycle immediately to the next element + if (!ingredientBox.classList.contains("is-cycling")) { + ingredientBox.classList.add("is-cycling"); + ingredientBox + .querySelector(":first-child") + ?.classList.toggle("current", true); + } + cycleChildren(ingredientBox); + } + }, 1000); } /** @@ -46,57 +143,59 @@ function setupCyclingIngredients() { * continuously. */ function setupMenuBarToggle() { - const mainElement = document.querySelector("body > main"); - mainElement - ?.querySelector(".navbar-burger") - ?.addEventListener("click", (e) => { - e.preventDefault(); - mainElement?.classList.toggle("menu-expanded"); - }); + const mainElement = document.querySelector("body > main"); + mainElement + ?.querySelector(".navbar-burger") + ?.addEventListener("click", (e) => { + e.preventDefault(); + mainElement?.classList.toggle("menu-expanded"); + }); } function setupTooltips() { - tippy("#page-content .minecraft-tooltip", { - content(reference: Element) { - if (!(reference instanceof HTMLElement)) { - return ""; - } - - const textContent = reference.dataset.tooltipText; - if (textContent) { - return textContent; - } - - const id = reference.dataset.template; - if (!id) { - console.warn( - "Found element %o marked as tooltip without template.", - reference, - ); - return ""; - } - const template = document.getElementById(id); - if (!template) { - console.warn( - "Found element %o with tooltip from template, which is missing.", - reference, - ); - return ""; - } - return template.innerHTML; - }, - allowHTML: true, - inlinePositioning: true, - }); + tippy("#page-content .minecraft-tooltip", { + content(reference: Element) { + if (!(reference instanceof HTMLElement)) { + return ""; + } + + const textContent = reference.dataset.tooltipText; + if (textContent) { + return textContent; + } + + const id = reference.dataset.template; + if (!id) { + console.warn( + "Found element %o marked as tooltip without template.", + reference, + ); + return ""; + } + const template = document.getElementById(id); + if (!template) { + console.warn( + "Found element %o with tooltip from template, which is missing.", + reference, + ); + return ""; + } + return template.innerHTML; + }, + allowHTML: true, + inlinePositioning: true, + }); } document.addEventListener("DOMContentLoaded", function () { - setupMenuBarToggle(); - setupTooltips(); - setupCyclingIngredients(); - - const pageContentRoot = document.querySelector("#page-content"); - if (pageContentRoot) { - fixupPageContent(pageContentRoot); - } + hookNavigation(); + setupMenuBarToggle(); + setupTooltips(); + setupCyclingIngredients(); + + const pageContentRoot = document.querySelector("#page-content"); + if (pageContentRoot) { + fixupPageContent(pageContentRoot); + } + resolveRelativeLinks(document); }); diff --git a/web/src/layout.html b/web/src/layout.html index 2b6ff4a9..f0976b5b 100644 --- a/web/src/layout.html +++ b/web/src/layout.html @@ -6,7 +6,7 @@ {{PAGE_TITLE_TEXT}} - +
diff --git a/web/src/recipes.css b/web/src/recipes.css index e542a98f..8745784b 100644 --- a/web/src/recipes.css +++ b/web/src/recipes.css @@ -51,6 +51,12 @@ height: calc(8px * var(--gui-scale)); } +/* Custom grid */ +.recipeBoxLayout > .grid { + display: grid; + align-items: center; +} + .fluidIcon { width: calc(8px * var(--gui-scale)); height: calc(8px * var(--gui-scale));