展示
Markdown Markdown 渲染
一段 Markdown 字符串,用这套系统的组件渲染出来。
用法
什么时候用它
import { Markdown } from '@misoto22/design'说明
一段 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。
结构
| 部件 | 说明 |
|---|---|
| Fragment必填 | What it renders. There is no wrapper element, because Article’s rhythm is a direct-child combinator — anything between the two, display: contents included, would cost every paragraph its spacing. |
| Parser必填 | parseMarkdown by default: ATX headings, paragraphs, fenced code, blockquotes, nested lists, thematic breaks, and inline emphasis, strong, code, links, images and escapes. Raw HTML is dropped rather than rendered, so this path has no dangerouslySetInnerHTML in it at all. |
| Nodes必填 | The system’s own components: Heading, Text, Code, CodeBlock and Separator. That is what makes it styled on its own rather than only inside a reading column. One of them is not server-only — a fenced block renders CodeBlock, which is ‘use client’ with useState, useEffect and two icons, so content containing code brings a client component with it. |
| Heading ids必填 | Slugged from each heading’s own text, in any script, deduplicated within the document with -2, -3. Exported as slugify, so a table of contents can arrive at the same ids without reading them back off the DOM. |
实践建议
推荐
- Set headingLevelStart to the level BELOW the heading the content sits under. Markdown is written as a document, so its # is an <h1>; dropped into a page that already has one, that is two first-level headings and an outline nobody can navigate.
- Put it inside an Article — <Article as="div"> for a comment or an answer, <Article> for a document. Markdown makes the nodes, Article is the column they sit in and the only thing that spaces them, which is also why Markdown renders no element of its own.
- Pass idPrefix when two documents share a page. Both would otherwise claim #installation, and a fragment link lands on whichever the browser found first.
- Bring your own parser through parse for tables, footnotes or task lists. The package ships no parser dependency on purpose: markdown-it measures 110.7 kB minified against the 38.9 kB this package had left under its bundle budget.
避免
- Do not hand it HTML. It parses Markdown; a string of tags renders as the text of those tags, which is the safe answer and not the one you wanted — trusted HTML belongs in Article.
- Do not expect the rhythm without a surface, and do not try to buy it with a gap. Every node renders m-0 into a bare fragment, and prose spacing is not uniform — article.css gives a heading 2.25em above and 0.75em below, which is what sits it with the paragraph it introduces. A uniform gap on a container cannot reproduce that; <Article as="div"> can.
- Do not assume the full GFM surface. Tables, footnotes, task lists, setext headings, reference links and hard breaks are outside the built-in parser, and asking for one silently gets you a paragraph.
示例
a string into nodes
A string nobody on this side wrote, rendered as the system's own components — no dangerouslySetInnerHTML anywhere in the path, so there is no sanitiser to configure and none to get wrong. headingLevelStart is 2 because this sits under the page's own headings, and the ids the headings get are slugged from their text so a table of contents can link into them. Article is the reading column; Markdown makes the nodes that sit in it, which is why it renders no element of its own.
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/designa readme fragment
A README, straight off a repository, rendered as this system's own components. Nested lists, an ordered list, a fence with its language, inline code and a link all come out of the built-in parser, and none of it goes through dangerouslySetInnerHTML — the nodes are React elements, so there is no sanitiser to configure and none to get wrong. headingLevelStart is 2 because the document's own # would otherwise be a second h1 on this page.
@misoto22/design
A monochrome design system, published as one package and one stylesheet.
Install
pnpm add @misoto22/designThen import the compiled look once, at the root of the app:
@misoto22/design/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
A model's answer, cut off mid-fence by a token limit — which renders as a code block rather than as an exception, because an unclosed fence runs to the end of the document exactly as CommonMark says. Nothing here is a document, so there is no Article: the fragment goes straight into the box the thread already has, and that box supplies the gap, since Markdown renders no element of its own and every paragraph it makes is 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
The same document under a heading that already exists. headingLevelStart is the level BELOW the one it sits under, so the guide's # renders as h3 and every level in it shifts together — dropped in unshifted, it would be a second first-level heading and an outline a screen reader cannot navigate. idPrefix namespaces the generated ids so two documents on one page cannot both claim #installing, and slugify is exported for exactly this: the table of contents arrives at the same ids without reading them back off the DOM.
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
Task lists are not in the built-in grammar, so a bracketed x arrives as literal text and nothing else happens — quiet rather than broken, which is the shape every unsupported feature takes here. parse is the seam: any function from a string to MarkdownNode values, so it takes a whole pipeline (markdown-it, remark, an AST you already have) or, as here, a rewrite of the one thing the grammar does not know before the parser that ships handles the rest.
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 加的是那个看得见的站外箭头,默认关闭。
- 格式不对或者空的字符串渲染成什么都没有,而不是抛错——读者写出来的内容,本来就常常是这样。