7

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

 1 year ago
source link: https://www.cnblogs.com/Kation/p/16869326.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

之前想用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';
    }
}
238507-20221108130259618-687211984.png

这样加载内容就能缩小至2.5MB

238507-20221108130528587-488044440.png

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK