要写文档了,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
效果

链接
要写文档了,emmm,先写个文档工具吧——DocMarkdown的更多相关文章
- 如何写好技术文档——来自Google十多年的文档经验
本文大部分内容翻译总结自<Software Engineering at Google> 第10章节 Documentation. 另外,该书电子版近日已经可以免费下载了 https:// ...
- bootstrap 中是通过写less文件来生成css文件,用什么工具来编写呢?
bootstrap 中是通过写less文件来生成css文件,用什么工具来编写呢? 如果用sublime的话如何实现代码保存后浏览器刷新成最新的代码样式? 或者有什么其他好用的工具? 从网上找了很多方法 ...
- 什么是API文档?--斯科特·马文
有时候,软件开发人员想要的是自己的软件被其他应用软件所应用,而不是让人来操作.API使各种应用软件互相通信成为了可能. 从事API文档写作15年,我亲眼见证了API产品的崛起.各个公司开始搭建平台,希 ...
- 剖析手写Vue,你也可以手写一个MVVM框架
剖析手写Vue,你也可以手写一个MVVM框架# 邮箱:563995050@qq.com github: https://github.com/xiaoqiuxiong 作者:肖秋雄(eddy) 温馨提 ...
- 瞧一瞧,看一看呐,用MVC+EF快速弄出一个CRUD,一行代码都不用写,真的一行代码都不用写!!!!
瞧一瞧,看一看呐用MVC+EF快速弄出一个CRUD,一行代码都不用写,真的一行代码都不用写!!!! 现在要写的呢就是,用MVC和EF弄出一个CRUD四个页面和一个列表页面的一个快速DEMO,当然是在不 ...
- (转载)Excel文档保存的时候,提示“文档未保存”
亲测,成功搞定 Excel文档保存的时候,提示“文档未保存”? 先打开你需要处理的excel,然后打开工具栏--宏--录制新宏--确定--停止录制宏--宏-宏--编辑--复制以下程序Sub 恢复保存( ...
- 刺猬大作战(游戏引擎用Free Pascal写成,GUI用C++写成,使用SDL和Qt4)
游戏特性[编辑] 游戏引擎用Free Pascal写成,GUI用C++写成,使用SDL和Qt4[2]. 0.9.12开始支持实时动态缩放游戏画面. 个性化[编辑] 刺猬大作战有着高度定制性 游戏模式: ...
- Learning to rank的讲解,单文档方法(Pointwise),文档对方法(Pairwise),文档列表方法(Listwise)
学习排序(Learning to Rank) LTR(Learning torank)学习排序是一种监督学习(SupervisedLearning)的排序方法.LTR已经被广泛应用到文本挖掘的很多领域 ...
- 配置允许匿名用户登录访问vsftpd服务,进行文档的上传下载、文档的新建删除等操作
centos7环境下 临时关闭防火墙 #systemctl stop firewalld 临时关闭selinux #setenforce 0 安装ftp服务 #yum install vsftpd - ...
- php 写内容到文件,把日志写到log文件
php 写内容到文件,把日志写到log文件 <?php header("Content-type: text/html; charset=utf-8"); /******** ...
随机推荐
- Android的Handler线程切换原理
Handler是我们在开发中经常会接触到的类,因为在Android中,子线程一般是不能更新UI的. 所以我们会使用Handler切换到主线程来更新UI,那Handler是如何做到实现不同线程之间的切换 ...
- Python入门系列(七)开发常说的“累”与“对象”
类与对象 Python是一种面向对象的编程语言. 要创建类,请使用关键字class class MyClass: x = 5 创建一个名为p1的对象,并打印x的值 p1 = MyClass() pri ...
- Linux之如何配置IPV6网络
配置IPV6地址小笔记 #例题: 1)为server添加一个IPv6地址fd00:ba5e:ba11:10::10/64: 2)为client添加一个IPv6地址fd00:ba5e:ba11:10:: ...
- 凭借SpringBoot整合Neo4j,我理清了《雷神》中错综复杂的人物关系
原创:微信公众号 码农参上,欢迎分享,转载请保留出处. 哈喽大家好啊,我是Hydra. 虽然距离中秋放假还要熬过漫长的两天,不过也有个好消息,今天是<雷神4>上线Disney+流媒体的日子 ...
- java多线程实例程序实现与思想
写程序之前要了解两个概念 1.什么是进程 2.什么是线程 搞清楚这两个概念之后 才能写好一个合适而不会太抽象的程序 对进程和线程的理解见链接: https://blog.csdn.net/new_te ...
- C++ 左值引用与一级指针
将**左值引用**用于**一级指针**时,有以下几种用法: ```c++ //方式一:引用一级指针,常规用法 int a = 5; int * pa = &a; int * &rpa ...
- Latex中也能展示动态图?
技术背景 在学术领域,很多文档是用Latex做的,甚至有很多人用Latex Beamer来做PPT演示文稿.虽然在易用性和美观等角度来说,Latex Beamer很大程度上不如PowerPoint,但 ...
- ssh访问控制,阻断异常IP,防止暴力破解
文章转载自:https://mp.weixin.qq.com/s/oktVy09zJAAH_MMKdXjtIA 由于业务需要将Linux服务器映射到公网访问,SSH 端口已经修改,但还是发现有很多IP ...
- Elasticsearch集群管理之添加、删除节点
1.问题抛出 1.1 新增节点问题 我的群集具有黄色运行状况,因为它只有一个节点,因此副本保持未分配状态,我想要添加一个节点,该怎么弄? 1.2 删除节点问题 假设集群中有5个节点,我必须在运行时删除 ...
- 第六章:Django 综合篇 - 6:会话session
因为因特网HTTP协议的特性,每一次来自于用户浏览器的请求(request)都是无状态的.独立的.通俗地说,就是无法保存用户状态,后台服务器根本就不知道当前请求和以前及以后请求是否来自同一用户.对于静 ...