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