要写文档了,emmm,先写个文档工具吧——DocMarkdown - Kation
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.
要写文档了,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';
}
}
这样加载内容就能缩小至2.5MB
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK