要写文档了,emmm,先写个文档工具吧——DocMarkdown

  • 要写文档了,emmm,先写个文档工具吧——DocMarkdown已关闭评论
  • 157 次浏览
  • A+
所属分类:.NET技术
摘要

之前想用Markdown来写框架文档,找来找去发现还是Jekyll的多,但又感觉不是很合我的需求
于是打算自己简单弄一个展示Markdown文档的网站工具,要支持多版本、多语言、导航、页内导航等,并且支持Github Pages免费站点


前言

之前想用Markdown来写框架文档,找来找去发现还是Jekyll的多,但又感觉不是很合我的需求
于是打算自己简单弄一个展示Markdown文档的网站工具,要支持多版本、多语言、导航、页内导航等,并且支持Github Pages免费站点

组件选择

我自己呢比较喜欢C#,恰好现在ASP.Net Core Blazor支持WebAssembly,绝大部分代码都可以用C#完成
对于Markdown的分析,可以使用markdig组件(有个缺点,目前它把生成Html的代码也放到了程序集里,增加了不少的程序集大小,增加了载入时间)
展示组件可以使用Blazorise,有挺多组件能用,还有几个风格能选,使用比较方便

配置

为了能提供较好的通用性,我定义了以下配置

配置文件

站点目录必须包含config.json配置文件,
配置文件声明了DocMarkdown该从哪里读取Markdown文档并建立目录关系。

config.json是一个JSON格式的配置文件,以下配置是一个完整的配置文件示例。

{   "Title": "DocMarkdown",   "Icon": "logo.png",   "BaseUrl": "https://raw.githubusercontent.com/who/project",   "Path": "docs",   "Languages": [     {       "Name": "简体中文",       "Value": "zh-cn",       "CatalogText": "本文内容"     }   ],   "Versions": [     {       "Name": "DocMarkdown 1.0",       "Value": "1.0",       "Path": "main"     }   ] } 

标题

$.Title属性值决定了显示于左上角(默认主题)的文档标题名称。
该属性必须填写。

图标

$.Icon属性决定了显示于文档标题左侧的图标路径。
该属性可不存在或为空。

基础地址

$.BaseUrl属性决定了整个Markdown文档的路径。
该属性必须填写,可以为空字符串。
当属性为空字符串或相对路径时,将使用本域名内资源。

路径地址

$.Path属性将附加于每个Markdown文档路径之前。
该属性可以不存在。

多语言

$.Languages属性用于定义文档的多语言支持。
该属性可以不存在。
属性内容必须为数组。
第一个元素将作为默认语言。

语言名称

$.Languages[0].Name属性用于显示语言名称。
该属性必须填写。

语言值

$.Languages[0].Value属性决定了该语言的文件名称。
该属性必须填写。
属性内容将附加在Markdown文档路径扩展名之前。例如.zh-cn

目录文本

$.Languages[0].CatalogText属性决定了选择该语言时,文档页右侧的导航目录标题。
该属性必须填写。

多版本

$.Versions属性用于定义文档的多版本支持。
该属性可以不存在。
属性内容必须为数组。
第一个元素将作为默认版本。

版本名称

$.Versions[0].Name属性用于显示版本名称。
该属性必须填写。

版本值

$.Languages[0].Value属性决定了该版本在Url上的值。
该属性必须填写。

版本路径

$.Languages[0].Path属性决定了该版本在Url上的值。
该属性必须填写。

导航配置

文档根路径必须存在nav.json,如果存在多语言,每个语言都需要一份导航配置。
文档路径规则里的示例为例,则必须存在https://raw.githubusercontent.com/who/project/main/docs/nav.zh-cn.json导航配置文件。

nav.json是一个JSON格式的配置文件,以下配置是一个完整的配置文件示例。

{   "简介": {     "Path": "index"   },   "快速使用": {     "Path": "quick"   },   "高级": {     "Children": {       "内容A": {         "Path": "advanced/content1"       },       "内容B": {         "Path": "advanced/content2"       }     }   } } 

导航文件的内容将被解析生成树形结构展示于页面。

节点名称

$.{name}属性名称将作为导航目录的树形节点名。
属性值为对象,不能为空。
可以存在多个节点。

节点路径

$.{name}.Path属性作为该节点对应的文档路径,路径为相对路径。
属性可以不存在。不存在或为空时,只作为可折叠节点,点击不会导航至其它页面。

节点子项

$.{name}.Children属性作为该节点的子项容器,里面包含了该节点下的所有子节点内容。
属性可以不存在。

可以组合多层树形导航目录。

{   "一级目录1": {     "Path": "c1"   },   "一级目录2": {     "Path": "c2"   },   "一级目录3": {     "Children": {       "二级目录1": {         "Path": "c3/c1"       },       "二级目录2": {         "Children": {           "三级目录1": {             "Path": "c3/c2/c1"           },           "三级目录2": {             "Path": "c3/c2/c2"                         }         }       }     }   } } 

文档路径规则

基于配置,DocMarkdown会将网站的路径映射至目标文档。
例如/grpc/
当以/结尾或为空值时,自动添加index
然后得到路径/grpc/index

如果存在多语言,则于路径末尾添加.{lang}{lang}为当前语言值
最后于末尾添加.md扩展名。
得到路径/grpc/index.zh-cn.md

如果存在路径地址,则于路径前添加/{path}路径地址
得到路径/docs/grpc/index.zh-cn.md

如果存在多版本,则于路径前添加/{version}{version}版本路径
得到路径/main/docs/grpc/index.zh-cn.md

最后于路径前添加{baseUrl}基础地址
得到路径https://raw.githubusercontent.com/who/project/main/docs/grpc/index.zh-cn.md

DocMarkdown将请求该地址以获取Markdown文档内容并解析生成Html内容展现出来。

解析与渲染

markdig能解析Markdown内容并返回一系列不同类型的对象,根据这些对象的类型,我们可以生成想要的内容对应的Razor组件

定义一个MarkdownRenderer用于解析对应类型的对象

public abstract class MarkdownRenderer {     public abstract bool CanRender(MarkdownObject markdown);      public abstract object Render(IMarkdownRenderContext context, MarkdownObject markdown); }  public abstract class MarkdownRenderer<T> : MarkdownRenderer     where T : MarkdownObject {     public override bool CanRender(MarkdownObject markdown)     {         return markdown is T;     }      public override object Render(IMarkdownRenderContext context, MarkdownObject markdown)     {         return Render(context, (T)markdown);     }      protected abstract object Render(IMarkdownRenderContext context, T markdown); } 

为什么返回object类型?这是由于Markdown里支持HTML内容,而markdig返回行内HTML内容时,会将一个元素拆成两个IarkdownRender
一个是开头,例如<span>,一个是结尾,例如</span>

渲染Block和Inline

public RenderFragment RenderBlock(ContainerBlock containerBlock) {     return new RenderFragment(builder =>     {         int i = 0;         foreach (var block in containerBlock)         {             var obj = Render(block);             if (obj is RenderFragment fragment)                 builder.AddContent(i, fragment);             else if (obj is MarkupString markup)                 builder.AddContent(i, markup);             else if (obj is HtmlElement html)             {                 if (html.IsEnd)                     builder.CloseComponent();                 else                 {                     builder.OpenElement(i, html.Tag);                     i++;                     if (html.Attributes != null)                     {                         foreach (var attr in html.Attributes)                         {                             if (attr.Value == null)                                 builder.AddAttribute(i, attr.Key);                             else                                 builder.AddAttribute(i, attr.Key, attr.Value);                             i++;                         }                     }                     if (html.IsSelfClose)                         builder.CloseElement();                 }             }             else                 builder.AddContent(i, obj);             i++;         }     }); } public RenderFragment RenderInline(ContainerInline containerInline) {     return new RenderFragment(content =>     {         var inline = containerInline.FirstChild;         int i = 0;         while (inline != null)         {             var obj = Render(inline);             if (obj is RenderFragment fragment)                 content.AddContent(i, fragment);             else if (obj is MarkupString markup)                 content.AddContent(i, markup);             else if (obj is HtmlElement html)             {                 if (html.IsEnd)                     content.CloseComponent();                 else                 {                     content.OpenElement(i, html.Tag);                     i++;                     if (html.Attributes != null)                     {                         foreach (var attr in html.Attributes)                         {                             if (attr.Value == null)                                 content.AddAttribute(i, attr.Key);                             else                                 content.AddAttribute(i, attr.Key, attr.Value);                             i++;                         }                     }                     if (html.IsSelfClose)                         content.CloseElement();                 }             }             else                 content.AddContent(i, obj);             inline = inline.NextSibling;             i++;         }     }); } 

渲染整个Markdown文档

private void RenderMarkdown(RenderHandle renderHandle, MarkdownDocument document) {     var content = RenderBlock(document);     renderHandle.Render(builder =>     {         builder.OpenComponent<LayoutView>(0);         builder.AddAttribute(1, nameof(LayoutView.Layout), typeof(MainLayout));         builder.AddAttribute(2, nameof(LayoutView.ChildContent), (RenderFragment)(child =>         {             child.OpenComponent<Index>(0);             child.AddAttribute(1, "Content", content);             child.CloseComponent();         }));         builder.CloseComponent();     }); } 

加载

为了加快加载速度,按照官方文档,改为加载Brotli压缩后的文件
并增加加载进度动画

<div id="app">     <div class="position-fixed" style="bottom: 0; top: 0; left: 0; right: 0;">         <div class="d-flex flex-column justify-content-center align-items-center h-100">             <div style="width: 64px; height: 64px;">                 <svg viewBox="0 0 21 24">                     <path fill="transparent" d="M4.5,19.5A1.5,1.5,0,0,0,6,21H18.08V18H6A1.51,1.51,0,0,0,4.5,19.5Z" transform="translate(-1.5)" />                     <path fill="#1296db" d="M21.39,18a1.12,1.12,0,0,0,1.12-1.12V1.13A1.13,1.13,0,0,0,21.38,0H6A4.5,4.5,0,0,0,1.5,4.5v15A4.5,4.5,0,0,0,6,24H21.38a1.13,1.13,0,0,0,1.13-1.13v-.76A1.12,1.12,0,0,0,21.39,21h-.3V18Zm-4.14-4.54-2.93-4h1.79V5h2.3V9.42h1.79ZM13.29,5v8.52H11V8.91L8.94,11.54,6.89,8.91v4.64H4.59V5H6.95l2,3.22,2-3.22Zm4.79,16H6a1.5,1.5,0,0,1,0-3H18.08Z" transform="translate(-1.5)" />                 </svg>             </div>             <div class="w-50">                 <div class="progress" style="margin-top: 32px;">                     <div id="progressBar" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="width: 0%">0%</div>                 </div>             </div>         </div>     </div> </div> 
var total = 0; var receivedLength = 0; Blazor.start({ // start manually with loadBootResource     loadBootResource: function (type, name, defaultUri, integrity) {         if (type == "dotnetjs")             return defaultUri;          if (location.hostname !== 'localhost')             defaultUri = defaultUri + '.br';          const fetchResources = fetch(defaultUri, { cache: 'no-cache' });         return fetchResources.then(async (r) => {             const reader = r.body.getReader();             let length = +r.headers.get('Content-Length');             total += length;             var progressbar = document.getElementById('progressBar');             let dataLength = 0;             let dataArray = [];             while (true) {                 const { done, value } = await reader.read();                 if (done) {                     break;                 }                 dataArray.push(value);                 dataLength += value.length;                 receivedLength += value.length;                 const percent = Math.round(receivedLength / total * 100)                 var pct = percent + '%';                 progressbar.style.width = pct;                 progressbar.innerText = pct + ' ' + calcSize(receivedLength) + '/' + calcSize(total);                 console.log('Received: ' + name + ',' + calcSize(dataLength) + '/' + calcSize(length));             }             let data = new Uint8Array(dataLength);             let position = 0;             for (let array of dataArray) {                 data.set(array, position);                 position += array.length;             }             const contentType = type ===                 'dotnetwasm' ? 'application/wasm' : 'application/octet-stream';             if (location.hostname !== 'localhost') {                 const decompressedResponseArray = BrotliDecode(data);                 return new Response(decompressedResponseArray,                     { headers: { 'content-type': contentType } });             }             else                 return new Response(data,                     { headers: { 'content-type': contentType } });         });         return fetchResources;     } });  function calcSize(bytes) {     if (bytes > 1024 * 1024) {         return Math.round(bytes / 1024 / 1024 * 100) / 100 + 'MB';     }     else if (bytes > 1024) {         return Math.round(bytes / 1024 * 100) / 100 + 'KB';     }     else {         return bytes + 'B';     } } 

要写文档了,emmm,先写个文档工具吧——DocMarkdown
这样加载内容就能缩小至2.5MB

效果

要写文档了,emmm,先写个文档工具吧——DocMarkdown

链接

最终效果,点击访问
Github源码地址,点击访问