diff --git a/build.gradle b/build.gradle index 7d66ec9..71e029c 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,5 @@ plugins { - id 'dev.architectury.loom' version '1.7-SNAPSHOT' apply false + id 'dev.architectury.loom' version '1.13-SNAPSHOT' apply false id 'architectury-plugin' version '3.4-SNAPSHOT' id 'com.github.johnrengelman.shadow' version '8.1.1' apply false id 'org.moddedmc.wiki.toolkit' version '0.2.7' @@ -91,4 +91,28 @@ subprojects { // retrieving dependencies. } } + + if (project.name != 'common') { + sourceSets { + testmod { + compileClasspath += sourceSets.main.compileClasspath + runtimeClasspath += sourceSets.main.runtimeClasspath + + java.srcDir project(':common').file('src/testmod/java') + resources.srcDir project(':common').file('src/testmod/resources') + } + } + + loom { + runs { + testmodClient { + client() + ideConfigGenerated true + name = "Testmod Client" + source sourceSets.main + source sourceSets.testmod + } + } + } + } } diff --git a/common/build.gradle b/common/build.gradle index 6af4540..5e6ba8d 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -35,4 +35,11 @@ tasks.named('processResources') { doLast { println "processResources executed: wiki folder copied into assets/oracle_index" } -} \ No newline at end of file +} + +sourceSets { + testmod { + compileClasspath += sourceSets.main.compileClasspath + runtimeClasspath += sourceSets.main.runtimeClasspath + } +} diff --git a/common/src/main/java/rearth/oracle/OracleClient.java b/common/src/main/java/rearth/oracle/OracleClient.java index e43a8c6..cd16626 100644 --- a/common/src/main/java/rearth/oracle/OracleClient.java +++ b/common/src/main/java/rearth/oracle/OracleClient.java @@ -6,9 +6,7 @@ import net.minecraft.client.MinecraftClient; import net.minecraft.client.gui.screen.Screen; import net.minecraft.client.option.KeyBinding; -import net.minecraft.client.resource.language.I18n; import net.minecraft.item.ItemStack; -import net.minecraft.registry.Registries; import net.minecraft.resource.ResourceManager; import net.minecraft.resource.ResourceType; import net.minecraft.resource.SynchronousResourceReloader; @@ -18,17 +16,17 @@ import org.apache.logging.log4j.core.config.Configurator; import org.jetbrains.annotations.Nullable; import org.lwjgl.glfw.GLFW; +import rearth.oracle.docs.DocsFormat; +import rearth.oracle.docs.DocsIndexer; +import rearth.oracle.docs.DocsMode; import rearth.oracle.progress.AdvancementProgressValidator; import rearth.oracle.ui.OracleScreen; import rearth.oracle.ui.SearchScreen; -import rearth.oracle.util.MarkdownParser; +import rearth.oracle.util.TitleLookup; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Optional; -import java.util.Set; +import java.util.*; +import java.util.function.BiPredicate; +import java.util.function.Supplier; public final class OracleClient { @@ -37,11 +35,12 @@ public final class OracleClient { public static final KeyBinding ORACLE_WIKI = new KeyBinding("key.oracle_index.open", GLFW.GLFW_KEY_H, "key.categories.oracle"); public static final KeyBinding ORACLE_SEARCH = new KeyBinding("key.oracle_index.search", -1, "key.categories.oracle"); - public static final Set LOADED_WIKIS = new HashSet<>(); // just keeps a set of loaded wiki ids - public static final HashMap ITEM_LINKS = new HashMap<>(); // items that have a corresponding wiki page (docs or content) - public static final HashMap> UNLOCK_CRITERIONS = new HashMap<>(); // path/key here is: "books/modid/folder/entry.mdx". Value is unlock type and content - public static final HashMap> AVAILABLE_MODES = new HashMap<>(); // wikiID -> Set of available modes (e.g., "oritech" -> ["docs", "content"]) - public static final HashMap CONTENT_ID_MAP = new HashMap<>();// item / block id -> resource path (e.g., "oritech:enderic_laser" -> "oracle_index:books/oritech/.content/machines/laser.mdx") + public static final Map LOADED_WIKIS = new HashMap<>(); // map of loaded wiki ids to formats (specifies directory layout) + public static final Map ITEM_LINKS = new HashMap<>(); // items that have a corresponding wiki page (docs or content) + public static final Map> UNLOCK_CRITERIONS = new HashMap<>(); // path/key here is: "books/modid/folder/entry.mdx". Value is unlock type and content + public static final Map> AVAILABLE_MODES = new HashMap<>(); // wikiID -> Set of available modes (e.g., "oritech" -> ["docs", "content"]) + public static final Map CONTENT_ID_MAP = new HashMap<>();// item / block id -> resource path (e.g., "oritech:enderic_laser" -> "oracle_index:books/oritech/.content/machines/laser.mdx") + public static final Map> CONTENT_REF_MAP = new HashMap<>();// page ref -> resource path (e.g., "colored_cables" -> "oracle_index:books/oritech/.content/cabling/colored_cables.mdx") public static ItemStack tooltipStack; public static float openEntryProgress = 0; @@ -147,75 +146,58 @@ public static boolean hasContentEntry(Identifier assetId) { return CONTENT_ID_MAP.containsKey(assetId.toString()); } + public static DocsMode getDocsModeForPage(Identifier pageId) { + String path = pageId.getPath(); + String modId = Objects.requireNonNull(DocsIndexer.extractModid(path), "modid must be extracted"); + DocsFormat format = getWikiFormat(modId); + return format.isContentPath(path) ? DocsMode.CONTENT : DocsMode.DOCS; + } + + public static DocsFormat getWikiFormat(String wikiId) { + return Objects.requireNonNull(LOADED_WIKIS.get(wikiId), "unknown wiki id"); + } + + @Nullable + public static Identifier getPage(String wikiId, String ref) { + Map refs = CONTENT_REF_MAP.get(wikiId); + return refs != null ? refs.get(ref) : null; + } + private static void findAllResourceEntries(ResourceManager manager) { - var resources = manager.findResources(ROOT_DIR, path -> path.getPath().endsWith(".mdx")); - + TitleLookup.clearCache(); + + DocsIndexer indexer = new DocsIndexer(); + indexer.findAllResourceEntries(manager); + LOADED_WIKIS.clear(); + LOADED_WIKIS.putAll(indexer.getLoadedWikis()); + ITEM_LINKS.clear(); + ITEM_LINKS.putAll(indexer.getItemLinks()); + UNLOCK_CRITERIONS.clear(); + UNLOCK_CRITERIONS.putAll(indexer.getUnlockCriterions()); + CONTENT_ID_MAP.clear(); - AVAILABLE_MODES.clear(); + CONTENT_ID_MAP.putAll(indexer.getContentIds()); - for (var entry : resources.entrySet()) { - var resourceId = entry.getKey(); - var path = resourceId.getPath(); // e.g., "books/oritech/.content/machines/laser.mdx" - - // extract mode + mod id - var segments = path.split("/"); - if (segments.length < 2) continue; - - var modId = segments[1]; // e.g., "oritech" - LOADED_WIKIS.add(modId); - - if (path.contains(".translated")) continue; // skip / don't support translations for now in indexing - - // check docs or content - var isContent = path.contains("/.content/"); - var mode = isContent ? "content" : "docs"; - AVAILABLE_MODES.computeIfAbsent(modId, k -> new HashSet<>()).add(mode); - - // parse frontmatter - try (var inputStream = entry.getValue().getInputStream()) { - var fileContent = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); - var frontmatter = MarkdownParser.parseFrontmatter(fileContent); - - // map game id for content links - if (isContent && frontmatter.containsKey("id")) { - var id = frontmatter.get("id").trim(); - CONTENT_ID_MAP.put(id, resourceId); - var itemId = Identifier.of(id); - var title = frontmatter.getOrDefault("title", "missing"); - if (title.equals("missing") && Registries.ITEM.containsId(itemId)) - title = I18n.translate(Registries.ITEM.get(itemId).getTranslationKey()); - ITEM_LINKS.put(itemId, new ItemArticleRef(resourceId, title, modId)); - } - - // frontmatter custom item links indexing - if (frontmatter.containsKey("related_items")) { - var baseString = frontmatter.get("related_items").replace("[", "").replace("]", "").replace("\"", ""); - for (var itemString : baseString.split(", ")) { - var itemId = Identifier.of(itemString.trim()); - ITEM_LINKS.put(itemId, new ItemArticleRef(resourceId, frontmatter.getOrDefault("title", "missing"), modId)); - } - } - - if (frontmatter.containsKey("unlock")) { - var unlockText = frontmatter.get("unlock"); - var parts = unlockText.split(":", 2); - if (parts.length == 2) { - UNLOCK_CRITERIONS.put(path, new Pair<>(parts[0], parts[1])); - } - } - - } catch (IOException e) { - Oracle.LOGGER.error("Unable to load book entry: {}", resourceId, e); - } - } + CONTENT_REF_MAP.clear(); + CONTENT_REF_MAP.putAll(indexer.getContentRefs()); + + AVAILABLE_MODES.clear(); + AVAILABLE_MODES.putAll(indexer.getAvailableModes()); } public static SemanticSearch getOrCreateSearch() { - if (searchInstance == null) searchInstance = new SemanticSearch(); + if (searchInstance == null) { + BiPredicate filter = (modId, path) -> { + DocsFormat format = getWikiFormat(modId); + return !format.isTranslatedPath(path); + }; + + searchInstance = new SemanticSearch(filter); + } return searchInstance; } @@ -230,7 +212,9 @@ public static Optional getTranslatedPath(Identifier identifier, Stri var resourceManager = MinecraftClient.getInstance().getResourceManager(); if (!languageCode.startsWith("en_")) { - var translatedPath = Identifier.of(identifier.getNamespace(), identifier.getPath().replace(ROOT_DIR + "/" + wikiId, ROOT_DIR + "/" + wikiId + "/.translated/" + languageCode)); + var format = getWikiFormat(wikiId); + var translatedDir = format.getTranslatedDir(languageCode); + var translatedPath = Identifier.of(identifier.getNamespace(), identifier.getPath().replace(ROOT_DIR + "/" + wikiId, ROOT_DIR + "/" + wikiId + translatedDir)); if (resourceManager.getResource(translatedPath).isPresent()) { return Optional.of(translatedPath); @@ -241,7 +225,7 @@ public static Optional getTranslatedPath(Identifier identifier, Stri return Optional.empty(); } - public record ItemArticleRef(Identifier linkTarget, String entryName, String wikiId) { + public record ItemArticleRef(Identifier linkTarget, Supplier entryName, String wikiId, int pageIDs) { } } diff --git a/common/src/main/java/rearth/oracle/SemanticSearch.java b/common/src/main/java/rearth/oracle/SemanticSearch.java index 4286349..8298020 100644 --- a/common/src/main/java/rearth/oracle/SemanticSearch.java +++ b/common/src/main/java/rearth/oracle/SemanticSearch.java @@ -12,6 +12,7 @@ import net.minecraft.client.MinecraftClient; import net.minecraft.util.Identifier; import rearth.oracle.util.MarkdownParser; +import rearth.oracle.util.TitleLookup; import java.io.IOException; import java.io.InvalidObjectException; @@ -21,6 +22,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiPredicate; import static rearth.oracle.OracleClient.ROOT_DIR; @@ -33,7 +35,7 @@ public class SemanticSearch { public static AtomicBoolean EMBEDDING_ERRORED = new AtomicBoolean(false); public static AtomicBoolean FINISHED = new AtomicBoolean(false); - public SemanticSearch() { + public SemanticSearch(BiPredicate filter) { // do this in background to avoid freezing main new Thread(() -> { @@ -73,18 +75,23 @@ public SemanticSearch() { var entryFileName = segments[segments.length - 1]; // e.g. "wrench.mdx" var entryDirectory = entryPath.replace(entryFileName, ""); // e.g. "tools" or "processing/reactor" - if (entryDirectory.contains(".translated")) continue; // skip / don't support translations for now + if (!filter.test(modId, entryDirectory)) continue; // skip / don't support translations for now try { var fileContent = new String(resources.get(resourceId).getInputStream().readAllBytes(), StandardCharsets.UTF_8); - var fileComponents = MarkdownParser.parseFrontmatter(fileContent); - + var frontmatter = MarkdownParser.parseFrontmatter(fileContent); + var title = MarkdownParser.parseHeadingTitle(fileContent); + // generate embeddings - this.queueEmbeddingsJob(modId, entryDirectory, entryFileName, fileComponents, fileContent); + var fileComponents = new HashMap(); + frontmatter.map().forEach((k, v) -> { + if (v.size() == 1) fileComponents.put(k, v.getFirst()); + }); + this.queueEmbeddingsJob(modId, entryDirectory, entryFileName, fileComponents, fileContent, title); } catch (IOException e) { - Oracle.LOGGER.error("Unable to load book with id: " + resourceId); + Oracle.LOGGER.error("Unable to load book with id: {}", resourceId); throw new RuntimeException(e); } } @@ -127,12 +134,7 @@ public ArrayList search(String query) { var id = match.embedded().metadata().getString("wiki") + ":" + match.embedded().metadata().getString("category") + match.embedded().metadata().getString("fileName"); var title = match.embedded().metadata().getString("title"); if (title == null) { - var frontmatter = new HashMap(); - for (var data : match.embedded().metadata().toMap().entrySet()) { - if (data.getValue() instanceof String value) - frontmatter.put(data.getKey(), value); - } - title = MarkdownParser.getTitle(frontmatter, Identifier.of(id)); + title = TitleLookup.getTitle(Identifier.of(id)); } // check if id already exists, add it to alt texts @@ -154,12 +156,13 @@ public ArrayList search(String query) { } - public void queueEmbeddingsJob(String wikiId, String filePath, String fileName, Map frontmatter, String content) { + public void queueEmbeddingsJob(String wikiId, String filePath, String fileName, Map frontmatter, String content, String title) { var document = Document.from(content, Metadata.from(frontmatter)); document.metadata().put("fileName", fileName); document.metadata().put("category", filePath); document.metadata().put("wiki", wikiId); + if (title != null) document.metadata().put("title", title); ingestor.ingest(document); } diff --git a/common/src/main/java/rearth/oracle/docs/DocsFormat.java b/common/src/main/java/rearth/oracle/docs/DocsFormat.java new file mode 100644 index 0000000..3497d9f --- /dev/null +++ b/common/src/main/java/rearth/oracle/docs/DocsFormat.java @@ -0,0 +1,17 @@ +package rearth.oracle.docs; + +public interface DocsFormat { + boolean isContentPath(String path); + + boolean isTranslatedPath(String path); + + String getDocsRoot(DocsMode mode); + + String getTranslatedDir(String locale); + + String getAssetsRoot(); + + String getDocsPagePath(String slug); + + String stripContentPrefix(String path); +} diff --git a/common/src/main/java/rearth/oracle/docs/DocsIndexer.java b/common/src/main/java/rearth/oracle/docs/DocsIndexer.java new file mode 100644 index 0000000..ccb1479 --- /dev/null +++ b/common/src/main/java/rearth/oracle/docs/DocsIndexer.java @@ -0,0 +1,260 @@ +package rearth.oracle.docs; + +import com.google.common.base.Suppliers; +import com.google.common.collect.HashMultimap; +import com.google.common.collect.Multimap; +import com.google.gson.Gson; +import com.mojang.logging.LogUtils; +import net.minecraft.client.resource.language.I18n; +import net.minecraft.registry.Registries; +import net.minecraft.resource.Resource; +import net.minecraft.resource.ResourceManager; +import net.minecraft.util.Identifier; +import net.minecraft.util.Pair; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import rearth.oracle.Oracle; +import rearth.oracle.OracleClient.ItemArticleRef; +import rearth.oracle.util.MarkdownParser; +import rearth.oracle.util.MarkdownParser.Frontmatter; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.Map.Entry; +import java.util.function.Supplier; + +import static rearth.oracle.OracleClient.ROOT_DIR; + +public class DocsIndexer { + public static final String WIKI_META_FILE = "sinytra-wiki.json"; + public static final String SCHEMA_V1 = "1"; + + private static final Logger LOGGER = LogUtils.getLogger(); + private static final Gson GSON = new Gson(); + + private final Map loadedWikis; + private final Multimap itemLinkCandidates; + private final Map itemLinks; + private final Map> unlockCriterions; + private final Map> availableModes; + private final Map contentIds; + private final Map> contentRefs; + + public DocsIndexer() { + this.loadedWikis = new HashMap<>(); + this.itemLinkCandidates = HashMultimap.create(); + this.itemLinks = new HashMap<>(); + this.unlockCriterions = new HashMap<>(); + this.availableModes = new HashMap<>(); + this.contentIds = new HashMap<>(); + this.contentRefs = new HashMap<>(); + } + + public Map getLoadedWikis() { + return loadedWikis; + } + + public Map getItemLinks() { + return itemLinks; + } + + public Map> getUnlockCriterions() { + return unlockCriterions; + } + + public Map> getAvailableModes() { + return availableModes; + } + + public Map getContentIds() { + return contentIds; + } + + public Map> getContentRefs() { + return contentRefs; + } + + public void findAllResourceEntries(ResourceManager manager) { + var resources = manager.findResources(ROOT_DIR, path -> path.getPath().endsWith(".mdx")); + + Map> wikis = new HashMap<>(); + for (var entry : resources.entrySet()) { + var resourceId = entry.getKey(); + var path = resourceId.getPath(); // e.g., "books/oritech/.content/machines/laser.mdx" + + var modId = extractModid(path); // e.g., "oritech" + if (modId == null) continue; + + var modResources = wikis.computeIfAbsent(modId, m -> new ArrayList<>()); + modResources.add(new IdentifiedResource(resourceId, entry.getValue())); + } + + for (var wiki : wikis.entrySet()) { + var modId = wiki.getKey(); + var entries = wiki.getValue(); + var format = detectDocsFormat(manager, modId); + + this.loadedWikis.put(modId, format); + + for (IdentifiedResource entry : entries) { + processEntry(modId, entry.id(), entry.resource(), format); + } + } + + for (Entry> entry : this.itemLinkCandidates.asMap().entrySet()) { + Identifier itemId = entry.getKey(); + ItemArticleRef bestMatch = findBestMatch(entry.getValue()); + this.itemLinks.put(itemId, bestMatch); + } + } + + private void processEntry(String modId, Identifier resourceId, Resource resource, DocsFormat format) { + var path = resourceId.getPath(); // e.g., "books/oritech/.content/machines/laser.mdx" + + if (format.isTranslatedPath(path)) { + return; // skip / don't support translations for now in indexing + } + + // check docs or content + var isContent = format.isContentPath(path); + var mode = isContent ? DocsMode.CONTENT : DocsMode.DOCS; + this.availableModes.computeIfAbsent(modId, k -> new HashSet<>()).add(mode); + + // parse frontmatter + try (var inputStream = resource.getInputStream()) { + var fileContent = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + var frontmatter = MarkdownParser.parseFrontmatter(fileContent); + + // map game id for content links + if (isContent) { + List ids = frontmatter.getAll("id"); + String ref = computePageRef(resourceId, format, frontmatter, ids); + this.contentRefs.computeIfAbsent(modId, k -> new HashMap<>()).put(ref, resourceId); + + if (frontmatter.containsKey("id")) { + for (String id : ids) { + this.contentIds.put(id, resourceId); + + var itemId = Identifier.of(id); + + Supplier lazyTitle; + var title = frontmatter.getOrDefault("title", "missing"); + if (title.equals("missing") && Registries.ITEM.containsId(itemId)) { + // Use supplier as translations may not be available at this time yet + lazyTitle = Suppliers.memoize(() -> I18n.translate(Registries.ITEM.get(itemId).getTranslationKey())); + } else { + lazyTitle = () -> title; + } + + // TODO Pick best page for item + this.itemLinkCandidates.put(itemId, new ItemArticleRef(resourceId, lazyTitle, modId, ids.size())); + } + } + } + + // frontmatter custom item links indexing + if (frontmatter.containsKey("related_items")) { + var baseString = frontmatter.getOne("related_items").replace("[", "").replace("]", "").replace("\"", ""); + for (var itemString : baseString.split(", ")) { + var itemId = Identifier.of(itemString.trim()); + var title = frontmatter.getOrDefault("title", "missing"); + this.itemLinkCandidates.put(itemId, new ItemArticleRef(resourceId, () -> title, modId, 0)); + } + } + + if (frontmatter.containsKey("unlock")) { + var unlockText = frontmatter.getOne("unlock"); + var parts = unlockText.split(":", 2); + if (parts.length == 2) { + this.unlockCriterions.put(path, new Pair<>(parts[0], parts[1])); + } + } + + } catch (IOException e) { + Oracle.LOGGER.error("Unable to load book entry: {}", resourceId, e); + } + } + + private static DocsFormat detectDocsFormat(ResourceManager manager, String wikiId) { + var metaPath = Identifier.of(Oracle.MOD_ID, ROOT_DIR + "/" + wikiId + "/" + WIKI_META_FILE); + WikiMetaStub meta = manager.getResource(metaPath) + .map(DocsIndexer::parseWikiMeta) + .orElse(null); + + if (meta != null && SCHEMA_V1.equals(meta.schema)) { + return new V1DocsFormat(); + } + + return new LegacyDocsFormat(); + } + + /** + * Compute page ref using the same process as the wiki web + */ + @Nullable + private String computePageRef(Identifier resourceId, DocsFormat format, Frontmatter frontmatter, List ids) { + // 1. Try using user-specified ref + var userRef = frontmatter.getOne("ref"); + if (userRef != null && !this.contentRefs.containsKey(userRef)) { + return userRef; + } + + // 2. Try deriving the ref from a single specified item ID + if (ids.size() == 1) { + var id = Identifier.tryParse(ids.getFirst()); + if (id != null) { + var primaryRef = id.getPath().replace("/", "_"); + if (!this.contentRefs.containsKey(primaryRef)) { + return primaryRef; + } + } + } + + // 3. Try deriving from the page file name + var relativePath = format.stripContentPrefix(resourceId.getPath()); + var stripped = List.of(relativePath.split("\\.")).getFirst(); + var fileName = List.of(stripped.split("/")).getLast(); + var normalized = fileName.replace("/", "_"); + if (!this.contentRefs.containsKey(normalized)) { + return normalized; + } + + // 4. Use the full path + return stripped.replace("/", "_"); + } + + @Nullable + private static WikiMetaStub parseWikiMeta(Resource resource) { + try (Reader reader = new InputStreamReader(resource.getInputStream())) { + return GSON.fromJson(reader, WikiMetaStub.class); + } catch (Exception e) { + LOGGER.error("Error parsing wiki metadata", e); + return null; + } + } + + @Nullable + public static String extractModid(String path) { + var segments = path.split("/"); + if (segments.length < 2) { + return null; + } + return segments[1]; + } + + private static ItemArticleRef findBestMatch(Collection articles) { + if (articles.size() == 1) { + return articles.iterator().next(); + } + return articles.stream().min(Comparator.comparingInt(ItemArticleRef::pageIDs)).orElseThrow(); + } + + record IdentifiedResource(Identifier id, Resource resource) { + } + + record WikiMetaStub(@Nullable String schema) { + } +} diff --git a/common/src/main/java/rearth/oracle/docs/DocsMode.java b/common/src/main/java/rearth/oracle/docs/DocsMode.java new file mode 100644 index 0000000..6d97723 --- /dev/null +++ b/common/src/main/java/rearth/oracle/docs/DocsMode.java @@ -0,0 +1,6 @@ +package rearth.oracle.docs; + +public enum DocsMode { + DOCS, + CONTENT +} diff --git a/common/src/main/java/rearth/oracle/docs/LegacyDocsFormat.java b/common/src/main/java/rearth/oracle/docs/LegacyDocsFormat.java new file mode 100644 index 0000000..01565a1 --- /dev/null +++ b/common/src/main/java/rearth/oracle/docs/LegacyDocsFormat.java @@ -0,0 +1,41 @@ +package rearth.oracle.docs; + +public class LegacyDocsFormat implements DocsFormat { + @Override + public boolean isContentPath(String path) { + return path.contains("/.content/"); + } + + @Override + public boolean isTranslatedPath(String path) { + return path.contains("/.translated"); + } + + @Override + public String getDocsRoot(DocsMode mode) { + return switch (mode) { + case DOCS -> ""; + case CONTENT -> "/.content"; + }; + } + + @Override + public String getTranslatedDir(String locale) { + return "/.translated/" + locale; + } + + @Override + public String getAssetsRoot() { + return "/.assets"; + } + + @Override + public String getDocsPagePath(String slug) { + return slug; + } + + @Override + public String stripContentPrefix(String path) { + return path.split("/\\.content/")[1]; + } +} diff --git a/common/src/main/java/rearth/oracle/docs/V1DocsFormat.java b/common/src/main/java/rearth/oracle/docs/V1DocsFormat.java new file mode 100644 index 0000000..33b474f --- /dev/null +++ b/common/src/main/java/rearth/oracle/docs/V1DocsFormat.java @@ -0,0 +1,41 @@ +package rearth.oracle.docs; + +public class V1DocsFormat implements DocsFormat { + @Override + public boolean isContentPath(String path) { + return path.contains("/content/"); + } + + @Override + public boolean isTranslatedPath(String path) { + return path.contains("/translated"); + } + + @Override + public String getDocsRoot(DocsMode mode) { + return switch (mode) { + case DOCS -> "/docs"; + case CONTENT -> "/content"; + }; + } + + @Override + public String getTranslatedDir(String locale) { + return "/translated/" + locale; + } + + @Override + public String getAssetsRoot() { + return "/assets"; + } + + @Override + public String getDocsPagePath(String slug) { + return "docs/" + slug; + } + + @Override + public String stripContentPrefix(String path) { + return path.split("/content/")[1]; + } +} diff --git a/common/src/main/java/rearth/oracle/mixin/DrawContextMixin.java b/common/src/main/java/rearth/oracle/mixin/DrawContextMixin.java index de79cdf..4527cc6 100644 --- a/common/src/main/java/rearth/oracle/mixin/DrawContextMixin.java +++ b/common/src/main/java/rearth/oracle/mixin/DrawContextMixin.java @@ -16,6 +16,7 @@ import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; import rearth.oracle.OracleClient; +import rearth.oracle.ui.OracleScreen; import java.util.ArrayList; import java.util.List; @@ -27,6 +28,7 @@ public class DrawContextMixin { @Inject(method = "drawTooltip(Lnet/minecraft/client/font/TextRenderer;Ljava/util/List;IILnet/minecraft/client/gui/tooltip/TooltipPositioner;)V", at = @At("HEAD")) private void injectTooltipComponents(TextRenderer textRenderer, List components, int x, int y, TooltipPositioner positioner, CallbackInfo ci, @Local(argsOnly = true) LocalRef> componentsRef) { if (OracleClient.tooltipStack == null) return; + if (MinecraftClient.getInstance().currentScreen instanceof OracleScreen) return; var stackItem = OracleClient.tooltipStack.getItem(); var stackId = Registries.ITEM.getId(stackItem); @@ -43,9 +45,7 @@ private void injectTooltipComponents(TextRenderer textRenderer, List 0.95f) { @@ -63,8 +63,10 @@ private void injectTooltipComponents(TextRenderer textRenderer, List PAGE_FALLBACK_NAMES = new HashMap<>(); - public static String activeWikiMode = "docs"; + public static DocsMode activeWikiMode = DocsMode.DOCS; public static Identifier activeEntry; public static String activeWiki; @@ -123,6 +126,9 @@ protected void buildRoots() { addRoot(contentScroll); addRoot(actionHub); + if (activeEntry != null) { + activeWikiMode = OracleClient.getDocsModeForPage(activeEntry); + } buildNavigationTree(); if (activeEntry != null) { try { @@ -134,7 +140,7 @@ protected void buildRoots() { } private UIComponent buildWikiTitleHeader() { - var wikiIds = OracleClient.LOADED_WIKIS.stream().sorted().toList(); + var wikiIds = OracleClient.LOADED_WIKIS.keySet().stream().sorted().toList(); if (wikiIds.isEmpty()) return FlowWidget.horizontal(); if (activeWiki == null || !wikiIds.contains(activeWiki)) activeWiki = wikiIds.get(0); @@ -184,7 +190,7 @@ private FlowWidget buildModDropdown() { private void rebuildModDropdown(FlowWidget dropdown) { dropdown.clearChildren(); - var wikiIds = OracleClient.LOADED_WIKIS.stream().sorted().toList(); + var wikiIds = OracleClient.LOADED_WIKIS.keySet().stream().sorted().toList(); for (var wikiId : wikiIds) { var label = new LabelWidget(Text.translatable(Oracle.MOD_ID + ".title." + wikiId).formatted(wikiId.equals(activeWiki) ? Formatting.WHITE : Formatting.DARK_GRAY)); label.setPadding(Insets.of(4, 3)); @@ -358,6 +364,7 @@ private void loadContent(Identifier filePath, String wikiId) throws IOException var lastEntry = activeEntry; contentContainer.clearChildren(); activeEntry = filePath; + activeWikiMode = getDocsModeForPage(filePath); var translatedPath = OracleClient.getTranslatedPath(filePath, wikiId); if (translatedPath.isPresent()) filePath = translatedPath.get(); @@ -390,6 +397,7 @@ private boolean onLinkClicked(String wikiId, String link, Identifier sourceEntry var ingameTarget = MarkdownParser.getLinkTarget(link, wikiId, sourceEntryPath); if (ingameTarget != null) { loadContent(ingameTarget, wikiId); + buildNavigationTree(); return true; } if (link.startsWith("@") || link.contains(":")) { @@ -429,22 +437,23 @@ private void buildNavigationTree() { if (canSwitchWikiMode(activeWiki)) { navigationBar.child(buildModeSelector()); } - var path = activeWikiMode.equals("docs") ? "" : "/.content"; - buildNavigationEntries(activeWiki, path, navigationBar); + var format = OracleClient.getWikiFormat(activeWiki); + var path = format.getDocsRoot(activeWikiMode); + buildNavigationEntries(activeWiki, path, navigationBar, new Stack<>()); } private FlowWidget buildModeSelector() { var row = FlowWidget.horizontal().gap(-1); row.size(SIDEBAR_WIDTH - 10, 0); row.horizontalAlignment(FlowWidget.HorizontalAlignment.CENTER); - row.child(makeModeButton("docs")); - row.child(makeModeButton("content")); + row.child(makeModeButton(DocsMode.DOCS)); + row.child(makeModeButton(DocsMode.CONTENT)); return row; } - private ClickableWidget makeModeButton(String mode) { + private ClickableWidget makeModeButton(DocsMode mode) { boolean selected = activeWikiMode.equals(mode); - var text = Text.translatable("oracle_index.button." + mode).formatted(selected ? Formatting.WHITE : Formatting.DARK_GRAY); + var text = Text.translatable("oracle_index.button." + mode.name().toLowerCase(Locale.ROOT)).formatted(selected ? Formatting.WHITE : Formatting.DARK_GRAY); var label = new LabelWidget(text); var widget = new ClickableWidget(label, b -> { if (!selected) { @@ -466,22 +475,22 @@ private ClickableWidget makeModeButton(String mode) { return widget; } - private String getWikiMode(String wikiId) { - var modes = OracleClient.AVAILABLE_MODES.getOrDefault(wikiId, Set.of("docs")); + private DocsMode getWikiMode(String wikiId) { + var modes = OracleClient.AVAILABLE_MODES.getOrDefault(wikiId, Set.of(DocsMode.DOCS)); if (modes.contains(activeWikiMode)) return activeWikiMode; - if (modes.contains("docs")) return "docs"; - if (modes.contains("content")) return "content"; - return modes.stream().findFirst().orElse("docs"); + if (modes.contains(DocsMode.DOCS)) return DocsMode.DOCS; + if (modes.contains(DocsMode.CONTENT)) return DocsMode.CONTENT; + return modes.stream().findFirst().orElse(DocsMode.DOCS); } private boolean canSwitchWikiMode(String wikiId) { - return OracleClient.AVAILABLE_MODES.getOrDefault(wikiId, Set.of("docs")).size() > 1; + return OracleClient.AVAILABLE_MODES.getOrDefault(wikiId, Set.of(DocsMode.DOCS)).size() > 1; } /** * @return true if any entry under this path is unlocked. */ - private boolean buildNavigationEntries(String wikiId, String path, FlowWidget container) { + private boolean buildNavigationEntries(String wikiId, String path, FlowWidget container, Stack hierarchy) { var rm = MinecraftClient.getInstance().getResourceManager(); var metaPath = Identifier.of(Oracle.MOD_ID, ROOT_DIR + "/" + wikiId + path + "/_meta.json"); var translated = OracleClient.getTranslatedPath(metaPath, wikiId); @@ -501,9 +510,15 @@ private boolean buildNavigationEntries(String wikiId, String path, FlowWidget co var levelContainers = new ArrayList(); for (var entry : entries) { + final var labelPath = Identifier.of(Oracle.MOD_ID, ROOT_DIR + "/" + wikiId + path + "/" + entry.id()); + if (entry.directory) { - var directory = new CollapsibleWidget(Text.translatable(entry.name()).formatted(Formatting.WHITE), false); - boolean childrenUnlocked = buildNavigationEntries(wikiId, path + "/" + entry.id(), directory.body()); + boolean expanded = activeEntry != null && activeEntry.getPath().startsWith(labelPath.getPath()); + + var directory = new CollapsibleWidget(Text.translatable(entry.name()).formatted(Formatting.WHITE), expanded); + hierarchy.push(directory); + boolean childrenUnlocked = buildNavigationEntries(wikiId, path + "/" + entry.id(), directory.body(), hierarchy); + hierarchy.pop(); if (childrenUnlocked) anyUnlocked = true; final var captured = directory; @@ -520,8 +535,6 @@ private boolean buildNavigationEntries(String wikiId, String path, FlowWidget co levelContainers.add(directory); } } else { - final var labelPath = Identifier.of(Oracle.MOD_ID, ROOT_DIR + "/" + wikiId + path + "/" + entry.id()); - var shownName = entry.name; if (shownName.isBlank()) { var contentRc = rm.getResource(labelPath); @@ -529,9 +542,7 @@ private boolean buildNavigationEntries(String wikiId, String path, FlowWidget co Oracle.LOGGER.warn("Unable to get name for entry: {}", labelPath); shownName = ""; } else { - var fileContent = new String(contentRc.get().getInputStream().readAllBytes(), StandardCharsets.UTF_8); - var fm = MarkdownParser.parseFrontmatter(fileContent); - shownName = MarkdownParser.getTitle(fm, labelPath); + shownName = TitleLookup.getTitle(labelPath); } } PAGE_FALLBACK_NAMES.put(labelPath, shownName); @@ -560,6 +571,10 @@ private boolean buildNavigationEntries(String wikiId, String path, FlowWidget co var firstPath = Identifier.of(Oracle.MOD_ID, ROOT_DIR + "/" + wikiId + path + "/" + first.get().id()); loadContent(firstPath, wikiId); activeEntry = firstPath; + + for (CollapsibleWidget parent : hierarchy) { + parent.setExpanded(true); + } } } } catch (IOException e) { diff --git a/common/src/main/java/rearth/oracle/ui/SearchScreen.java b/common/src/main/java/rearth/oracle/ui/SearchScreen.java index 7720142..4dbb343 100644 --- a/common/src/main/java/rearth/oracle/ui/SearchScreen.java +++ b/common/src/main/java/rearth/oracle/ui/SearchScreen.java @@ -191,7 +191,7 @@ private void onSearchTyped(String query) { var iconStack = new ItemStack(Registries.ITEM.get(Identifier.of(result.iconName()))); var icon = new ItemWidget(iconStack); icon.size(12, 12); - icon.setHideItemTooltip(true); + icon.setTooltipMode(TooltipMode.HIDDEN); icon.setHideItemDecorations(true); titleRow.child(icon); } diff --git a/common/src/main/java/rearth/oracle/ui/widgets/CalloutWidget.java b/common/src/main/java/rearth/oracle/ui/widgets/CalloutWidget.java index 4f62098..b260caa 100644 --- a/common/src/main/java/rearth/oracle/ui/widgets/CalloutWidget.java +++ b/common/src/main/java/rearth/oracle/ui/widgets/CalloutWidget.java @@ -4,7 +4,9 @@ import net.minecraft.client.gui.DrawContext; import net.minecraft.text.Text; import net.minecraft.util.Formatting; -import org.apache.commons.lang3.StringUtils; +import rearth.oracle.util.CalloutVariant; + +import java.util.Locale; /** * Stylised callout block used by {@code } markdown tags. @@ -15,10 +17,10 @@ public class CalloutWidget extends FlowWidget { private static final int BODY_TEXT_COLOR = 0xFF555555; - private final String variant; + private final CalloutVariant variant; private final FlowWidget body; - public CalloutWidget(String variant) { + public CalloutWidget(CalloutVariant variant) { super(Direction.VERTICAL); this.variant = variant; this.body = FlowWidget.vertical(); @@ -73,13 +75,14 @@ protected void renderContent(DrawContext context, int mouseX, int mouseY, float super.renderContent(context, mouseX, mouseY, delta); // overlapping title chip rendered on top var tr = MinecraftClient.getInstance().textRenderer; - var title = Text.literal(StringUtils.capitalize(variant)).formatted(Formatting.WHITE); + var key = "oracle_index.callout." + this.variant.name().toLowerCase(Locale.ROOT); + var title = Text.translatable(key).formatted(Formatting.WHITE); int textW = tr.getWidth(title); int chipW = textW + 12; int chipH = tr.fontHeight + 9; int chipX = body.getX(); int chipY = body.getY(); - WikiSurface.BEDROCK_PANEL_PRESSED.render(context, chipX - 6, chipY - 6, chipW, chipH); + this.variant.getSurface().render(context, chipX - 6, chipY - 6, chipW, chipH); context.drawText(tr, title, chipX, chipY, 0xFFFFFFFF, false); } } diff --git a/common/src/main/java/rearth/oracle/ui/widgets/ItemWidget.java b/common/src/main/java/rearth/oracle/ui/widgets/ItemWidget.java index bd46112..df40745 100644 --- a/common/src/main/java/rearth/oracle/ui/widgets/ItemWidget.java +++ b/common/src/main/java/rearth/oracle/ui/widgets/ItemWidget.java @@ -15,7 +15,7 @@ public class ItemWidget extends UIComponent { private final ItemStack stack; - private boolean hideItemTooltip; + private TooltipMode tooltipMode; private boolean hideItemDecorations; public ItemWidget(ItemStack stack) { @@ -38,20 +38,24 @@ protected void renderContent(DrawContext context, int mouseX, int mouseY, float if (!hideItemDecorations) context.drawItemInSlot(mc.textRenderer, stack, 0, 0); matrices.pop(); } - - public void setHideItemTooltip(boolean hideItemTooltip) { - this.hideItemTooltip = hideItemTooltip; + + public void setTooltipMode(TooltipMode tooltipMode) { + this.tooltipMode = tooltipMode; } - + public void setHideItemDecorations(boolean hideItemDecorations) { this.hideItemDecorations = hideItemDecorations; } @Override public List tooltip(int mouseX, int mouseY) { - if (stack == null || stack.isEmpty() || hideItemTooltip) return super.tooltip(mouseX, mouseY); + if (stack == null || stack.isEmpty() || tooltipMode == TooltipMode.HIDDEN) return super.tooltip(mouseX, mouseY); var mc = MinecraftClient.getInstance(); - return stack.getTooltip(Item.TooltipContext.create(mc.world), mc.player, - mc.options.advancedItemTooltips ? TooltipType.ADVANCED : TooltipType.BASIC); + List tooltip = stack.getTooltip( + Item.TooltipContext.create(mc.world), + mc.player, + mc.options.advancedItemTooltips ? TooltipType.ADVANCED : TooltipType.BASIC + ); + return tooltipMode == TooltipMode.NAME_ONLY && !tooltip.isEmpty() ? tooltip.subList(0, 1) : tooltip; } } diff --git a/common/src/main/java/rearth/oracle/ui/widgets/TooltipMode.java b/common/src/main/java/rearth/oracle/ui/widgets/TooltipMode.java new file mode 100644 index 0000000..996adb4 --- /dev/null +++ b/common/src/main/java/rearth/oracle/ui/widgets/TooltipMode.java @@ -0,0 +1,7 @@ +package rearth.oracle.ui.widgets; + +public enum TooltipMode { + HIDDEN, + NAME_ONLY, + FULL +} diff --git a/common/src/main/java/rearth/oracle/ui/widgets/WikiSurface.java b/common/src/main/java/rearth/oracle/ui/widgets/WikiSurface.java index 74c4c5f..dcc8f7b 100644 --- a/common/src/main/java/rearth/oracle/ui/widgets/WikiSurface.java +++ b/common/src/main/java/rearth/oracle/ui/widgets/WikiSurface.java @@ -14,6 +14,9 @@ public enum WikiSurface { BEDROCK_PANEL(ninePatch("bedrock_panel")), BEDROCK_PANEL_HOVER(ninePatch("bedrock_panel_hover")), BEDROCK_PANEL_PRESSED(ninePatch("bedrock_panel_pressed")), + BEDROCK_PANEL_NOTE(ninePatch("bedrock_panel_note")), + BEDROCK_PANEL_WARNING(ninePatch("bedrock_panel_warning")), + BEDROCK_PANEL_DANGER(ninePatch("bedrock_panel_danger")), BEDROCK_PANEL_DARK(ninePatch("bedrock_panel_dark")), BEDROCK_PANEL_DISABLED(ninePatch("bedrock_panel_disabled")); diff --git a/common/src/main/java/rearth/oracle/util/CalloutVariant.java b/common/src/main/java/rearth/oracle/util/CalloutVariant.java new file mode 100644 index 0000000..d5bb57b --- /dev/null +++ b/common/src/main/java/rearth/oracle/util/CalloutVariant.java @@ -0,0 +1,20 @@ +package rearth.oracle.util; + +import rearth.oracle.ui.widgets.WikiSurface; + +public enum CalloutVariant { + DEFAULT(WikiSurface.BEDROCK_PANEL_NOTE), + INFO(WikiSurface.BEDROCK_PANEL_PRESSED), + WARNING(WikiSurface.BEDROCK_PANEL_WARNING), + DANGER(WikiSurface.BEDROCK_PANEL_DANGER); + + private final WikiSurface surface; + + CalloutVariant(WikiSurface surface) { + this.surface = surface; + } + + public WikiSurface getSurface() { + return surface; + } +} diff --git a/common/src/main/java/rearth/oracle/util/MarkdownParser.java b/common/src/main/java/rearth/oracle/util/MarkdownParser.java index 6508bf5..6b8adf3 100644 --- a/common/src/main/java/rearth/oracle/util/MarkdownParser.java +++ b/common/src/main/java/rearth/oracle/util/MarkdownParser.java @@ -2,8 +2,8 @@ import net.minecraft.client.MinecraftClient; import net.minecraft.client.gui.DrawContext; -import net.minecraft.client.resource.language.I18n; import net.minecraft.client.texture.NativeImage; +import net.minecraft.item.Item; import net.minecraft.item.ItemStack; import net.minecraft.registry.Registries; import net.minecraft.text.ClickEvent; @@ -25,7 +25,6 @@ import rearth.oracle.ui.widgets.*; import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.util.*; import java.util.function.Predicate; @@ -36,21 +35,21 @@ * Replaces the previous owo-lib-based parser. */ public class MarkdownParser { - + private static final String[] removedLines = {"
", "
", "
", "
", "", ""}; - + private static final List EXTENSIONS = List.of(YamlFrontMatterExtension.create()); private static final Set> ENABLED_BLOCKS = Set.of( - Heading.class, HtmlBlock.class, ThematicBreak.class, - FencedCodeBlock.class, BlockQuote.class, ListBlock.class + Heading.class, HtmlBlock.class, ThematicBreak.class, + FencedCodeBlock.class, BlockQuote.class, ListBlock.class ); - + private static final Parser PARSER = Parser.builder() - .enabledBlockTypes(ENABLED_BLOCKS) - .extensions(EXTENSIONS) - .customBlockParserFactory(new MdxBlockFactory()) - .build(); - + .enabledBlockTypes(ENABLED_BLOCKS) + .extensions(EXTENSIONS) + .customBlockParserFactory(new MdxBlockFactory()) + .build(); + /** * Parse markdown and produce a list of top-level widgets. * @@ -60,55 +59,91 @@ public class MarkdownParser { public static List parseMarkdownToWidgets(String markdown, String wikiId, Identifier currentPath, Predicate linkHandler, int contentWidthPx) { for (var toRemove : removedLines) markdown = markdown.replace(toRemove, ""); - + var document = PARSER.parse(markdown); var yamlVisitor = new YamlFrontMatterVisitor(); document.accept(yamlVisitor); - + var frontMatter = parseFrontmatter(markdown); - + var visitor = new WikiMarkdownVisitor(linkHandler, wikiId, currentPath, contentWidthPx); document.accept(visitor); - + var widgets = new ArrayList(); widgets.add(buildTitlePanel(linkHandler, frontMatter, currentPath, contentWidthPx)); widgets.addAll(visitor.results()); - - if (frontMatter.containsKey("id")) { - var gameId = frontMatter.get("id"); + + var gameId = frontMatter.getOne("id"); + if (gameId != null) { var id = Identifier.of(gameId); if (Registries.ITEM.containsId(id) || Registries.BLOCK.containsId(id)) widgets.add(buildPropertiesPanel(ContentProperties.getProperties(gameId), contentWidthPx)); } - + return widgets; } - + + public static String parseHeadingTitle(String markdown) { + var document = PARSER.parse(markdown); + var visitor = new WikiTitleVisitor(); + document.accept(visitor); + + return visitor.getTitle(); + } + // ---------------------------------------------------------------- visitor - - private static class WikiMarkdownVisitor extends AbstractVisitor { - + + private static class WikiTitleVisitor extends AbstractVisitor { + protected String title; + protected MutableText buffer = Text.empty(); + + public String getTitle() { + return title; + } + + @Override + public void visit(Heading heading) { + buffer = Text.empty(); + visitChildren(heading); + + if (heading.getLevel() == 1 && title == null) { + title = buffer.getString(); + } + + buffer = Text.empty(); + } + + @Override + public void visit(org.commonmark.node.Text text) { + if (buffer != null) { + buffer.append(Text.literal(text.getLiteral())); + } + } + } + + private static class WikiMarkdownVisitor extends WikiTitleVisitor { + private final Predicate linkHandler; private final String wikiId; private final Identifier contentPath; private final int contentWidthPx; - + private List components = new ArrayList<>(); private MutableText buffer = Text.empty(); private Style currentStyle = Style.EMPTY; private int currentIndentation = 0; - + WikiMarkdownVisitor(Predicate linkHandler, String wikiId, Identifier contentPath, int contentWidthPx) { this.linkHandler = linkHandler; this.wikiId = wikiId; this.contentPath = contentPath; this.contentWidthPx = contentWidthPx; } - + List results() { return components; } - + private void flushBuffer() { if (buffer == null || buffer.getString().isEmpty()) return; var label = new LabelWidget(buffer).linkHandler(linkHandler).lineSpacing(1).fillWidth(); @@ -117,13 +152,13 @@ private void flushBuffer() { buffer = Text.empty(); currentIndentation = 0; } - + @Override public void visit(Paragraph paragraph) { visitChildren(paragraph); flushBuffer(); } - + @Override public void visit(Heading heading) { buffer = Text.empty(); @@ -131,14 +166,19 @@ public void visit(Heading heading) { currentStyle = currentStyle.withColor(Formatting.GRAY); visitChildren(heading); currentStyle = oldStyle; - - var label = new LabelWidget(buffer).linkHandler(linkHandler).fillWidth(); - label.scale(Math.max(1.0f, 2.0f - heading.getLevel() * 0.2f)); - label.setPadding(Insets.of(10, 5, 0, 0)); - components.add(label); + + if (heading.getLevel() == 1 && title == null) { + title = buffer.getString(); + } else { + var label = new LabelWidget(buffer).linkHandler(linkHandler).fillWidth(); + label.scale(Math.max(1.0f, 2.0f - heading.getLevel() * 0.2f)); + label.setPadding(Insets.of(10, 5, 0, 0)); + components.add(label); + } + buffer = Text.empty(); } - + @Override public void visit(FencedCodeBlock codeBlock) { flushBuffer(); @@ -149,17 +189,17 @@ public void visit(FencedCodeBlock codeBlock) { panel.child(new LabelWidget(text)); components.add(panel); } - + @Override public void visit(BulletList l) { visitChildren(l); } - + @Override public void visit(OrderedList l) { visitChildren(l); } - + @Override public void visit(ListItem listItem) { var parent = listItem.getParent(); @@ -170,7 +210,7 @@ public void visit(ListItem listItem) { ancestor = ancestor.getParent(); } this.currentIndentation = depth - 1; - + if (parent instanceof BulletList) { buffer.append(Text.literal("• ").formatted(Formatting.DARK_GRAY)); } else if (parent instanceof OrderedList orderedList) { @@ -187,13 +227,13 @@ public void visit(ListItem listItem) { flushBuffer(); this.currentIndentation = 0; } - + @Override public void visit(CustomBlock customBlock) { if (customBlock instanceof MdxComponentBlock.CraftingRecipeBlock recipe) { components.add(buildRecipe(recipe.slots, recipe.result, recipe.count)); } else if (customBlock instanceof MdxComponentBlock.AssetBlock image) { - components.add(buildImage(image.location, image.width, this.wikiId, image.isModAsset(), contentWidthPx)); + components.add(buildImage(image.location, image.width, this.wikiId, contentWidthPx)); } else if (customBlock instanceof MdxComponentBlock.CalloutBlock callout) { var oldComponents = this.components; var inner = new ArrayList(); @@ -201,24 +241,24 @@ public void visit(CustomBlock customBlock) { visitChildren(callout); flushBuffer(); this.components = oldComponents; - + var widget = new CalloutWidget(callout.variant); for (var c : inner) widget.addBodyChild(c); components.add(widget); } } - + @Override public void visit(Image image) { flushBuffer(); - components.add(buildImage(image.getDestination(), "60%", wikiId, true, contentWidthPx)); + components.add(buildImage(image.getDestination(), "60%", wikiId, contentWidthPx)); } - + @Override public void visit(org.commonmark.node.Text text) { if (buffer != null) buffer.append(Text.literal(text.getLiteral()).setStyle(currentStyle)); } - + @Override public void visit(StrongEmphasis e) { var old = currentStyle; @@ -226,7 +266,7 @@ public void visit(StrongEmphasis e) { visitChildren(e); currentStyle = old; } - + @Override public void visit(Emphasis e) { var old = currentStyle; @@ -234,57 +274,65 @@ public void visit(Emphasis e) { visitChildren(e); currentStyle = old; } - + @Override public void visit(Link link) { var old = currentStyle; var clickEvent = new ClickEvent(ClickEvent.Action.OPEN_URL, link.getDestination()); currentStyle = currentStyle.withColor(Formatting.BLUE).withUnderline(true).withClickEvent(clickEvent); - + if (link.getFirstChild() == null && (link.getTitle() == null || link.getTitle().isBlank())) { var linkTitle = getLinkText(link.getDestination(), wikiId, contentPath); - buffer.append(Text.literal(linkTitle).setStyle(currentStyle)); + buffer.append(linkTitle.setStyle(currentStyle)); } visitChildren(link); currentStyle = old; } - + @Override public void visit(Code inlineCode) { if (buffer != null) { - buffer.append(Text.literal(inlineCode.getLiteral()).formatted(Formatting.RED)); + buffer.append(Text.literal(inlineCode.getLiteral()).formatted(Formatting.DARK_AQUA)); } } - + @Override public void visit(SoftLineBreak n) { if (buffer != null) buffer.append(Text.literal(" ")); } - + @Override public void visit(HardLineBreak n) { if (buffer != null) buffer.append(Text.literal("\n")); } } - + // ---------------------------------------------------------------- helpers - - public static String getLinkText(String link, String activeWikiId, Identifier sourceEntryPath) { + + public static MutableText getLinkText(String link, String activeWikiId, Identifier sourceEntryPath) { + if (link.startsWith("@")) { + Identifier id = Identifier.tryParse(link.substring(1)); + if (id != null && id.getNamespace().equals(Identifier.DEFAULT_NAMESPACE)) { + Item item = Registries.ITEM.get(id); + if (item != null) { + return Text.translatable(item.getTranslationKey()); + } + } + } + + return Text.literal(getLinkTextLiteral(link, activeWikiId, sourceEntryPath)); + } + + public static String getLinkTextLiteral(String link, String activeWikiId, Identifier sourceEntryPath) { var linkTarget = getLinkTarget(link, activeWikiId, sourceEntryPath); if (linkTarget == null) return ""; var rm = MinecraftClient.getInstance().getResourceManager(); var rc = rm.getResource(linkTarget); if (rc.isEmpty()) return ""; - try { - var fileContent = new String(rc.get().getInputStream().readAllBytes(), StandardCharsets.UTF_8); - var fm = parseFrontmatter(fileContent); - return getTitle(fm, linkTarget); - } catch (IOException e) { - Oracle.LOGGER.warn("Unable to load file content to get link title: {}, {}", linkTarget, e); - return ""; - } + String title = TitleLookup.getTitle(linkTarget); + return title != null ? title : ""; } - + @Nullable public static Identifier getLinkTarget(String link, String activeWikiId, Identifier sourceEntryPath) { Identifier targetFile; @@ -292,9 +340,13 @@ public static Identifier getLinkTarget(String link, String activeWikiId, Identif var id = link.startsWith("@") ? link.substring(1) : link; targetFile = OracleClient.CONTENT_ID_MAP.get(id); } else if (link.startsWith("$")) { - var p = "books/" + activeWikiId + "/" + link.substring(1); + var format = OracleClient.getWikiFormat(activeWikiId); + var p = "books/" + activeWikiId + "/" + format.getDocsPagePath(link.substring(1)); if (!p.endsWith(".mdx")) p += ".mdx"; targetFile = Identifier.of(Oracle.MOD_ID, p); + } else if (link.startsWith("+")) { + var id = link.substring(1); + targetFile = OracleClient.getPage(activeWikiId, id); } else { var p = OracleScreen.parsePathLink(link, sourceEntryPath); if (!p.endsWith(".mdx")) p += ".mdx"; @@ -302,77 +354,120 @@ public static Identifier getLinkTarget(String link, String activeWikiId, Identif } return targetFile; } - - public static String getTitle(Map frontMatter, Identifier pagePath) { - if (frontMatter.containsKey("title")) return frontMatter.get("title"); - if (frontMatter.containsKey("id")) { - var item = frontMatter.get("id"); - if (Identifier.validate(item).isSuccess() && Registries.ITEM.containsId(Identifier.of(item))) { - return I18n.translate(Registries.ITEM.get(Identifier.of(item)).getTranslationKey()); + + private static UIComponent buildTitlePanel(Predicate linkHandler, Frontmatter frontMatter, Identifier pageId, int contentWidthPx) { + var iconId = frontMatter.getOrDefault("icon", ""); + if (iconId.isBlank()) iconId = frontMatter.getOrDefault("id", ""); + ItemStack iconStack = getIconStack(iconId); + + List itemStacks = new ArrayList<>(); + List ids = frontMatter.getAll("id"); + if (ids != null && ids.size() > 1) { + for (String id : ids) { + ItemStack stack = getIconStack(id); + if (!stack.isEmpty()) { + itemStacks.add(stack); + } } - return item; } - return OracleScreen.PAGE_FALLBACK_NAMES.getOrDefault(pagePath, "No title found"); + + return new PageTitleWidget( + Text.literal(TitleLookup.getTitle(pageId)).formatted(Formatting.DARK_GRAY), + iconStack, + itemStacks, + linkHandler, + contentWidthPx + ); } - - private static UIComponent buildTitlePanel(Predicate linkHandler, Map frontMatter, Identifier pageId, int contentWidthPx) { - ItemStack iconStack = ItemStack.EMPTY; - var iconId = frontMatter.getOrDefault("icon", ""); - if (iconId.isBlank()) iconId = frontMatter.getOrDefault("id", ""); + + private static ItemStack getIconStack(String iconId) { if (Identifier.validate(iconId).isSuccess() && Registries.ITEM.containsId(Identifier.of(iconId))) { - iconStack = new ItemStack(Registries.ITEM.get(Identifier.of(iconId))); + return new ItemStack(Registries.ITEM.get(Identifier.of(iconId))); } - return new PageTitleWidget(Text.literal(getTitle(frontMatter, pageId)).formatted(Formatting.DARK_GRAY), iconStack, linkHandler, contentWidthPx); + return ItemStack.EMPTY; } - + private static class PageTitleWidget extends UIComponent { private static final int ICON_PANEL_SIZE = 58; private static final int ICON_ITEM_SIZE = 50; + + private static final int ITEM_PANEL_SIZE = 32; + private static final int ITEM_ICON_SIZE = 24; + private static final int ITEM_PADDING = (ITEM_PANEL_SIZE - ITEM_ICON_SIZE) / 2; + private static final int ITEMS_MARGIN = 2; + private static final int TITLE_OVERLAP = 12; private static final int TITLE_PAD_X = 14; private static final int TITLE_PAD_Y = 9; - + private final LabelWidget titleLabel; private final ItemWidget icon; + private final List items; private final int contentWidthPx; - + private int titleX; private int titleY; private int titleW; private int titleH; private int iconX; private int iconY; - - PageTitleWidget(Text title, ItemStack iconStack, Predicate linkHandler, int contentWidthPx) { + + PageTitleWidget(Text title, ItemStack iconStack, List itemStacks, Predicate linkHandler, int contentWidthPx) { this.titleLabel = new LabelWidget(title).scale(2f).linkHandler(linkHandler); this.icon = iconStack.isEmpty() ? null : new ItemWidget(iconStack); if (icon != null) { - icon.setHideItemTooltip(true); + icon.setTooltipMode(TooltipMode.HIDDEN); icon.setHideItemDecorations(true); } + this.items = itemStacks.stream() + .map(ItemWidget::new) + .peek(w -> { + w.setTooltipMode(TooltipMode.NAME_ONLY); + w.setHideItemDecorations(true); + w.size(ITEM_ICON_SIZE, ITEM_ICON_SIZE); + }) + .toList(); this.contentWidthPx = contentWidthPx; } - + @Override public int getPreferredWidth(int widthHint) { int maxWidth = widthHint > 0 ? widthHint : contentWidthPx; int labelMaxWidth = labelMaxWidth(maxWidth); titleLabel.wrapWidth(labelMaxWidth); int titlePanelWidth = titleLabel.getPreferredWidth(labelMaxWidth) + TITLE_PAD_X * 2; - return leadingWidth() + titlePanelWidth; + int itemsRowWidth = Math.min(maxWidth, items.size() * ITEM_PANEL_SIZE); + return Math.max(leadingWidth() + titlePanelWidth, itemsRowWidth); } - + @Override public int getPreferredHeight(int widthHint) { int maxWidth = widthHint > 0 ? widthHint : contentWidthPx; int labelMaxWidth = labelMaxWidth(maxWidth); titleLabel.wrapWidth(labelMaxWidth); int titlePanelHeight = titleLabel.getPreferredHeight(labelMaxWidth) + TITLE_PAD_Y * 2; - return Math.max(icon == null ? 0 : ICON_PANEL_SIZE, titlePanelHeight); + int itemRowsHeight = getOuterRowsHeight(maxWidth); + return Math.max(icon == null ? 0 : ICON_PANEL_SIZE, titlePanelHeight) + itemRowsHeight; } - + + private int getMaxCols(int maxWidth) { + return maxWidth / ITEM_PANEL_SIZE; + } + + private int getInnerRowsHeight(int maxWidth) { + int itemCols = getMaxCols(maxWidth); + return (int) Math.ceil(items.size() / (double) itemCols) * ITEM_PANEL_SIZE; + } + + private int getOuterRowsHeight(int maxWidth) { + int height = getInnerRowsHeight(maxWidth); + return height > 0 ? height + ITEMS_MARGIN : 0; + } + @Override public void layout(int parentWidthHint, int parentHeightHint) { + int centerOffset = getOuterRowsHeight(width) / 2; + int labelMaxWidth = Math.max(80, width - leadingWidth() - TITLE_PAD_X * 2); titleLabel.wrapWidth(labelMaxWidth); int labelW = titleLabel.getPreferredWidth(labelMaxWidth); @@ -380,44 +475,80 @@ public void layout(int parentWidthHint, int parentHeightHint) { titleW = labelW + TITLE_PAD_X * 2; titleH = labelH + TITLE_PAD_Y * 2; titleX = x + leadingWidth(); - titleY = y + (height - titleH) / 2; - titleLabel.setPosition(titleX + TITLE_PAD_X, titleY + TITLE_PAD_Y); + titleY = y + (height - titleH) / 2 - centerOffset; + int offset = icon != null ? TITLE_PAD_X / 2 : 0; + titleLabel.setPosition(titleX + TITLE_PAD_X + offset, titleY + TITLE_PAD_Y); titleLabel.setLayoutSize(labelW, labelH); titleLabel.layout(labelW, labelH); + if (icon != null) { iconX = x; - iconY = y + (height - ICON_PANEL_SIZE) / 2; + iconY = y + (height - ICON_PANEL_SIZE) / 2 - centerOffset; icon.setPosition(iconX + (ICON_PANEL_SIZE - ICON_ITEM_SIZE) / 2, iconY + (ICON_PANEL_SIZE - ICON_ITEM_SIZE) / 2); icon.setLayoutSize(ICON_ITEM_SIZE, ICON_ITEM_SIZE); icon.layout(ICON_ITEM_SIZE, ICON_ITEM_SIZE); } + + if (!items.isEmpty()) { + int cols = getMaxCols(width); + int rowsHeight = getInnerRowsHeight(width); + int baseX = x; + int baseY = y + height - rowsHeight; + + for (int i = 0; i < items.size(); i++) { + ItemWidget item = items.get(i); + int row = i / cols; + int col = i % cols; + int iconX = baseX + ITEM_PADDING + col * ITEM_PANEL_SIZE; + int iconY = baseY + ITEM_PADDING + row * ITEM_PANEL_SIZE; + + item.setPosition(iconX, iconY); + item.setLayoutSize(ITEM_ICON_SIZE, ITEM_ICON_SIZE); + item.layout(ITEM_ICON_SIZE, ITEM_ICON_SIZE); + } + } } - + @Override protected void renderContent(DrawContext context, int mouseX, int mouseY, float delta) { WikiSurface.BEDROCK_PANEL.render(context, titleX, titleY, titleW, titleH); titleLabel.render(context, mouseX, mouseY, delta); + if (icon != null) { WikiSurface.BEDROCK_PANEL.render(context, iconX, iconY, ICON_PANEL_SIZE, ICON_PANEL_SIZE); icon.render(context, mouseX, mouseY, delta); } + + if (!items.isEmpty()) { + for (ItemWidget item : items) { + WikiSurface.BEDROCK_PANEL.render(context, item.getX() - ITEM_PADDING, item.getY() - ITEM_PADDING, ITEM_PANEL_SIZE, ITEM_PANEL_SIZE); + item.render(context, mouseX, mouseY, delta); + } + } } - + @Override public List tooltip(int mouseX, int mouseY) { - if (icon != null && icon.isInBounds(mouseX, mouseY)) return icon.tooltip(mouseX, mouseY); + if (icon != null && icon.isInBounds(mouseX, mouseY)) { + return icon.tooltip(mouseX, mouseY); + } + for (ItemWidget item : items) { + if (item.isInBounds(mouseX, mouseY)) { + return item.tooltip(mouseX, mouseY); + } + } return super.tooltip(mouseX, mouseY); } - + private int leadingWidth() { return icon == null ? 0 : ICON_PANEL_SIZE - TITLE_OVERLAP; } - + private int labelMaxWidth(int maxWidth) { return Math.max(80, maxWidth - leadingWidth() - TITLE_PAD_X * 2); } } - + private static UIComponent buildPropertiesPanel(Map properties, int contentWidthPx) { var tr = MinecraftClient.getInstance().textRenderer; int titleWidth = tr.getWidth("Details"); @@ -427,39 +558,39 @@ private static UIComponent buildPropertiesPanel(Map properties, in keyWidth = Math.max(keyWidth, tr.getWidth(entry.getKey())); valueWidth = Math.max(valueWidth, tr.getWidth(entry.getValue())); } - int innerWidth = Math.min(contentWidthPx, Math.max(160, Math.max(titleWidth, keyWidth + valueWidth + 28) + 20)); + int innerWidth = Math.clamp(Math.max(titleWidth, keyWidth + valueWidth + 28) + 20, 160, contentWidthPx); var outer = FlowWidget.vertical().gap(2); outer.setSurface(WikiSurface.BEDROCK_PANEL_DARK); outer.setPadding(Insets.of(10)); outer.size(innerWidth, 0); outer.horizontalAlignment(FlowWidget.HorizontalAlignment.CENTER); outer.child(new LabelWidget(Text.literal("Details").formatted(Formatting.BOLD, Formatting.GRAY))); - + for (var entry : properties.entrySet()) { outer.child(new PropertyRowWidget(Text.literal(entry.getKey()).formatted(Formatting.GOLD), entry.getValue())); } return outer; } - + private static class PropertyRowWidget extends UIComponent { private final Text key; private final Text value; - + PropertyRowWidget(Text key, Text value) { this.key = key; this.value = value; } - + @Override public int getPreferredWidth(int widthHint) { return widthHint > 0 ? widthHint : MinecraftClient.getInstance().textRenderer.getWidth(key) + 28 + MinecraftClient.getInstance().textRenderer.getWidth(value); } - + @Override public int getPreferredHeight(int widthHint) { return MinecraftClient.getInstance().textRenderer.fontHeight; } - + @Override protected void renderContent(DrawContext context, int mouseX, int mouseY, float delta) { var tr = MinecraftClient.getInstance().textRenderer; @@ -467,12 +598,12 @@ protected void renderContent(DrawContext context, int mouseX, int mouseY, float context.drawText(tr, value, x + width - tr.getWidth(value), y, 0xFFFFFFFF, false); } } - + public static UIComponent buildRecipe(List inputs, String resultId, int resultCount) { if (inputs.size() != 9) { return new LabelWidget(Text.literal("Invalid crafting recipe data: expected 9 inputs").formatted(Formatting.RED)); } - + // Layered: a 3x3 grid of slots with items overlaid on top. var grid = new GridWidget(3, 3, ItemSlotWidget.SLOT_SIZE, ItemSlotWidget.SLOT_SIZE).gap(0, 0); grid.setPadding(Insets.of(3)); @@ -486,18 +617,18 @@ public static UIComponent buildRecipe(List inputs, String resultId, int var item = new ItemWidget(stack); grid.set(i / 3, i % 3, new ItemSlotWidget(item)); } - + // → arrow var arrow = new TextureWidget(Identifier.of(Oracle.MOD_ID, "textures/arrow_empty.png"), 29, 16); - + // result slot var resultIdObj = Identifier.of(resultId); var resultStack = Registries.ITEM.containsId(resultIdObj) - ? new ItemStack(Registries.ITEM.get(resultIdObj), resultCount) - : ItemStack.EMPTY; + ? new ItemStack(Registries.ITEM.get(resultIdObj), resultCount) + : ItemStack.EMPTY; var result = new ItemWidget(resultStack); var resultSlot = new ItemSlotWidget(result); - + var panel = FlowWidget.horizontal().gap(8); panel.setSurface(WikiSurface.BEDROCK_PANEL); panel.setPadding(Insets.of(8)); @@ -507,15 +638,15 @@ public static UIComponent buildRecipe(List inputs, String resultId, int panel.child(resultSlot); return panel; } - - public static UIComponent buildImage(String location, String widthSource, String wikiId, boolean isModAsset, int contentWidthPx) { + + public static UIComponent buildImage(String location, String widthSource, String wikiId, int contentWidthPx) { float widthRatio = convertImageWidth(widthSource); if (widthRatio <= 0) widthRatio = 0.5f; if (location.startsWith("@")) location = location.substring(1); - + // available pixel budget after scrollbar gutter + a tiny breathing margin int budget = Math.max(16, contentWidthPx - 12); - + // case 1: ingame item → render as ItemWidget var itemIdCandidate = Identifier.of(location); if (Registries.ITEM.containsId(itemIdCandidate)) { @@ -527,18 +658,16 @@ public static UIComponent buildImage(String location, String widthSource, String itemWidget.setHideItemDecorations(true); return itemWidget; } - + // case 2: texture path + String assetsRoot = OracleClient.getWikiFormat(wikiId).getAssetsRoot(); Identifier searchPath; - if (isModAsset) { - var parts = location.split(":", 2); - var imageModId = parts.length > 0 ? parts[0] : wikiId; - var imagePath = parts.length > 1 ? parts[1] : location; - searchPath = Identifier.of(Oracle.MOD_ID, ROOT_DIR + "/" + wikiId + "/.assets/" + imageModId + "/" + imagePath + ".png"); - } else { - searchPath = Identifier.of(Oracle.MOD_ID, ROOT_DIR + "/" + wikiId + "/.assets/" + wikiId + "/" + location + ".png"); - } - + var parts = location.split(":", 2); + var imageModId = parts.length > 0 ? parts[0] : wikiId; + var imagePath = parts.length > 1 ? parts[1] : location; + var extension = imagePath.contains(".") ? "" : ".png"; + searchPath = Identifier.of(Oracle.MOD_ID, ROOT_DIR + "/" + wikiId + assetsRoot + "/" + imageModId + "/" + imagePath + extension); + var rm = MinecraftClient.getInstance().getResourceManager(); var resource = rm.getResource(searchPath); if (resource.isEmpty()) { @@ -557,25 +686,25 @@ public static UIComponent buildImage(String location, String widthSource, String return new LabelWidget(Text.literal("Error reading image: " + location).formatted(Formatting.RED)); } } - - public static Map parseFrontmatter(String markdown) { + + public static Frontmatter parseFrontmatter(String markdown) { var document = PARSER.parse(markdown); var yamlVisitor = new YamlFrontMatterVisitor(); document.accept(yamlVisitor); var frontmatter = yamlVisitor.getData(); try { - var simple = new HashMap(); + var inner = new HashMap>(); for (var pair : frontmatter.entrySet()) { if (pair.getValue().isEmpty()) continue; - simple.put(pair.getKey(), pair.getValue().getFirst().trim()); + inner.put(pair.getKey(), pair.getValue().stream().map(String::trim).toList()); } - return simple; + return new Frontmatter(inner); } catch (RuntimeException ex) { Oracle.LOGGER.warn("Error parsing markdown frontmatter: {} in {}", ex, markdown); - return new HashMap<>(); + return new Frontmatter(Map.of()); } } - + public static float convertImageWidth(String input) { if (input == null || input.isEmpty()) return 0.0f; var trimmed = input.trim(); @@ -602,4 +731,29 @@ public static float convertImageWidth(String input) { } return 0.0f; } + + public record Frontmatter(Map> map) { + @Nullable + public List getAll(String key) { + return this.map.get(key); + } + + @Nullable + public String getOne(String key) { + List values = this.map.get(key); + if (values == null) { + return null; + } + return values.size() == 1 ? values.getFirst() : null; + } + + public String getOrDefault(String key, String _default) { + String value = getOne(key); + return value != null ? value : _default; + } + + public boolean containsKey(String key) { + return this.map.containsKey(key); + } + } } diff --git a/common/src/main/java/rearth/oracle/util/MdxBlockFactory.java b/common/src/main/java/rearth/oracle/util/MdxBlockFactory.java index 0c71765..62a6e17 100644 --- a/common/src/main/java/rearth/oracle/util/MdxBlockFactory.java +++ b/common/src/main/java/rearth/oracle/util/MdxBlockFactory.java @@ -15,11 +15,9 @@ public BlockStart tryStart(ParserState state, MatchedBlockParser matchedBlockPar if (line.startsWith(" cachedTitles = new HashMap<>(); + + public static String getTitle(Identifier pagePath) { + return cachedTitles.computeIfAbsent(pagePath, TitleLookup::computeTitle); + } + + private static String computeTitle(Identifier pagePath) { + String cached = cachedTitles.get(pagePath); + if (cached != null) { + return cached; + } + + String markdown = parseContents(pagePath); + if (markdown == null) { + return OracleScreen.PAGE_FALLBACK_NAMES.getOrDefault(pagePath, "No title found"); + } + + Frontmatter frontMatter = MarkdownParser.parseFrontmatter(markdown); + + String title = frontMatter.getOne("title"); + if (title != null) return title; + + String headingTitle = MarkdownParser.parseHeadingTitle(markdown); + if (headingTitle != null) { + return headingTitle; + } + + String item = frontMatter.getOne("id"); + if (item != null) { + if (Identifier.validate(item).isSuccess() && Registries.ITEM.containsId(Identifier.of(item))) { + return I18n.translate(Registries.ITEM.get(Identifier.of(item)).getTranslationKey()); + } + return item; + } + + return OracleScreen.PAGE_FALLBACK_NAMES.getOrDefault(pagePath, "No title found"); + } + + public static void clearCache() { + cachedTitles.clear(); + } + + @Nullable + private static String parseContents(Identifier pagePath) { + try { + var rm = MinecraftClient.getInstance().getResourceManager(); + var rc = rm.getResource(pagePath); + if (rc.isEmpty()) { + return null; + } + return new String(rc.get().getInputStream().readAllBytes(), StandardCharsets.UTF_8); + } catch (Exception e) { + LOGGER.error("Error parsing markdown title for page {}", pagePath, e); + return null; + } + } +} diff --git a/common/src/main/resources/assets/oracle_index/lang/en_us.json b/common/src/main/resources/assets/oracle_index/lang/en_us.json index 3d57bb8..e706e09 100644 --- a/common/src/main/resources/assets/oracle_index/lang/en_us.json +++ b/common/src/main/resources/assets/oracle_index/lang/en_us.json @@ -10,5 +10,10 @@ "tooltip.oracle_index.back": "Go back to last page.", "tooltip.oracle_index.close_screen": "Close Wiki.", "oracle_index.button.docs": "Docs", - "oracle_index.button.content": "Content" + "oracle_index.button.content": "Content", + "oracle_index.tooltip.docs": "Hold [ALT] for Documentation", + "oracle_index.callout.default": "Note", + "oracle_index.callout.info": "Info", + "oracle_index.callout.warning": "Warning", + "oracle_index.callout.danger": "Danger" } \ No newline at end of file diff --git a/common/src/main/resources/assets/oracle_index/nine_patch_textures/bedrock_panel_danger.json b/common/src/main/resources/assets/oracle_index/nine_patch_textures/bedrock_panel_danger.json new file mode 100644 index 0000000..b784fc5 --- /dev/null +++ b/common/src/main/resources/assets/oracle_index/nine_patch_textures/bedrock_panel_danger.json @@ -0,0 +1,14 @@ +{ + "texture": "oracle_index:textures/gui/bedrock_panel_danger.png", + "texture_width": 16, + "texture_height": 16, + "repeat": false, + "corner_patch_size": { + "width": 4, + "height": 4 + }, + "center_patch_size": { + "width": 8, + "height": 8 + } +} \ No newline at end of file diff --git a/common/src/main/resources/assets/oracle_index/nine_patch_textures/bedrock_panel_note.json b/common/src/main/resources/assets/oracle_index/nine_patch_textures/bedrock_panel_note.json new file mode 100644 index 0000000..29441a1 --- /dev/null +++ b/common/src/main/resources/assets/oracle_index/nine_patch_textures/bedrock_panel_note.json @@ -0,0 +1,14 @@ +{ + "texture": "oracle_index:textures/gui/bedrock_panel_note.png", + "texture_width": 16, + "texture_height": 16, + "repeat": false, + "corner_patch_size": { + "width": 4, + "height": 4 + }, + "center_patch_size": { + "width": 8, + "height": 8 + } +} \ No newline at end of file diff --git a/common/src/main/resources/assets/oracle_index/nine_patch_textures/bedrock_panel_warning.json b/common/src/main/resources/assets/oracle_index/nine_patch_textures/bedrock_panel_warning.json new file mode 100644 index 0000000..dd82fa6 --- /dev/null +++ b/common/src/main/resources/assets/oracle_index/nine_patch_textures/bedrock_panel_warning.json @@ -0,0 +1,14 @@ +{ + "texture": "oracle_index:textures/gui/bedrock_panel_warning.png", + "texture_width": 16, + "texture_height": 16, + "repeat": false, + "corner_patch_size": { + "width": 4, + "height": 4 + }, + "center_patch_size": { + "width": 8, + "height": 8 + } +} \ No newline at end of file diff --git a/common/src/main/resources/assets/oracle_index/textures/gui/bedrock_panel_danger.png b/common/src/main/resources/assets/oracle_index/textures/gui/bedrock_panel_danger.png new file mode 100644 index 0000000..63de755 Binary files /dev/null and b/common/src/main/resources/assets/oracle_index/textures/gui/bedrock_panel_danger.png differ diff --git a/common/src/main/resources/assets/oracle_index/textures/gui/bedrock_panel_note.png b/common/src/main/resources/assets/oracle_index/textures/gui/bedrock_panel_note.png new file mode 100644 index 0000000..eb727e2 Binary files /dev/null and b/common/src/main/resources/assets/oracle_index/textures/gui/bedrock_panel_note.png differ diff --git a/common/src/main/resources/assets/oracle_index/textures/gui/bedrock_panel_warning.png b/common/src/main/resources/assets/oracle_index/textures/gui/bedrock_panel_warning.png new file mode 100644 index 0000000..0db79cb Binary files /dev/null and b/common/src/main/resources/assets/oracle_index/textures/gui/bedrock_panel_warning.png differ diff --git a/common/src/testmod/java/rearth/oracle_test/OracleTest.java b/common/src/testmod/java/rearth/oracle_test/OracleTest.java new file mode 100644 index 0000000..7e38bd8 --- /dev/null +++ b/common/src/testmod/java/rearth/oracle_test/OracleTest.java @@ -0,0 +1,20 @@ +package rearth.oracle_test; + +import com.google.common.base.Suppliers; +import net.minecraft.item.Item; +import net.minecraft.item.Item.Settings; +import net.minecraft.util.Identifier; + +import java.util.function.Supplier; + +public class OracleTest { + public static final String MODID = "oracle_index_test"; + + public static final Supplier GREEN_APPLE = Suppliers.memoize(() -> new Item(new Settings())); + public static final Supplier BLUE_APPLE = Suppliers.memoize(() -> new Item(new Settings())); + public static final Supplier YELLOW_APPLE = Suppliers.memoize(() -> new Item(new Settings())); + + public static Identifier id(String path) { + return Identifier.of(MODID, path); + } +} diff --git a/common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/assets/oracle_index_test/folder_layout.png b/common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/assets/oracle_index_test/folder_layout.png new file mode 100644 index 0000000..0a74261 Binary files /dev/null and b/common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/assets/oracle_index_test/folder_layout.png differ diff --git a/common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/assets/oracle_index_test/sounds/snare.ogg b/common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/assets/oracle_index_test/sounds/snare.ogg new file mode 100644 index 0000000..b1db8ae Binary files /dev/null and b/common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/assets/oracle_index_test/sounds/snare.ogg differ diff --git a/common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/content/_meta.json b/common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/content/_meta.json new file mode 100644 index 0000000..d74598d --- /dev/null +++ b/common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/content/_meta.json @@ -0,0 +1,3 @@ +{ + "misc": "Misc" +} \ No newline at end of file diff --git a/common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/content/misc/_meta.json b/common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/content/misc/_meta.json new file mode 100644 index 0000000..e022aa3 --- /dev/null +++ b/common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/content/misc/_meta.json @@ -0,0 +1,4 @@ +{ + "apples.mdx": "Apples", + "green_apple.mdx": "Green Apple" +} \ No newline at end of file diff --git a/common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/content/misc/apples.mdx b/common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/content/misc/apples.mdx new file mode 100644 index 0000000..26c2891 --- /dev/null +++ b/common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/content/misc/apples.mdx @@ -0,0 +1,11 @@ +--- +id: + - oracle_index_test:green_apple + - oracle_index_test:blue_apple + - oracle_index_test:yellow_apple +icon: minecraft:apple +--- + +# Apples + +Hello World \ No newline at end of file diff --git a/common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/content/misc/green_apple.mdx b/common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/content/misc/green_apple.mdx new file mode 100644 index 0000000..05eacb0 --- /dev/null +++ b/common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/content/misc/green_apple.mdx @@ -0,0 +1,12 @@ +--- +id: oracle_index_test:green_apple +ref: my_green_apple +--- + +# Green Apple + +Hello World. + +Check out all apple variants here: [](+apples). + +Read introduction at: []($introduction). \ No newline at end of file diff --git a/common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/docs/_meta.json b/common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/docs/_meta.json new file mode 100644 index 0000000..083ffbd --- /dev/null +++ b/common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/docs/_meta.json @@ -0,0 +1,3 @@ +{ + "introduction.mdx": "" +} \ No newline at end of file diff --git a/common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/docs/introduction.mdx b/common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/docs/introduction.mdx new file mode 100644 index 0000000..1a1f8db --- /dev/null +++ b/common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/docs/introduction.mdx @@ -0,0 +1,84 @@ +# Introduction + +## Links + +Green Apple by ID: [](@oracle_index_test:green_apple). + +Green Apple by ref: [Green Apple Ref](+my_green_apple). + +Green Apple by ref default title: [](+my_green_apple). + +Vanilla link: [](@minecraft:apple) + +## Components + +### `Asset` + + + +### `Audio` + +