要寫檔案了,emmm,先寫個檔案工具吧——DocMarkdown

2022-11-08 21:02:58

前言

之前想用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';
    }
}


這樣載入內容就能縮小至2.5MB

效果

連結

最終效果,點選存取
Github原始碼地址,點選存取