字节工程师自行开发了基于IntelliJ的终极文档套件

前言
众所周知,程序员最讨厌的四件事:写注释,写文档,别人不写注释,别人不写文档。因此,有必要找到降低文档编写和维护成本的方法。目前写技术文档的模式如下:

字节工程师自行开发了基于IntelliJ的终极文档套件

痛点总结有三个方面:

字节工程师自行开发了基于IntelliJ的终极文档套件

针对上述问题,我们的解决方案:

本地编辑,浏览工作收敛到IDE,提供身临其境的体验;

在文档和代码之间建立强关联,减少复制,提高联动性,提高文档触摸率;

代码和文档属于Git仓库,借助版本管理,避免因业务迭代而导致文档版本与代码不匹配;

制作可以将文档导出到在线的工具,浏览器可以随时访问;

方案总览
字节工程师自行开发了基于IntelliJ的终极文档套件

与原始模式相比,新方案可以做到完全脱离浏览器 / 文档编辑器,线上页面的同步完全交给定时触发的自动化部署。

图中橙色部分是方案的重点,按分工分为线下和线上两部分。职责如下:

线下:IDEAPlugin。

实现自定义语言的分析和分析;

预览器、编辑器提供文档内容;

提供一系列实用功能,相关代码和文档;

线上:Gradle/DokkaPlugin。

桥接,重用IDEPlugin语义分析,生成预览内容的能力;

扩展Dokarenderer,实现HTML和飞书文档的导出能力;

方案建设采用了许多有趣的技术,后面详细介绍。

线下效果
IDEAPlugin提供侧边栏和强大的编辑器。以下从编辑和浏览两个角度介绍。

编辑体验
假设源代码如下:

public class ClassA {

public static final String TAG = "tag";



ClassB b;



/**
 * method document here.
 *
 * @param params input string
 */

public static void invoke(@NotNull String params) {

    System.out.println("invoke method!");

    System.out.println("this is method body: " + params);

}



public ClassA() {

    System.out.println("create new instance!");

}



private static final class ChildClass {



    /**
     * This is a method from inner class.
     */

    void innerInvoke() {

        System.out.println("invoke method from child!");

    }

}

}
此效果是在文档中添加此类引用:

字节工程师自行开发了基于IntelliJ的终极文档套件

与复制、粘贴代码不同,新方案具有以下优点:

相关性更强,预览会随着代码片段的变化而变化;

易于重构、引用类名、方法名、字段名重命名时,文档内容会自动改变,防止引用失效;

更直观,编辑,浏览时可以更快地找到代码源;

输入流畅,补充能力提高;

浏览体验

字节工程师自行开发了基于IntelliJ的终极文档套件

新方案比普通Markdown更友好:

沉浸式使用,界面嵌入IDE,无需跳转到其他应用;

提到的源代码旁边有行标,点击一键查看文档;

文档浏览器支持与IDE一致的代码亮度,引用跳转;

线上效果
代码中文档会定期自动部署到远端。以一篇真实业务文档举例,HTML 部署到轻服务后长这样:

字节工程师自行开发了基于IntelliJ的终极文档套件

对应飞书的产物长如下:
字节工程师自行开发了基于IntelliJ的终极文档套件

这些线上页面主要面向非当前团队的读者,内容由 CI 定时同步,暂不提供跳转到 IDE 的能力。

技术实现
项目的架构如图所示:
字节工程师自行开发了基于IntelliJ的终极文档套件

考虑到用户体验部分主要呈现在IDEA(AndroidStudio)中,我们的技术栈选择基于InteliJ。按模块可分为三部分:

基建层

IDEA Plugin

Gradle / Dokka Plugin

通用逻辑(语言实现相关)封装在基建层,仅依赖 IntelliJ Core。相对于 IntelliJ Platform,IntelliJ Core 仅保留语言相关的能力,精简了 codeInsight、UI 组件等代码,被广泛用于 IntelliJ 各大产品中(包括图中的 Kotlin、Dokka 等)。

以下将介绍这三个主要模块。

基建
在整个方案中,基础设施是所有功能的基石,其核心能力是建立代码和文档之间的关联。在这里,我们设计了一套标记语言CodeRef,以满足以下需求:

语法简洁,结构与源代码一一对应;

准确的指向,即必须满足一对一的关系;

支持只保留声明(去除body),提高信噪比;

具有扩展性,便于后续迭代新功能;

Coderef语言并不复杂,采用类似Kotlin/Java的风格,用关键字、字符串、括号构成句子和代码块,代码块中的每个节点都有相应的源代码节点。下图是一个简单的示例,相应的关系用着色文字标记:

字节工程师自行开发了基于IntelliJ的终极文档套件

注:即使文档内容不改变,一旦图片中的源代码部分发生变化,相应的渲染效果也会实时改变,产生动态绑定效果。那么,如何实现动态绑定呢?大致分为以下三个步骤:

设计语法,编写语言实现;

结合现有能力(InteliJCore、KotlinPlugin)获取双边语法树,从而建立从文档节点到源代码节点的单向对应关系;

结合现有能力(MarkdownParser)生成用于渲染的文档文本;

语言基础实现
基于InteliJPlatform,实现自定义语言至少要做以下几件事:

编写BNF定义,描述语法;

Parser、Psielement接口、flex定义等。

基于生成的flex文件和JFlex生成lexer;

用Psitreutil等工具编写Mixin,实现PSI中声明的自定义方法;

BNF是一切的基础,每个定义和价值的选择都非常重要。一个小例子:

{

/* ...一些必要的 Context */

tokens = [

    /* ...一些 Token,转换为代码中的 IElementType */

    AT='@'

    CLASS='class'

]

/* ...一些规则 */

extends("class_ref_block|direct_ref|empty_ref") = ref

extends("package_location|class_location") = ref_location

extends("class_ref|method_ref|field_ref") = direct_ref

}

ref_location ::= package_location | class_location

package_location ::= AT package_def {

pin=2 // 只有 '@' 和 package_def 一起出现时,才把整个 element 视为 package_location

}

class_location ::= AT class_def {

pin=2 // 只有 '@' 和 class_def 一起出现时,才把整个 element 视为 class_location

}

direct_ref ::= class_ref | method_ref | field_ref | empty_ref {

methods = [ // 一些自定义的 method,需要在下面指定的 mixin class 中给出实现

    getNameStringLiteral

    getReferencedElement

    getOptionalArgs

]

mixin="com.bytedance.lang.codeRef.psi.impl.CodeRefDirectRefMixin"

}

class_ref ::= CLASS L_PAREN string_literal [COMMA ref_args_element*] R_PAREN {

methods = [

    property_value=""

]

pin=1 // 即遇到第一个元素 class 后,就将当前 element 匹配为 class_ref

}
上面的小片段中定义了 @class("")、@package("")、class("", ...) 语法。实战中比较关键的是 pin 和 recoverWhile,前者影响一段“未完成”的代码的类型,后者控制一段规则何时结束。具体参考 Grammar-Kit。

编写完成后,我们可以使用Grammar-Kit生成Parser和Lexer。前者负责最基本的语法亮度,后者负责输出PSI树。在自定义的ParserDefinition中注册两者,然后结合自定义的LanguageFiletype,IDE将相应类型的文件分析为由Psielement组成的树。示意图如图所示:
字节工程师自行开发了基于IntelliJ的终极文档套件

值得一提的是,后续Formatter、CompletionContributor等组件的实现受上述过程的影响很大,如果实现不好,必然会面临返工。但是有很多坑需要一个一个流过。这部分仅限于空间,不能写得太细。有兴趣看看Fortran的BNF定义,语言特征相对简单。

语法树单向对应
考虑到IDE内置对Java和Kotlin语言的支持,以及上一步的结果,我们得到了两棵语法树,是时候连接两棵树的节点了:

字节工程师自行开发了基于IntelliJ的终极文档套件

在这里,我们借用PsireferenceContributor(官方文档)注册Crelement(即Coderef语言Psielement的基类)引用源代码Psielement,基于每行双引号内容(字符串)。如何找到每个字符串对应的元素?遵循以下三个步骤:

除根节点外,每个节点还需要向上递归,找到每个级别的parent,直到根节点;

根节点是给定full-qualified-name的package或class,上一步的结果可以确定元素在package或class中的位置;

通过JavaPsiFacade和一系列搜索方法确定源中对应的Psielement;

注意:Kotlin Plugin 提供一套针对 Java 的 “Light” PsiElement 实现,因此这里我们考虑 Java 即可。

生成文档文本
有了语法树的对应关系,可以生成用于预览的文本。这部分比较常规,要时刻注意读写环境,按照以下步骤实现:

为每个Coderef语法树根节点指向的源代码文件创建副本;

遍历CodeRef树中的每一个Ref或Location,创建或定位副本中的相应位置,并将源代码文件中的元素(修改后)复制到副本中;

导出副本字符串;

考虑到 IDE 中 PSI 和文件是实时映射的,为不影响原文件内容,必须在副本环境中进行语法树的增删改。

虽然这部分不难,但繁琐程度最高。一方面,由于需要深入细节,上述KotlinLightPSI不再适用,因此必须分别为Java和Kotlin编写和实现。另一方面,如何保证复制后的代码格式正确也是一个大问题,尤其是元素之间的注释。最后,文本内容的生成在不断的断点和调试周期中形而上学地完成。

到目前为止,基建层的任务——将CodeRef还原为代码段——全部完成。

IDEA Plugin
有了前面的基础,IDEAPlugin主要负责使方案的本地使用体验可用易用。具体来说,插件的功能分为两类:

丰富语言功能的CodeRef;

面向Markdown,改进编辑,阅读体验;

接下来分别从以上角度介绍。

语言优化
对于一门新语言来说,从体验层面来说,PSI的完成只是第一步,自动完成、关键词亮度、格式化等功能对可用性的影响也是决定性的。尤其是在Coderef语法下,指望用户不依靠提示手动输入正确的包名、类名、方法名无疑太硬核了。让我们选择一些有趣的开始。

代码补全

在IDEA中,大多数(不太复杂的)代码都是通过Pattern模式注册的。所谓Pattern相当于Filter,当前光标位置满足Pattern时,会触发相应的ComplternConContributor。

我们可以用PlatformPaterns的几种内置方法来描述Pattern。例如,一个Coderef代码:method(“helloworld”),它的PSI树长如下:

  • CrMethodRef // text: method("helloWorld")

    • CrStringLiteral // text: "helloWorld"

      • LeafPsiElement // text: helloWorld

Pattern 因此为:

val pattern = PlatformPatterns.psiElement()

.withParent(CrStringLiteral::class.java)

.withSuperParent(2, CrMethodRef::class.java)

对应每一个Pattern,我们需要实现一个CompletionProvider给出补充信息,比如一个固定返回关键字补充的Provider:

val keywords = setOf("package", "class", "lang")

class KeywordCompletionProvider : CompletionProvider() {

override fun addCompletions(
    parameters: CompletionParameters,
    context: ProcessingContext,
    result: CompletionResultSet
) {

    keywords.forEach { keyword ->

        if (result.prefixMatcher.prefixMatches(keyword)) {

            // 添加一个 LookupElementBuilder,可以指定简单的样式

            result.addElement(LookupElementBuilder.create(keyword).bold())

        }

    }

}

}
掌握上述技能,如class、package、method等关键字,甚至方法名和字段名的补充都很容易实现。

比较 trick 的是包名和带有包名的类名的补全,它们形如 a.b.c.DEF。不同的是,每次输入 '.' 都会触发一次补全,而且要求在字符串开头直接输入“DE”也能正确联想并补全。限于篇幅不展开介绍了,详见 com.intellij.codeInsight.completion.JavaClassNameCompletionContributor 的实现。

格式化

在格式化方面,IDEA并没有直接使用PSI或ASTNode,而是建立了基于两者的Block系统。所有缩进和间距的调整都是以Block为最小粒度进行的(有些复杂的语言拆得太细,可以很好的降低设计的复杂性,很棒)。

这里概念不多,列举如下:

ASTBlock:我们用现有的ASTNode树构建Block,所以继承这个基础;

Indent:控制每行的缩进;

Spacing:控制每个Block之间的间距策略(最小、最大空间、强制换行/不换行等)。

Wrap:单行长度过长的折行策略;

Alignment:自己在ParentBlock中的对齐方向;

实际敲击代码时,大部分时间都花在getSpacing方法上,写出来的效果类似于这样:

override fun getSpacing(child1: Block?, child2: Block): Spacing? {

/*...*/

return when {

    // between ',' and ref

    node1?.elementType == CodeRefElementTypes.COMMA && psi2 is CrRef ->

        Spacing.createSpacing(/*minSpaces*/0, /*maxSpaces*/0, /*minLineFeeds*/1, /*keepLineBreaks*/true, /*keepBlankLines*/1)

    // between '[', literal, ']'

    node1?.elementType == CodeRefElementTypes.L_BRACKET && psi2 is CrStringLiteral ||

            psi1 is CrStringLiteral && node2?.elementType == CodeRefElementTypes.R_BRACKET ->

        Spacing.createSpacing(/*minSpaces*/0, /*maxSpaces*/0, /*minLineFeeds*/0, /*keepLineBreaks*/false, /*keepBlankLines*/0)

}

}
格式化属于说起来很简单,实现起来很头痛的东西。实操过程中,*把前面写好的 BNF 做了一波不小的调整,才达到理想效果。好在我们的语言比较简陋简洁,没踩到什么大坑,如果面向更复杂的语言,工作量将是指数级提升(参考 com.intellij.psi.formatter.java 包下的代码量)。

MarkdownX
上面列出了这么多内容,说白了就是Markdown中代码块的增强方案,然后Coderef和Markdown终于要合体了。

事实上,官方一直支持Markdown(IDEA内置,AS可选安装),包括一套完整的语言实现和编辑器,预览器。以下是预览的生成过程,如图所示:

字节工程师自行开发了基于IntelliJ的终极文档套件

分为以下步骤:

利用MarkdownParser将文本分析为多个ASTNode;

利用HtmlGenerator内置的visitor访问每个ASTNode生成HTML文本;

将生成的HTMLDocument设置为内置浏览器(如有),最终呈现在屏幕上;

交代个背景:在本项目启动之初,IDEA 正处于 JavaFX-WebView 到 JCEF 的过渡期(直接导致了 AndroidStudio 4.0 左右的版本没有可用的内置 WebView 实现)。

上述方案总结有以下问题:

兼容性差,部分IDE版看不到预览;

每一次MD变更都会触发完整的generateHtml,如果文档内容复杂度高,就会出现性能瓶颈;

将HTML文本set交给浏览器时,没有diff逻辑,会触发页面reload,这也可能导致性能问题(后来diff能力增加到带JCEF的IDE,但并非所有IDE都内置JCEF);

综合考虑,我们决定不直接使用本地插件,而是基于其创建新语言MarkdownX,最大限度地重用原有能力,增加对Coderef的支持,并根据Swing制作一套类似Recyclerview的机制来提高预览性能。

优化后的方案流程类似:

字节工程师自行开发了基于IntelliJ的终极文档套件

自制方案有许多优点:

内存占用较低(浏览器vs.JComponent)

性能更好(局部刷新、控件复用等)

更好的体验(浏览器内置对标签的支持太基础,无法实现代码亮度、引用跳转等功能,本地控件没有这些限制)

更好的兼容性(不解释)

CodeRef 支持

Markdownx只表现为新语言,MarkdownParser和HtmlGenerator在实现中仍然被重用,主要区别在于文件扩展名和code-fence的处理。

所谓code-fence,就是用``符号包裹在Markdown中的代码块。与本地实现不同,我们需要在生成预览时更换代码块的内容,并使内容随代码的变化而变化。

实际上,我们需要实现一个org.intellij.markdown.html.generatingProvider,简写如下:

class MarkDownXCodeFenceGeneratingProvider : GeneratingProvider {

override fun processNode(visitor: HtmlGenerator.HtmlGeneratingVisitor, text: String, node: ASTNode) {

    visitor.consumeHtml("<pre>")

    var state = 0 // 用于后面遍历 children 的时候暂存状态

    /* ...一些变量定义 */

    for(child in childrenToConsider) {

        if (state == 1 && child.type in listOf(MarkdownTokenTypes.CODE_FENCE_CONTENT, MarkdownTokenTypes.EOL)) {

            /* ...拼接每行内容 */

        }

        if (state == 0 && child.type == MarkdownTokenTypes.FENCE_LANG) {

            /* ...记录当前 code-fence 的语言 */

            applicablePlugin = firstApplicablePlugin(language) // 找到可以处理当前语言的“插件”

        }

        if (state == 0 && child.type == MarkdownTokenTypes.EOL) {

            /* ...进入代码段,设置状态 */

            state = 1

        }

    }

    if (state == 1) {

        visitor.consumeTagOpen(node, "code", *attributes.toTypedArray())

        if (language != null && applicablePlugin != null) {

            /* ...命中自定义处理逻辑(即 CodeRef)*/

            visitor.consumeHtml(content) // 即由自定义逻辑生成的 Html

        } else {

            visitor.consumeHtml(codeFenceContent) // 默认内容

        }

    }

    /* ...一些收尾 */

}

}
可此可见,当前代码段的语言可以在遍历node的children后确定。若语言为coderef,则进入上述预览文本生成逻辑,最后通过visitor(相当于HTMLBuilder)将定制内容拼接到Html中。

预览性能优化

考虑到JList没有item回收的能力,我们选择在List实现中直接使用Box。处理过程如下:

字节工程师自行开发了基于IntelliJ的终极文档套件

机制分为两大步:

Data层将HTML的body分成几个部分,diff后通知View层更改;

View层将变更的数据设置到List对应的位置,并尽可能重用现有的ViewHolder。该过程可能涉及创建和删除ViewHolder;

目前,我们为文本、图片和代码创建了三种ViewHolder:

文本:使用JTextPane与HTML+CSS一起恢复文本样式;

图片:自定义JComponent缩放,绘制,确保图片居中并完整显示;

代码:基于IDE提供的Editor,进行必要的设置和逻辑简化;

在这里,处理Editor花费了大量精力:

使用原始代码文件作为context创建PsicodeFragment作为内容填充Editor,以确保代码中的原始文件import类别、方法、字段可以正常resolve(这一点非常重要,如果使用MockDocument作为内容,绝大多数代码亮度和跳转不生效);

设置合适的HighlightingFilter,确保不报红(将原文件作为context的代价是当前代码片段的类很可能被认为是类重复,代码结构不一定合法,因此需要禁用报红级别的代码分析);

禁用Intention,设置只读(提高性能,减少干扰);

禁止Inspection和ExternalAnnotator;(两者都是性能消耗大户,后者包括AndroidLint相关逻辑)

经过以上优化,测量预览在大多数情况下可以顺利显示和刷新。但如果同时打开多个文档,或者操作速度惊人,还是会时不时卡住很久。分析发现,性能消耗主要在HTML生成上。

由于Markdown语法限制(节点深度低),传统MD转HTML的性能支出有限。但是回顾以上,我们对coderef的处理会伴随着大量的PSIresolve,复杂飙升,频繁的全generate就没那么合适了。一个很自然的想法是给每个coderef添加缓存,内容不变的时候直接使用缓存内容。这样,在修改文本段落时,可以完全避免其他文件的语法分析,在修改coderef段落时,只会刷新当前代码块的内容。

然后问题来了:如果用户修改的代码不是文档文件,而是被引用的代码,预览不会在缓存的作用下立即改变。那么进一步,如果你注册并监控所有被引用的文件,并在更改时刷新缓存,问题能解决吗?其实这样做的问题确实解决了,但是引入了新的问题:如何释放文件监控?

此处插入背景:对 code-fence 内容的干预是基于 Visitor 模式回调完成的,因此作为 generator 本身是不知道本次处理的代码块与前一次、后一次回调是否由同一个变更引起。举个例子:一个文档中有 A、B、C 三个 codeRef 块,则在一次 HTML 生成过程中,generator 会收到三次回调,且没有任何手段可以得知这三次回调的关联性。

目前,我们只能在HTML生成前后通知generator,并在generator内部维护一个队列+计数器,以不那么优雅地解决泄漏问题。

至此,插件的整体性能终于在可接受范围内。

Gradle / Dokka Plugin
为了让受众更广、内容随时可读,把文档做到可导出、可自动化部署是非常必要的。方案上,我们选用同为 IntelliJ 出品的 Dokka 作为基础框架,利用其完善的数据流变换能力,高效地适配多输出格式的场景。

Dokka 流程扩展

Dokka 作为同时兼容 Kotlin 和 Java 的文档框架,“数据流水线”的思想和极强的可扩展性是其特点。代码转换到文档页面的流程如下:

字节工程师自行开发了基于IntelliJ的终极文档套件

每个节点都有至少一个 Extension Point,扩展起来非常灵活。

图中几个主要角色列如下:

Env:包含基于KotlinCompiler和IntelliJ-Core扩展的代码分析器(用于输出DocumentModels)、开发者定制的插件等组件;

DocumentModels:对module、package、class、function、fields等元素的抽象,呈树形组织,本质上是一些dataclass;

PageModels:PageCreator以DocumentModels为输入,创建的一系列对象是封装页面,描述页面的结构;

Renderer:用于将PageModels渲染成某种格式的产品(Dokka内置HTML、Markdown等););

从以上内容可以看出,Doka最初的功能只是将代码转换为文档页面,而不是本地支持文档文件的转换(真的没有必要)。但是在我们的场景中,MarkdownX的渲染取决于源代码信息,所以Doka的这部分ka的这部分能力。

通过重写PageCreator,我们将包含Markdownx文档的项目变成这样一个节点树:
字节工程师自行开发了基于IntelliJ的终极文档套件

MdxDirNode 对应文件夹节点,页面内容是当前文件夹的目录,点击链接可跳转至下一级;

MdxPageNode 对应 MarkdownX 文档内容,包含若干类型的 children 分别代表不同类型的内容片段;

在创建MdxPagenode时,我们使用类似于上述IDEA-plugin的方法,重写一个org.jetbrains.doka.barsers.parserser,修改code-fence的处理,改为调用到基础设施部分生成coderef预览文本的代码,最终获得所需的文档文本。

飞书适配
在获得页面内容后,结合Doka自带的HTMLRenderer,很容易输出可用于部署的HTML产品。但目前的情况是,我们更喜欢将文档收敛到飞行书籍中,这需要为飞行书籍编写另一份定制Renderer。

考虑到自己处理页面的树形结构过于复杂,我们实际上是基于内置的Defaultrender基类来扩展的:

abstract class DefaultRenderer(

protected val context: DokkaContext

) : Renderer {

abstract fun T.buildHeader(level: Int, node: ContentHeader, content: T.() -> Unit)

abstract fun T.buildLink(address: String, content: T.() -> Unit)

abstract fun T.buildList(

    node: ContentList,

    pageContext: ContentPage,

    sourceSetRestriction: Set<DisplaySourceSet>? = null

)

abstract fun T.buildNewLine()

abstract fun T.buildResource(node: ContentEmbeddedResource, pageContext: ContentPage)

abstract fun T.buildTable(

    node: ContentTable,

    pageContext: ContentPage,

    sourceSetRestriction: Set<DisplaySourceSet>? = null

)

abstract fun T.buildText(textNode: ContentText)

abstract fun T.buildNavigation(page: PageNode)



abstract fun buildPage(page: ContentPage, content: (T, ContentPage) -> Unit): String

abstract fun buildError(node: ContentNode)

}
上面只列出一部分了回调方法。

可以看出,这种接口方式比较新颖:以Visitor的方式遍历页面节点树,然后为开发者提供一系列Builder/DSL风格的待实现方法。对于这些abstractfunction,内置Htmlrender采用kotlinx.html(DSL风格的HTML构建器)实现,这意味着我们也应该实现一套DSL风格的飞行文档构建器。

飞书开放平台文档查看链接:飞书开放平台

DSL部分不详细,这里主要讲飞书的文档结构。众所周知,Markdown在设计之初就是面向Web的,所以天生就有与HTML互动的能力。但飞书文档的数据结构相对更像Pdf、Docx等文件,层次有限,相对扁平。例如,在相同的文档内容中,MdxPagenode的结构是这样的:

字节工程师自行开发了基于IntelliJ的终极文档套件

而飞书的结构长这样:

字节工程师自行开发了基于IntelliJ的终极文档套件

可以看出,差异是巨大的。这部分差异的抹平完全取决于自定义的Feishurenderer,具体做法只能由casebycase介绍,仅限于篇幅不展开,一般思路是对不兼容的节点进行展开或合并,穿插必要的子树遍历。

以下是两个特殊点:图片和链接。

文档链接

写Markdown文档时,往往需要插入链接,指向其他Markdown文档(一般使用相对路径)。这时候就需要想办法把相对路径映射成飞书链接,需要在Render步骤之后进行,因为映射的时候需要知道相应文档的飞书链接是什么。

第一反应必须是对文档进行拓扑排序,并根据依赖关系逐一上传文档。但这需要文档之间没有循环依赖,这显然不能保证(两个文档相互引用相当常见)。幸运的是,飞行文档提供了修改文档的界面,因此我们可以提前创建一批空文档,在获得空文档的链接后更换相对路径。换句话说,文档上传的处理过程是:创建空文档->替换相对路径为相应的文档链接->修改文档内容。

图片

图片可以在Markdown中与文本并列,属于Paragraph的一种。在飞行图书文档结构中,图片属于Gallery,只能独占一行,不能与文本同行。这两种格式在实现中不能完全兼容。目前的初步实现方案是在Paragraph的Group入口处向下DFS,找到所有图片,提出放在文本前面。效果,只能忍受。

顺便说一下,图片也需要上传和替换逻辑,类似于文档链接,不重复。

结语

以上是文档套件的全部内容:基于InteliJ技术栈,我们通过设计新语言、编写IDE插件、Gradle/Dokka插件,形成了完整的文档辅助解决方案,有效建立了文档与代码的关联,大大提高了编写和阅读体验。

未来,我们将为框架引入更实用的改进,包括:

添加图形代码元素选择器,降低语言学习和使用成本;

优化预览渲染效果,对齐WebView;

探索部分框架(Dagger、Retrofit等)的文档自动生成能力。

目前框架还处于内部测试阶段,正在逐步扩大推广范围。方案成熟,功能稳定后,将整体开源方案,为更多用户服务,吸收社区Idea。请期待!

上一篇:HashMap 的产生与原理


下一篇:再见 Xshell ,这款开源的终端工具逼格更高