diff --git a/nop-idea-plugin/build.gradle.kts b/nop-idea-plugin/build.gradle.kts index 88907d8d9e40a825796e4ea27a3585463e1180b8..0fc06615f13bdfcc4aa8e3bdde4335b49b6c9bf0 100644 --- a/nop-idea-plugin/build.gradle.kts +++ b/nop-idea-plugin/build.gradle.kts @@ -28,7 +28,8 @@ intellij { } dependencies { - implementation( "io.github.entropy-cloud:nop-xlang-debugger:2.0.0-SNAPSHOT") + implementation("io.github.entropy-cloud:nop-markdown:2.0.0-SNAPSHOT") + implementation("io.github.entropy-cloud:nop-xlang-debugger:2.0.0-SNAPSHOT") implementation("io.github.entropy-cloud:nop-xlang:2.0.0-SNAPSHOT") { //exclude antlr4's dependency icu4j since it is not necessary and is too large. exclude(group = "com.ibm.icu") diff --git a/nop-idea-plugin/src/main/java/io/nop/idea/plugin/doc/XLangDocumentationProvider.java b/nop-idea-plugin/src/main/java/io/nop/idea/plugin/doc/XLangDocumentationProvider.java index 308f8f3d9e75ddd637ee49298a6a660af27c7d9d..cc42cb03886264fe1e2cfa76911ec748f6de977c 100644 --- a/nop-idea-plugin/src/main/java/io/nop/idea/plugin/doc/XLangDocumentationProvider.java +++ b/nop-idea-plugin/src/main/java/io/nop/idea/plugin/doc/XLangDocumentationProvider.java @@ -8,7 +8,6 @@ package io.nop.idea.plugin.doc; import com.intellij.lang.documentation.AbstractDocumentationProvider; -import com.intellij.lang.documentation.DocumentationMarkup; import com.intellij.psi.PsiElement; import com.intellij.psi.util.PsiTreeUtil; import com.intellij.psi.xml.XmlAttribute; @@ -17,8 +16,14 @@ import com.intellij.psi.xml.XmlTokenType; import io.nop.api.core.beans.DictBean; import io.nop.api.core.beans.DictOptionBean; import io.nop.commons.util.StringHelper; +import io.nop.core.dict.DictModel; +import io.nop.core.dict.DictModelParser; import io.nop.core.dict.DictProvider; +import io.nop.core.resource.IResource; +import io.nop.core.resource.VirtualFileSystem; +import io.nop.core.resource.component.ResourceComponentManager; import io.nop.idea.plugin.resource.ProjectEnv; +import io.nop.idea.plugin.utils.MarkdownHelper; import io.nop.idea.plugin.utils.XDefPsiHelper; import io.nop.idea.plugin.utils.XmlPsiHelper; import io.nop.idea.plugin.utils.XmlTagInfo; @@ -32,6 +37,7 @@ import jakarta.annotation.Nullable; import java.util.Objects; public class XLangDocumentationProvider extends AbstractDocumentationProvider { + @Override public @Nullable String generateDoc(PsiElement element, @Nullable PsiElement originalElement) { @@ -40,120 +46,68 @@ public class XLangDocumentationProvider extends AbstractDocumentationProvider { }); } - String doGenerate(PsiElement element, PsiElement originalElement) { - PsiElement elm = originalElement; + String doGenerate(PsiElement element, PsiElement elm) { if (XmlPsiHelper.isElementType(elm, XmlTokenType.XML_NAME)) { PsiElement parent = elm.getParent(); + XmlTagInfo tagInfo = XDefPsiHelper.getTagInfo(parent); + if (tagInfo == null || tagInfo.getDefNode() == null) { + return null; + } + if (parent instanceof XmlTag) { - XmlTagInfo tagInfo = XDefPsiHelper.getTagInfo(parent); - if (tagInfo != null && tagInfo.getDefNode() != null) { - IXDefComment comment = tagInfo.getDefNode().getComment(); - String desc = null; - if (comment != null) { - desc = comment.getMainDescription(); - if (desc == null) - desc = comment.getMainDisplayName(); - } - return doc(desc, tagInfo.getDefNode()); + DocInfo doc = new DocInfo(tagInfo.getDefNode()); + + IXDefComment comment = tagInfo.getDefNode().getComment(); + if (comment != null) { + doc.setTitle(comment.getMainDisplayName()); + doc.setDesc(comment.getMainDescription()); } + return doc.toString(); } else if (parent instanceof XmlAttribute) { XmlAttribute attr = (XmlAttribute) parent; - XmlTagInfo tagInfo = XDefPsiHelper.getTagInfo(parent); - if (tagInfo != null && tagInfo.getDefNode() != null) { - IXDefComment comment = tagInfo.getDefNode().getComment(); - String desc = null; - if (comment != null) { - desc = comment.getSubDescription(attr.getName()); - if (desc == null) - desc = comment.getSubDisplayName(attr.getName()); - } - return doc(desc, tagInfo.getDefNode().getAttribute(attr.getName())); + String attrName = attr.getName(); + DocInfo doc = new DocInfo(tagInfo.getDefNode().getAttribute(attrName)); + + IXDefComment comment = tagInfo.getDefNode().getComment(); + if (comment != null) { + doc.setTitle(comment.getSubDisplayName(attrName)); + doc.setDesc(comment.getSubDescription(attrName)); } + return doc.toString(); } } else if (XmlPsiHelper.isElementType(elm, XmlTokenType.XML_ATTRIBUTE_VALUE_TOKEN)) { XmlAttribute attr = PsiTreeUtil.getParentOfType(elm, XmlAttribute.class); - if (attr != null) { - XmlTagInfo tagInfo = XDefPsiHelper.getTagInfo(attr); - if (tagInfo != null && tagInfo.getDefNode() != null) { - XDefTypeDecl defType = tagInfo.getDefNode().getAttrType(attr.getName()); - if (defType != null) { - if (defType.getOptions() != null) { - DictBean dictBean = DictProvider.instance().getDict(null, defType.getOptions(), null,null); - if (dictBean != null) { - DictOptionBean option = dictBean.getOptionByValue(attr.getValue()); - if (option != null) { - if (option.getDescription() != null) - return doc(option.getDescription()); - if (!Objects.equals(option.getLabel(), option.getValue())) - return doc(option.getLabel()); - } - } - } - } - } + if (attr == null) { + return null; } - } - return null; - } - String doc(String text) { - if (!StringHelper.isEmpty(text)) - return "doc: " + StringHelper.escapeXml(text); - return null; - } - - String doc(String desc, IXDefNode defNode) { - if (defNode == null) - return doc(desc); + XmlTagInfo tagInfo = XDefPsiHelper.getTagInfo(attr); + if (tagInfo == null || tagInfo.getDefNode() == null) { + return null; + } - XDefTypeDecl type = defNode.getXdefValue(); - if (type != null) { - return doc("stdDomain=" + type.getStdDomain() + (StringHelper.isBlank(desc) ? "" : "。" + desc)); - } - return doc(desc); - } + XDefTypeDecl defType = tagInfo.getDefNode().getAttrType(attr.getName()); + if (defType == null || defType.getOptions() == null) { + return null; + } - String doc(String desc, IXDefAttribute attr) { - if (attr == null) - return doc(desc); + DictBean dictBean = DictProvider.instance().getDict(null, defType.getOptions(), null, null); + DictOptionBean option = dictBean != null ? dictBean.getOptionByValue(attr.getValue()) : null; + if (option == null) { + return null; + } - XDefTypeDecl type = attr.getType(); - return doc("stdDomain=" + type.getStdDomain() + (StringHelper.isBlank(desc) ? "" : "。" + desc)); - } + DocInfo doc = new DocInfo(); + if (!Objects.equals(option.getLabel(), option.getValue())) { + doc.setTitle(option.getLabel()); + } + doc.setDesc(option.getDescription()); - /** - * Creates the formatted documentation using {@link DocumentationMarkup}. See the Java doc of - * {@link com.intellij.lang.documentation.DocumentationProvider#generateDoc(PsiElement, PsiElement)} for more - * information about building the layout. - */ - private String renderFullDoc(String key, String value, String file, String docComment) { - StringBuilder sb = new StringBuilder(); - sb.append(DocumentationMarkup.DEFINITION_START); - sb.append("Simple Property"); - sb.append(DocumentationMarkup.DEFINITION_END); - sb.append(DocumentationMarkup.CONTENT_START); - sb.append(value); - sb.append(DocumentationMarkup.CONTENT_END); - sb.append(DocumentationMarkup.SECTIONS_START); - addKeyValueSection("Key:", key, sb); - addKeyValueSection("Value:", value, sb); - addKeyValueSection("File:", file, sb); - addKeyValueSection("Comment:", docComment, sb); - sb.append(DocumentationMarkup.SECTIONS_END); - return sb.toString(); - } + return doc.toString(); + } - /** - * Creates a key/value row for the rendered documentation. - */ - private void addKeyValueSection(String key, String value, StringBuilder sb) { - sb.append(DocumentationMarkup.SECTION_HEADER_START); - sb.append(key); - sb.append(DocumentationMarkup.SECTION_SEPARATOR); - sb.append("

"); - sb.append(value); - sb.append(DocumentationMarkup.SECTION_END); + return null; } /** @@ -180,4 +134,67 @@ public class XLangDocumentationProvider extends AbstractDocumentationProvider { // } return null; } + + /** 对于多行文本,行首的 > 将被去除后,再按照 markdown 渲染得到 html 代码 */ + public static String markdown(String text) { + text = text.replaceAll("(?m)^> ",""); + text = MarkdownHelper.renderHtml(text); + + return text; + } + + static class DocInfo { + String title; + String stdDomain; + String desc; + + DocInfo() { + } + + DocInfo(IXDefNode defNode) { + this(defNode.getXdefValue()); + } + + DocInfo(IXDefAttribute attr) { + this(attr.getType()); + } + + DocInfo(XDefTypeDecl type) { + this.stdDomain = type != null ? type.getStdDomain() : null; + } + + public void setTitle(String title) { + this.title = title; + } + + public void setDesc(String desc) { + this.desc = desc; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + + if (!StringHelper.isBlank(this.title)) { + sb.append("

"); + sb.append(StringHelper.escapeXml(this.title)); + sb.append("

"); + } + if (this.stdDomain != null) { + sb.append("

"); + sb.append("stdDomain=").append(StringHelper.escapeXml(this.stdDomain)); + sb.append("

"); + } + + if (!StringHelper.isBlank(this.desc)) { + if (!sb.isEmpty()) { + sb.append("

"); + } + + sb.append(markdown(this.desc)); + } + + return !sb.isEmpty() ? sb.toString() : null; + } + } } diff --git a/nop-idea-plugin/src/main/java/io/nop/idea/plugin/resource/ProjectVirtualFileSystem.java b/nop-idea-plugin/src/main/java/io/nop/idea/plugin/resource/ProjectVirtualFileSystem.java index 3dcca52f66d3db7169f852544af725c866063a20..08f1eb6ffe44e0136a4f33400dea18e4d0b5a1fc 100644 --- a/nop-idea-plugin/src/main/java/io/nop/idea/plugin/resource/ProjectVirtualFileSystem.java +++ b/nop-idea-plugin/src/main/java/io/nop/idea/plugin/resource/ProjectVirtualFileSystem.java @@ -48,7 +48,7 @@ public class ProjectVirtualFileSystem implements IVirtualFileSystem { // 在模块中查找 for (PsiFile file : files) { String matchPath = ProjectFileHelper.getNopVfsPath(file.getVirtualFile()); - if (matchPath.startsWith(path)) + if (matchPath != null && matchPath.startsWith(path)) return new VirtualFileResource(path, file.getVirtualFile()); } } else { diff --git a/nop-idea-plugin/src/main/java/io/nop/idea/plugin/utils/MarkdownHelper.java b/nop-idea-plugin/src/main/java/io/nop/idea/plugin/utils/MarkdownHelper.java new file mode 100644 index 0000000000000000000000000000000000000000..2724c31d9d7e8a570e969e92f2622a7611e3c798 --- /dev/null +++ b/nop-idea-plugin/src/main/java/io/nop/idea/plugin/utils/MarkdownHelper.java @@ -0,0 +1,114 @@ +package io.nop.idea.plugin.utils; + +import java.util.Arrays; +import java.util.Map; +import java.util.Set; + +import io.nop.idea.plugin.messages.NopPluginBundle; +import io.nop.markdown.math.MathExtension; +import org.commonmark.ext.gfm.tables.TablesExtension; +import org.commonmark.node.Image; +import org.commonmark.node.Link; +import org.commonmark.node.Node; +import org.commonmark.node.Text; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.NodeRenderer; +import org.commonmark.renderer.Renderer; +import org.commonmark.renderer.html.HtmlNodeRendererContext; +import org.commonmark.renderer.html.HtmlRenderer; +import org.commonmark.renderer.html.HtmlWriter; + +/** + * @author flytreeleft + * @date 2025-06-09 + */ +public class MarkdownHelper { + private static final Parser parser = Parser.builder() + .extensions(Arrays.asList(TablesExtension.create(), + MathExtension.create())) + .build(); + private static final Renderer htmlRenderer = HtmlRenderer.builder() + .escapeHtml(true) + .sanitizeUrls(true) + .extensions(Arrays.asList(TablesExtension.create(), + MathExtension.create(), + // + (HtmlRenderer.HtmlRendererExtension) rendererBuilder -> { + rendererBuilder.nodeRendererFactory( + SelectiveLinkRenderer::new); + } + // + )) + .build(); + + /** 将 markdown 文本渲染为 html 代码:在 markdown 中的 html 将被转义 */ + public static String renderHtml(String text) { + Node document = parser.parse(text); + return htmlRenderer.render(document); + } + + /** 将链接展示出来,且图片也不直接显示,避免恶意攻击 */ + static class SelectiveLinkRenderer implements NodeRenderer { + private final HtmlWriter html; + private final HtmlNodeRendererContext context; + + SelectiveLinkRenderer(HtmlNodeRendererContext context) { + this.html = context.getWriter(); + this.context = context; + } + + @Override + public Set> getNodeTypes() { + return Set.of(Link.class, Image.class); + } + + @Override + public void render(Node node) { + if (node instanceof Link) { + renderExplicitLink((Link) node); + } else if (node instanceof Image) { + renderExplicitImage((Image) node); + } + } + + private void renderExplicitLink(Link node) { + String href = this.context.encodeUrl(node.getDestination()); + String title = null; + + if (node.getFirstChild() instanceof Text) { + title = ((Text) node.getFirstChild()).getLiteral(); + } + if (title == null) { + title = node.getTitle(); + } + title = NopPluginBundle.message("xlang.doc.markdown.link-title", title != null ? " " + title : ""); + + this.html.tag("a", Map.of("href", href), this.context.shouldSanitizeUrls()); + this.html.text(title + " "); + this.html.tag("code"); + this.html.text(node.getDestination()); + this.html.tag("/code"); + this.html.tag("/a"); + } + + private void renderExplicitImage(Image node) { + String href = this.context.encodeUrl(node.getDestination()); + String title = null; + + if (node.getFirstChild() instanceof Text) { + title = ((Text) node.getFirstChild()).getLiteral(); + } + if (title == null) { + title = node.getTitle(); + } + title = NopPluginBundle.message("xlang.doc.markdown.image-title", title != null ? " " + title : ""); + + this.html.tag("a", Map.of("href", href), this.context.shouldSanitizeUrls()); + this.html.text(title + " "); + this.html.tag("code"); + this.html.text(node.getDestination()); + this.html.tag("/code"); + this.html.tag("/a"); + } + } +} diff --git a/nop-idea-plugin/src/main/resources/io/nop/idea/plugin/messages/NopPluginBundle.properties b/nop-idea-plugin/src/main/resources/io/nop/idea/plugin/messages/NopPluginBundle.properties index 79dda428553ebeb730611f6445fad57b6637f808..8fb78732543d29043bf55d56f8b9dea414bd3356 100644 --- a/nop-idea-plugin/src/main/resources/io/nop/idea/plugin/messages/NopPluginBundle.properties +++ b/nop-idea-plugin/src/main/resources/io/nop/idea/plugin/messages/NopPluginBundle.properties @@ -6,3 +6,5 @@ xlang.annotation.attr.not-allow-empty=Attr {0} not allow empty xlang.annotation.value.not-allow-empty=Tag {0} value not allow empty xlang.annotation.tag.not-allow-value=Tag {0} not allow value xlang.annotation.tag.not-allow-child=Tag {0} not allow child +xlang.doc.markdown.link-title='{'Link'}'{0} +xlang.doc.markdown.image-title='{'Image'}'{0} diff --git a/nop-idea-plugin/src/main/resources/io/nop/idea/plugin/messages/NopPluginBundle_zh.properties b/nop-idea-plugin/src/main/resources/io/nop/idea/plugin/messages/NopPluginBundle_zh.properties index 66c308a4769ef5930fd5936fb3395c56494ed6d0..e06849a87b726074fa4983f38b4cd62c534ca7ea 100644 --- a/nop-idea-plugin/src/main/resources/io/nop/idea/plugin/messages/NopPluginBundle_zh.properties +++ b/nop-idea-plugin/src/main/resources/io/nop/idea/plugin/messages/NopPluginBundle_zh.properties @@ -6,3 +6,5 @@ xlang.annotation.attr.not-allow-empty=\u5C5E\u6027{0}\u4E0D\u5141\u8BB8\u4E3A\u7 xlang.annotation.value.not-allow-empty=\u6807\u7B7E{0}\u7684\u503C\u4E0D\u5141\u8BB8\u4E3A\u7A7A xlang.annotation.tag.not-allow-value=\u6807\u7B7E{0}\u4E0D\u5141\u8BB8\u5185\u5BB9\u8282\u70B9 xlang.annotation.tag.not-allow-child=\u6807\u7B7E{0}\u4E0D\u5141\u8BB8\u5B50\u8282\u70B9 +xlang.doc.markdown.link-title='{'\u94FE\u63A5'}'{0} +xlang.doc.markdown.image-title='{'\u56FE\u7247'}'{0} diff --git a/nop-idea-plugin/src/test/java/io/nop/idea/plugin/utils/TestMarkdownHelper.java b/nop-idea-plugin/src/test/java/io/nop/idea/plugin/utils/TestMarkdownHelper.java new file mode 100644 index 0000000000000000000000000000000000000000..914bdd9e5b5896fe30b171b691a6c215674db6c9 --- /dev/null +++ b/nop-idea-plugin/src/test/java/io/nop/idea/plugin/utils/TestMarkdownHelper.java @@ -0,0 +1,39 @@ +package io.nop.idea.plugin.utils; + +import java.util.HashMap; +import java.util.Map; + +import com.intellij.testFramework.fixtures.LightJavaCodeInsightFixtureTestCase; +import org.junit.Assert; + +/** + * @author flytreeleft + * @date 2025-06-10 + */ +public class TestMarkdownHelper extends LightJavaCodeInsightFixtureTestCase { + + public void testRenderHtml() { + Map samples = new HashMap<>() {{ + put("[Abc](https://a.b.c/abc)", + "

{Link} Abc https://a.b.c/abc

"); + put("[Abc](https://a.b.c/abc \"Title\")", + "

{Link} Abc https://a.b.c/abc

"); + put("[](https://a.b.c/abc \"Title\")", + "

{Link} Title https://a.b.c/abc

"); + put("![Abc](https://a.b.c/abc)", + "

{Image} Abc https://a.b.c/abc

"); + put("![Abc](https://a.b.c/abc \"Title\")", + "

{Image} Abc https://a.b.c/abc

"); + put("![](https://a.b.c/abc \"Title\")", + "

{Image} Title https://a.b.c/abc

"); + put("https://a.b.c/a/b/c", "

https://a.b.c/a/b/c

"); + }}; + + samples.forEach((text, expected) -> { + String actual = MarkdownHelper.renderHtml(text).trim(); + + System.out.println(actual); + Assert.assertEquals(expected, actual); + }); + } +}