展示
Markdown - Markdown 渲染
一段 Markdown 字符串,用这套系统的组件渲染出来。
用法
什么时候用它
import { Markdown } from '@misoto22/folio'说明
一段 Markdown 字符串,用这套系统的组件渲染出来。
它补的那个缺口,是不是这边写的内容:一条评论、一份 README、一个模型的回答、数据库里的一段描述。Article 接不了这些——它用 dangerouslySetInnerHTML 渲染 HTML,而且白纸黑字写着只收可信输入——在此之前,剩下的选择只有一个 <pre>,或者往一个只管样式的原语上硬焊一个净化器。
它不是 `Article`,两者也不会合并。Markdown 把一个字符串变成节点;Article 是这些节点可以坐进去的那一列正文。它渲染的是一个 Fragment 而不是一个容器元素,正是这一点让嵌套成立——Article 的节奏是靠直接子元素选择器写的,中间夹进任何一个元素,display: contents 也算,都会让每一段都丢掉自己的间距。
── 它带来字体和颜色,不带来垂直节奏 ──
节点自己就带着这套系统的字面和墨色,所以单独看一个是对的。但它单独看是没有间距的:每个节点都渲染成 m-0,而一个 Fragment 没有自己的盒子可以用来放间隙。在容器上加一个统一的 gap 也复现不出来,因为正文的间距本来就不统一——article.css 给标题上方 2.25em、下方 0.75em,正是这个让标题贴着它所引出的那一段,而不是悬在两段中间。任何比一句话更长的东西——一条评论、一个回答、一份 README——请放进 <Article as="div">,用一个元素的代价换来真正的节奏。
── 一个围栏代码块会连带引入一个客户端组件 ──
渲染器是一堆纯函数,没有状态;但一个围栏代码块会渲染 CodeBlock,而它是 'use client' 的,还带着 useState、useEffect 和两个图标。这是对的——复制按钮正是这个块存在的理由——但“服务端渲染”这句话只对不含代码的内容成立,在把它放进一个还没有客户端包的路由之前,这一点值得先知道。
── 为什么 dependencies 里没有解析器 ──
三个选项,把决定写在这里,是因为这正是读者会想来争一争的地方。
1. 打包一个解析器。 markdown-it 是最明显的人选,文档站本身也已经依赖它——但站点是应用,这里是库,而库的依赖清单是每一个使用方都要签的合同的一部分。用 check:size 同一套 esbuild 流程量出来,markdown-it 压缩后 110.7 kB,而这个包在体积预算下只剩 38.9 kB。差得不是一点半点,而“预算调高就是了”正是一个设计系统变成页面上最大那块东西的方式。 2. 收已经解析好的 HTML,像 Article 那样。最省事,而对这个组件存在的理由来说是错的:来自不可信作者的 HTML 正是这里本该负责去化解的那种输入;而且它没法携带 headingLevelStart,也给不出稳定的标题 id,除非组件再把标记拆回去找它们。 3. 只解析我们自己排版的那个子集,其余的收一个 parse 函数。选的是这个。块级语法是按行来的,也很小(见 parse.ts),它产出的是 React 元素而不是标记——所以这条路径上压根没有 dangerouslySetInnerHTML,没有净化器要配置,也没有净化器会被配错——而需要表格、脚注或者任务列表的使用方传一个 parse 进来,组件其他每一条承诺都还在。
标题会带上一个由自己的文字生成、并在文档内去重的 id,所以目录可以直接链过去,调用方不必再去后处理 DOM,找它们跑到哪儿去了。
源文本里的链接在这里、在边界上被校验:协议不是 http、https、mailto 或 tel 的,永远不会变成链接;而通往站外的那些带着 rel="noreferrer nofollow"——所以一个页面并不信任的作者,既花不掉它的权重,也没法从 Referer 里读出它的 URL。
结构
| 部件 | 说明 |
|---|---|
| 片段必填 | 它渲染出来的东西。这里没有包装元素,因为 Article 的节奏靠的是直接子元素选择器——夹在两者之间的任何东西,包括 display: contents,都会让每一个段落丢掉自己的间距。 |
| 解析器必填 | 默认是 parseMarkdown:ATX 标题、段落、围栏代码、引用块、嵌套列表、分隔线,以及行内的强调、加粗、代码、链接、图片和转义。原始 HTML 是被丢掉而不是被渲染,所以这条路上根本没有 dangerouslySetInnerHTML。 |
| 节点必填 | 这套系统自己的组件:Heading、Text、Code、CodeBlock 和 Separator。正是它们让这段内容自己就有样式,而不是只有落在阅读栏里才有。其中有一个不是纯服务端的——围栏代码渲染成 CodeBlock,那是一个带 useState、useEffect 和两个图标的 "use client" 组件,所以含代码的内容会顺带把一个客户端组件带进来。 |
| 标题 id必填 | 从每个标题自己的文字生成 slug,任何文种都行,并在文档内用 -2、-3 去重。以 slugify 导出,所以目录不必回头从 DOM 里读,就能算出同样的 id。 |
实践建议
推荐
- headingLevelStart 要设成“这段内容所在标题的下一级”。Markdown 是当作一份文档写的,所以它的 # 就是 <h1>;扔进一个已经有 <h1> 的页面,那就是两个一级标题和一份谁都没法导航的大纲。
- 把它放进 Article 里——一条评论或者一个回答用 <Article as="div">,一份文档用 <Article>。Markdown 造出这些节点,Article 是它们所在的那一栏,也是唯一给它们间距的东西,这同时也是 Markdown 自己不渲染任何元素的原因。
- 一个页面上有两份文档时要传 idPrefix。否则两份都会来认领 #installation,而一个片段链接会落在浏览器先找到的那一个上。
- 需要表格、脚注或者任务列表,就用 parse 把你自己的解析器带进来。这个包故意不带任何解析器依赖:markdown-it 压缩后是 110.7 kB,而这个包在它的体积预算里只剩 38.9 kB。
避免
- 别递给它 HTML。它解析的是 Markdown;一串标签会被渲染成这些标签本身的文字,这是安全的答案,却不是你要的那个——可信的 HTML 属于 Article。
- 没有承载它的容器就别指望有节奏,也别想用一个 gap 把它买回来。每个节点都是 m-0 渲染进一个光秃秃的片段,而散文的间距本来就不均匀——article.css 给标题上方 2.25em、下方 0.75em,正是这一点让它和它所引出的那个段落坐在一起。容器上一个统一的 gap 复制不出这个;<Article as="div"> 可以。
- 别以为整个 GFM 都在。表格、脚注、任务列表、setext 标题、引用式链接和硬换行都不在内置解析器的范围里,写了其中任何一个,你悄无声息拿到的是一个段落。
示例
a string into nodes
一段不是这边的人写的字符串,渲染成这套系统自己的组件——整条路径上没有任何 dangerouslySetInnerHTML,所以既没有净化器要配置,也没有净化器会配错。headingLevelStart 是 2,因为这块内容坐在页面自己的标题底下,而那些标题拿到的 id 是从它们的文字 slug 出来的,好让目录能链进去。Article 是那根阅读列;Markdown 造的是坐在里面的那些节点,这也是它自己不渲染任何元素的原因。
Release notes
The parser covers the subset this system styles: headings, prose, code, strong, links, lists and fences. A link pointing at a scheme that is not http, https, mailto or tel renders as text instead.
- One rung of the ink ladder per tone
- A hairline dash for a marker, never a filled disc
Depth is a hairline and a change of ground, never a blur.
pnpm add @misoto22/folioa readme fragment
一份 README,直接从仓库里拿出来,渲染成这套系统自己的组件。嵌套列表、有序列表、带语言的围栏、行内代码和链接,全都出自内置的解析器,而且一样都没走 dangerouslySetInnerHTML——那些节点是 React 元素,所以既没有净化器要配置,也没有净化器会配错。headingLevelStart 是 2,因为不这样的话,文档自己那个 # 会成为这一页上的第二个 h1。
@misoto22/folio
A monochrome design system, published as one package and one stylesheet.
Install
pnpm add @misoto22/folioThen import the compiled look once, at the root of the app:
@misoto22/folio/styles.css— tokens, fonts and the compiled utilitiesOr the portable layers on their own:
tokens.cssfor the primitivessemantic.cssfor the roles
Everything exported from the package is a consumer contract: adding an export is cheap, and moving one is a major version.
an answer mid stream
一段模型的回答,被 token 上限从围栏中间切断了——它渲染出来是一个代码块,而不是一个异常,因为一个没闭合的围栏会一直延伸到文档末尾,CommonMark 说的正是这样。这里没有任何东西是一份文档,所以没有 Article:这段片段直接进到会话本来就有的那个盒子里,间距也由那个盒子提供,因为 Markdown 自己不渲染任何元素,而它造出来的每个段落都是 margin: 0。
Use Tag for the facets and keep the button outside it, so the padding stays on the chip and the target stays on the control.
- Wrap each tag in a real button
- Pass
aria-pressedin the same breath asactive - Leave one facet off, so the accent still means something
<button type="button" aria-pressed={on} onClick={toggle}> <Tag active={on}>Rust</Tag>shifting the outline
同一份文档,放在一个已经存在的标题底下。headingLevelStart 是它所在那一级的下一级,所以这份指南的 # 渲染成 h3,里面的每一级一起往下挪——不挪就丢进去,它会成为第二个一级标题,以及一份读屏软件没法导航的大纲。idPrefix 给生成的 id 加上命名空间,好让一页上的两份文档不会都来认领 #installing,而 slugify 被导出来正是为了这个:目录不用回头去 DOM 里读,就能得到同样那些 id。
Getting started
Installing
Add the package, then import the stylesheet once at the root of the app.
Upgrading
Minor versions add exports and never move one, so an upgrade inside a major is a version bump and nothing else.
your own parser
任务列表不在内置语法里,所以一个带方括号的 x 就是一段字面文本,别的什么都不会发生——是安静,不是坏掉,这里每一个不支持的特性都是这个形状。parse 就是那道接缝:任何一个从字符串到 MarkdownNode 的函数,所以它能接下一整条流水线(markdown-it、remark、一份你手上已经有的 AST),或者像这里一样,只把语法不认识的那一样东西重写掉,剩下的仍旧交给随包附带的解析器。
Before 0.5.0
- ✓ Tokens rebuilt from the CSS source
- ✓ Every example carries the sentence that explains it
- ○ Chinese copy for the five newest components
无障碍
- headingLevelStart 一次挪动整份文档,所以嵌进去的内容保持一份合法的大纲,而不是从 h1 重新来过。
- 每个标题都拿到一个稳定的、保留原文字符的 id,并按文档顺序去重,所以目录能链进去。
- href 的协议不是 http、https、mailto 或 tel 的链接会渲染成纯文本——javascript: 永远不会变成一个控件。
- 通往别的站点的链接带着 rel="noreferrer nofollow",所以一个不可信的作者既花不掉这个页面的权重,也没法从 Referer 里读出它的 URL。这一条不可配置;markExternalLinks 加的是那个看得见的站外箭头,默认关闭。
- 格式不对或者空的字符串渲染成什么都没有,而不是抛错——读者写出来的内容,本来就常常是这样。