@anyknown/ui 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/card/Card.d.ts +6 -1
- package/dist/components/card/Card.js +2 -2
- package/dist/components/markdown/Formula.d.ts +18 -0
- package/dist/components/markdown/Formula.js +75 -0
- package/dist/components/markdown/Markdown.d.ts +30 -0
- package/dist/components/markdown/Markdown.js +223 -0
- package/dist/components/markdown/math.d.ts +10 -0
- package/dist/components/markdown/math.js +86 -0
- package/dist/components/text/Text.d.ts +3 -1
- package/dist/components/text/Text.js +2 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/package.json +5 -4
|
@@ -1,2 +1,7 @@
|
|
|
1
1
|
import type { ComponentProps } from "react";
|
|
2
|
-
|
|
2
|
+
import { type StyleArg } from "../../lib/styled.js";
|
|
3
|
+
type CardProps = ComponentProps<"div"> & {
|
|
4
|
+
sx?: StyleArg;
|
|
5
|
+
};
|
|
6
|
+
export declare function Card({ sx, ...props }: CardProps): import("react").JSX.Element;
|
|
7
|
+
export {};
|
|
@@ -12,6 +12,6 @@ const styles = stylex.create({
|
|
|
12
12
|
padding: space.lg,
|
|
13
13
|
},
|
|
14
14
|
});
|
|
15
|
-
export function Card(props) {
|
|
16
|
-
return _jsx("div", { ...props, ...styled(props, styles.base) });
|
|
15
|
+
export function Card({ sx, ...props }) {
|
|
16
|
+
return _jsx("div", { ...props, ...styled(props, styles.base, sx) });
|
|
17
17
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type StyleArg } from "../../lib/styled.js";
|
|
2
|
+
export type FormulaProps = {
|
|
3
|
+
children: string;
|
|
4
|
+
display?: boolean;
|
|
5
|
+
sx?: StyleArg;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* TeX → MathML, rendered by the browser itself.
|
|
9
|
+
*
|
|
10
|
+
* Temml emits MathML rather than a pile of positioned spans, which is why this component needs no
|
|
11
|
+
* stylesheet and no web font — the two things a KaTeX integration would push onto every consumer
|
|
12
|
+
* of this package. It also means a screen reader gets the actual maths, not a div soup.
|
|
13
|
+
*
|
|
14
|
+
* Temml writes into the node directly (`temml.render`), so nothing here ever hands a string to
|
|
15
|
+
* `dangerouslySetInnerHTML` — this package does not use it anywhere and maths is not the place to
|
|
16
|
+
* start, given the TeX usually arrives from a language model.
|
|
17
|
+
*/
|
|
18
|
+
export declare function Formula({ children, display, sx }: FormulaProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import * as stylex from "@stylexjs/stylex";
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
import { styled } from "../../lib/styled.js";
|
|
5
|
+
import { color, space, text } from "../../tokens.stylex.js";
|
|
6
|
+
const styles = stylex.create({
|
|
7
|
+
// Display maths is the one block that routinely overflows a phone: a long equation cannot be
|
|
8
|
+
// re-wrapped the way a paragraph can, so it gets its own scroller rather than widening the page.
|
|
9
|
+
block: {
|
|
10
|
+
display: "block",
|
|
11
|
+
overflowX: "auto",
|
|
12
|
+
overflowY: "hidden",
|
|
13
|
+
paddingBlock: space.xs,
|
|
14
|
+
marginBlock: space.xs,
|
|
15
|
+
color: color.text,
|
|
16
|
+
},
|
|
17
|
+
inline: {
|
|
18
|
+
display: "inline-block",
|
|
19
|
+
maxWidth: "100%",
|
|
20
|
+
overflowX: "auto",
|
|
21
|
+
verticalAlign: "middle",
|
|
22
|
+
color: color.text,
|
|
23
|
+
},
|
|
24
|
+
// What a caller sees when the TeX itself is wrong. Showing the source beats showing nothing:
|
|
25
|
+
// the reader can still read the formula, and it is obvious who to blame.
|
|
26
|
+
error: {
|
|
27
|
+
fontFamily: "ui-monospace, monospace",
|
|
28
|
+
fontSize: text.xs,
|
|
29
|
+
color: color.danger,
|
|
30
|
+
overflowWrap: "anywhere",
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
/**
|
|
34
|
+
* TeX → MathML, rendered by the browser itself.
|
|
35
|
+
*
|
|
36
|
+
* Temml emits MathML rather than a pile of positioned spans, which is why this component needs no
|
|
37
|
+
* stylesheet and no web font — the two things a KaTeX integration would push onto every consumer
|
|
38
|
+
* of this package. It also means a screen reader gets the actual maths, not a div soup.
|
|
39
|
+
*
|
|
40
|
+
* Temml writes into the node directly (`temml.render`), so nothing here ever hands a string to
|
|
41
|
+
* `dangerouslySetInnerHTML` — this package does not use it anywhere and maths is not the place to
|
|
42
|
+
* start, given the TeX usually arrives from a language model.
|
|
43
|
+
*/
|
|
44
|
+
export function Formula({ children, display = false, sx }) {
|
|
45
|
+
const host = useRef(null);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
const node = host.current;
|
|
48
|
+
if (!node)
|
|
49
|
+
return;
|
|
50
|
+
let live = true;
|
|
51
|
+
// Temml is ~250KB and most messages contain no maths at all, so it arrives only when the
|
|
52
|
+
// first formula does.
|
|
53
|
+
void import("temml")
|
|
54
|
+
.then((temml) => {
|
|
55
|
+
if (!live || !host.current)
|
|
56
|
+
return;
|
|
57
|
+
temml.default.render(children, host.current, {
|
|
58
|
+
displayMode: display,
|
|
59
|
+
// `throwOnError: false` renders the offending source in red instead of blowing up the
|
|
60
|
+
// whole message; `trust: false` (the default) keeps \href and friends inert.
|
|
61
|
+
throwOnError: false,
|
|
62
|
+
errorColor: "currentColor",
|
|
63
|
+
});
|
|
64
|
+
})
|
|
65
|
+
// Nothing about one formula is worth taking a message down for. `throwOnError` covers bad
|
|
66
|
+
// TeX, this covers everything else: a chunk that fails to load, or a DOM that will not
|
|
67
|
+
// take MathML at all (jsdom builds those elements without a `style`, so Temml throws
|
|
68
|
+
// there and only there). The source TeX is already on screen and simply stays.
|
|
69
|
+
.catch(() => { });
|
|
70
|
+
return () => {
|
|
71
|
+
live = false;
|
|
72
|
+
};
|
|
73
|
+
}, [children, display]);
|
|
74
|
+
return (_jsx("span", { ref: host, ...styled({}, display ? styles.block : styles.inline, sx), ...(display ? { role: "math" } : {}), children: _jsx("span", { ...stylex.props(styles.error), children: children }) }));
|
|
75
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
2
|
+
import { type StyleArg } from "../../lib/styled.js";
|
|
3
|
+
/** A fenced block a shell wants to draw itself — mermaid, a chart spec, anything. */
|
|
4
|
+
export type MarkdownBlock = {
|
|
5
|
+
lang: string;
|
|
6
|
+
code: string;
|
|
7
|
+
};
|
|
8
|
+
export type MarkdownProps = Omit<ComponentProps<"div">, "children"> & {
|
|
9
|
+
children: string;
|
|
10
|
+
/**
|
|
11
|
+
* Draws a fenced block instead of the built-in code block. Returning `undefined` (or not
|
|
12
|
+
* passing this at all) falls back to the code block, which is why a shell that has no mermaid
|
|
13
|
+
* bundled still shows the source rather than nothing.
|
|
14
|
+
*
|
|
15
|
+
* This package deliberately does not depend on any diagram library: mermaid alone unpacks to
|
|
16
|
+
* ~84MB, which no design system should push onto every app that installs it.
|
|
17
|
+
*/
|
|
18
|
+
renderBlock?: (block: MarkdownBlock) => ReactNode | undefined;
|
|
19
|
+
/** Passed through to code blocks, so a shell can localise their copy button. */
|
|
20
|
+
copyLabel?: string;
|
|
21
|
+
copiedLabel?: string;
|
|
22
|
+
sx?: StyleArg;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Markdown as a conversation renders it: GFM tables, fenced code, TeX maths, task lists.
|
|
26
|
+
*
|
|
27
|
+
* Nothing here goes through `innerHTML`. The markdown is tokenised and every token is turned into
|
|
28
|
+
* a React element, so a model that emits `<script>` gets its source shown, not run.
|
|
29
|
+
*/
|
|
30
|
+
export declare function Markdown({ children, renderBlock, copyLabel, copiedLabel, sx, ...rest }: MarkdownProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import * as stylex from "@stylexjs/stylex";
|
|
3
|
+
import { Marked } from "marked";
|
|
4
|
+
import { styled } from "../../lib/styled.js";
|
|
5
|
+
import { color, radius, space, text } from "../../tokens.stylex.js";
|
|
6
|
+
import { Checkbox } from "../checkbox/Checkbox.js";
|
|
7
|
+
import { CodeBlock, InlineCode } from "../code-block/CodeBlock.js";
|
|
8
|
+
import { Formula } from "./Formula.js";
|
|
9
|
+
import { isMathToken, mathExtensions } from "./math.js";
|
|
10
|
+
// Its own Marked instance, not the module-level `marked`: `marked.use()` is global, and a design
|
|
11
|
+
// system must not reach into whatever else the host app parses.
|
|
12
|
+
//
|
|
13
|
+
// `breaks: true` because this renders what a person or a model just typed into a conversation. In
|
|
14
|
+
// a document a lone newline is a space; in a chat message it is a line the writer meant to break.
|
|
15
|
+
const parser = new Marked({ gfm: true, breaks: true }).use({ extensions: mathExtensions });
|
|
16
|
+
const styles = stylex.create({
|
|
17
|
+
root: {
|
|
18
|
+
display: "grid",
|
|
19
|
+
gap: space.sm,
|
|
20
|
+
// Set, never inherited. Every other component in this package states its own colour and
|
|
21
|
+
// size for the same reason: a host whose text colour is wrong (or missing, as when a page
|
|
22
|
+
// forgets tokens.css) would otherwise paint this black on a dark ground.
|
|
23
|
+
color: color.text,
|
|
24
|
+
fontSize: text.sm,
|
|
25
|
+
lineHeight: text.leadingRelaxed,
|
|
26
|
+
// Model output is full of things with no spaces in them — URLs, file paths, hashes. On a
|
|
27
|
+
// 390px screen any one of them would otherwise push the whole conversation sideways.
|
|
28
|
+
overflowWrap: "anywhere",
|
|
29
|
+
minWidth: 0,
|
|
30
|
+
},
|
|
31
|
+
p: { margin: 0, lineHeight: text.leadingNormal },
|
|
32
|
+
heading: { margin: 0, lineHeight: text.leadingSnug, fontWeight: 600 },
|
|
33
|
+
h1: { fontSize: text.lg },
|
|
34
|
+
// h2 and below are body-sized and carry their weight instead: these are headings inside one
|
|
35
|
+
// chat message, not a page outline, and a message that shouts three sizes at you reads worse.
|
|
36
|
+
h2: { fontSize: text.base },
|
|
37
|
+
h3: { fontSize: text.base },
|
|
38
|
+
list: { margin: 0, paddingInlineStart: "1.5em", display: "grid", gap: space.xxs },
|
|
39
|
+
item: { lineHeight: text.leadingNormal },
|
|
40
|
+
// The item's own text follows it on the same line, so the box must not take the whole row.
|
|
41
|
+
task: { display: "inline-flex", marginInlineEnd: space.xxs, verticalAlign: "middle" },
|
|
42
|
+
quote: {
|
|
43
|
+
margin: 0,
|
|
44
|
+
paddingInlineStart: space.sm,
|
|
45
|
+
borderInlineStartWidth: 2,
|
|
46
|
+
borderInlineStartStyle: "solid",
|
|
47
|
+
borderInlineStartColor: color.border,
|
|
48
|
+
color: color.textMuted,
|
|
49
|
+
display: "grid",
|
|
50
|
+
gap: space.xs,
|
|
51
|
+
},
|
|
52
|
+
rule: {
|
|
53
|
+
border: "none",
|
|
54
|
+
borderBlockStartWidth: 1,
|
|
55
|
+
borderBlockStartStyle: "solid",
|
|
56
|
+
borderBlockStartColor: color.border,
|
|
57
|
+
margin: 0,
|
|
58
|
+
},
|
|
59
|
+
link: { color: color.accent, textUnderlineOffset: "0.15em" },
|
|
60
|
+
image: { maxWidth: "100%", height: "auto", borderRadius: radius.md },
|
|
61
|
+
// The table itself must not shrink to fit; the wrapper scrolls instead. A squashed table on a
|
|
62
|
+
// phone is unreadable, a scrolling one is merely narrow.
|
|
63
|
+
tableWrap: { overflowX: "auto", maxWidth: "100%" },
|
|
64
|
+
// Sized by its own content, not stretched to the message width: a two-column table pulled out
|
|
65
|
+
// to full width puts its cells at opposite ends of the screen and is harder to read, not easier.
|
|
66
|
+
table: { borderCollapse: "collapse" },
|
|
67
|
+
cell: {
|
|
68
|
+
borderWidth: 1,
|
|
69
|
+
borderStyle: "solid",
|
|
70
|
+
borderColor: color.border,
|
|
71
|
+
paddingBlock: space.xxs,
|
|
72
|
+
paddingInline: space.xs,
|
|
73
|
+
textAlign: "start",
|
|
74
|
+
verticalAlign: "top",
|
|
75
|
+
},
|
|
76
|
+
head: { backgroundColor: color.surface, fontWeight: 600 },
|
|
77
|
+
});
|
|
78
|
+
const ALIGN = { left: "start", center: "center", right: "end" };
|
|
79
|
+
function inlineTokens(token) {
|
|
80
|
+
return token.tokens;
|
|
81
|
+
}
|
|
82
|
+
function renderInline(tokens, context, keyPrefix) {
|
|
83
|
+
if (!tokens)
|
|
84
|
+
return null;
|
|
85
|
+
return tokens.map((token, index) => {
|
|
86
|
+
const key = `${keyPrefix}.${index}`;
|
|
87
|
+
if (isMathToken(token)) {
|
|
88
|
+
return (_jsx(Formula, { display: token.display, children: token.text }, key));
|
|
89
|
+
}
|
|
90
|
+
switch (token.type) {
|
|
91
|
+
case "text":
|
|
92
|
+
case "escape": {
|
|
93
|
+
const nested = inlineTokens(token);
|
|
94
|
+
// A `text` token carries children only when it contains inline markup; otherwise the
|
|
95
|
+
// string itself is the whole of it.
|
|
96
|
+
return nested ? (_jsx("span", { children: renderInline(nested, context, key) }, key)) : (token.text);
|
|
97
|
+
}
|
|
98
|
+
case "strong":
|
|
99
|
+
return _jsx("strong", { children: renderInline(inlineTokens(token), context, key) }, key);
|
|
100
|
+
case "em":
|
|
101
|
+
return _jsx("em", { children: renderInline(inlineTokens(token), context, key) }, key);
|
|
102
|
+
case "del":
|
|
103
|
+
return _jsx("del", { children: renderInline(inlineTokens(token), context, key) }, key);
|
|
104
|
+
case "codespan":
|
|
105
|
+
return _jsx(InlineCode, { children: token.text }, key);
|
|
106
|
+
case "br":
|
|
107
|
+
return _jsx("br", {}, key);
|
|
108
|
+
// The list draws the box itself; this token is the source `[x]` and would double it.
|
|
109
|
+
case "checkbox":
|
|
110
|
+
return null;
|
|
111
|
+
case "link": {
|
|
112
|
+
const link = token;
|
|
113
|
+
return (_jsx("a", { href: link.href, title: link.title ?? undefined, target: "_blank",
|
|
114
|
+
// Model output is untrusted: never hand a new tab a live `window.opener`.
|
|
115
|
+
rel: "noopener noreferrer nofollow", ...stylex.props(styles.link), children: renderInline(inlineTokens(token), context, key) }, key));
|
|
116
|
+
}
|
|
117
|
+
case "image": {
|
|
118
|
+
const image = token;
|
|
119
|
+
return (_jsx("img", { src: image.href, alt: image.text, title: image.title ?? undefined, loading: "lazy", ...stylex.props(styles.image) }, key));
|
|
120
|
+
}
|
|
121
|
+
// `html` inline: shown as text, never parsed. See renderBlocks.
|
|
122
|
+
case "html":
|
|
123
|
+
return _jsx("span", { children: token.raw }, key);
|
|
124
|
+
default:
|
|
125
|
+
return _jsx("span", { children: token.raw ?? "" }, key);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* A list item's children are a mix: its own sentence arrives as inline tokens, but a nested list
|
|
131
|
+
* or fenced block is block-level. Sending the whole lot to one renderer is what flattened
|
|
132
|
+
* "巢狀:\n 1. 第一\n 2. 第二" into a single line.
|
|
133
|
+
*/
|
|
134
|
+
function renderItem(item, context, key) {
|
|
135
|
+
return item.tokens.map((token, index) => {
|
|
136
|
+
const childKey = `${key}.${index}`;
|
|
137
|
+
return token.type === "text" || token.type === "checkbox"
|
|
138
|
+
? renderInline([token], context, childKey)
|
|
139
|
+
: renderBlocks([token], context, childKey);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
function renderList(token, context, key) {
|
|
143
|
+
const Tag = token.ordered ? "ol" : "ul";
|
|
144
|
+
return (_jsx(Tag, { ...(token.ordered && token.start !== "" ? { start: token.start } : {}), ...stylex.props(styles.list), children: token.items.map((item, index) => (_jsxs("li", { ...stylex.props(styles.item), children: [item.task && (
|
|
145
|
+
// The design system's own box, not a native <input>: that one paints itself in the
|
|
146
|
+
// OS accent colour and ignores the theme entirely.
|
|
147
|
+
_jsx(Checkbox, { checked: item.checked ?? false, disabled: true, readOnly: true, "aria-label": item.checked ? "已完成" : "未完成", sx: styles.task })), renderItem(item, context, `${key}.${index}`)] }, `${key}.${index}`))) }, key));
|
|
148
|
+
}
|
|
149
|
+
function renderTable(token, context, key) {
|
|
150
|
+
return (_jsx("div", { ...stylex.props(styles.tableWrap), children: _jsxs("table", { ...stylex.props(styles.table), children: [_jsx("thead", { children: _jsx("tr", { children: token.header.map((cell, index) => (_jsx("th", { ...stylex.props(styles.cell, styles.head), style: { textAlign: ALIGN[token.align[index] ?? "left"] }, children: renderInline(cell.tokens, context, `${key}.h.${index}`) }, index))) }) }), _jsx("tbody", { children: token.rows.map((row, rowIndex) => (_jsx("tr", { children: row.map((cell, index) => (_jsx("td", { ...stylex.props(styles.cell), style: { textAlign: ALIGN[token.align[index] ?? "left"] }, children: renderInline(cell.tokens, context, `${key}.${rowIndex}.${index}`) }, index))) }, rowIndex))) })] }) }, key));
|
|
151
|
+
}
|
|
152
|
+
function renderBlocks(tokens, context, keyPrefix = "b") {
|
|
153
|
+
const out = [];
|
|
154
|
+
tokens.forEach((token, index) => {
|
|
155
|
+
const key = `${keyPrefix}.${index}`;
|
|
156
|
+
if (isMathToken(token)) {
|
|
157
|
+
out.push(_jsx(Formula, { display: true, children: token.text }, key));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
switch (token.type) {
|
|
161
|
+
case "space":
|
|
162
|
+
return;
|
|
163
|
+
case "paragraph":
|
|
164
|
+
out.push(_jsx("p", { ...stylex.props(styles.p), children: renderInline(inlineTokens(token), context, key) }, key));
|
|
165
|
+
return;
|
|
166
|
+
case "heading": {
|
|
167
|
+
const heading = token;
|
|
168
|
+
const level = headingLevel(heading.depth);
|
|
169
|
+
const Tag = `h${level}`;
|
|
170
|
+
out.push(_jsx(Tag, { ...stylex.props(styles.heading, styles[Tag]), children: renderInline(inlineTokens(token), context, key) }, key));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
case "code": {
|
|
174
|
+
const block = token;
|
|
175
|
+
const lang = (block.lang ?? "").trim().split(/\s+/)[0] ?? "";
|
|
176
|
+
const custom = context.renderBlock?.({ lang, code: block.text });
|
|
177
|
+
out.push(custom !== undefined && custom !== null ? (_jsx("div", { children: custom }, key)) : (_jsx(CodeBlock, { code: block.text, ...(lang ? { lang } : {}), ...(context.copyLabel ? { copyLabel: context.copyLabel } : {}), ...(context.copiedLabel ? { copiedLabel: context.copiedLabel } : {}) }, key)));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
case "table":
|
|
181
|
+
out.push(renderTable(token, context, key));
|
|
182
|
+
return;
|
|
183
|
+
case "list":
|
|
184
|
+
out.push(renderList(token, context, key));
|
|
185
|
+
return;
|
|
186
|
+
case "blockquote":
|
|
187
|
+
out.push(_jsx("blockquote", { ...stylex.props(styles.quote), children: renderBlocks(inlineTokens(token) ?? [], context, key) }, key));
|
|
188
|
+
return;
|
|
189
|
+
case "hr":
|
|
190
|
+
out.push(_jsx("hr", { ...stylex.props(styles.rule) }, key));
|
|
191
|
+
return;
|
|
192
|
+
case "html":
|
|
193
|
+
// Never parsed, never injected — the source is shown as code. Markdown from a model is
|
|
194
|
+
// untrusted input and this package has no sanitiser to lean on, so `<script>` is
|
|
195
|
+
// something to display, not something to run.
|
|
196
|
+
out.push(_jsx(CodeBlock, { lang: "html", code: token.raw.trimEnd(), ...(context.copyLabel ? { copyLabel: context.copyLabel } : {}), ...(context.copiedLabel ? { copiedLabel: context.copiedLabel } : {}) }, key));
|
|
197
|
+
return;
|
|
198
|
+
case "text": {
|
|
199
|
+
const nested = inlineTokens(token);
|
|
200
|
+
out.push(_jsx("p", { ...stylex.props(styles.p), children: nested ? renderInline(nested, context, key) : token.text }, key));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
// `def` is a link reference definition: it defines, it does not render.
|
|
204
|
+
default:
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
/** Headings inside a message never go past h3; deeper ones would be smaller than the body. */
|
|
211
|
+
function headingLevel(depth) {
|
|
212
|
+
return depth <= 1 ? 1 : depth === 2 ? 2 : 3;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Markdown as a conversation renders it: GFM tables, fenced code, TeX maths, task lists.
|
|
216
|
+
*
|
|
217
|
+
* Nothing here goes through `innerHTML`. The markdown is tokenised and every token is turned into
|
|
218
|
+
* a React element, so a model that emits `<script>` gets its source shown, not run.
|
|
219
|
+
*/
|
|
220
|
+
export function Markdown({ children, renderBlock, copyLabel, copiedLabel, sx, ...rest }) {
|
|
221
|
+
const tokens = parser.lexer(children);
|
|
222
|
+
return (_jsx("div", { ...rest, ...styled(rest, styles.root, sx), children: renderBlocks(tokens, { renderBlock, copyLabel, copiedLabel }) }));
|
|
223
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { TokenizerAndRendererExtension, Tokens } from "marked";
|
|
2
|
+
export type MathToken = Tokens.Generic & {
|
|
3
|
+
type: "math";
|
|
4
|
+
text: string;
|
|
5
|
+
display: boolean;
|
|
6
|
+
};
|
|
7
|
+
export declare const mathExtensions: TokenizerAndRendererExtension[];
|
|
8
|
+
export declare function isMathToken(token: {
|
|
9
|
+
type: string;
|
|
10
|
+
}): token is MathToken;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// 數學是 marked 沒有的語法,所以走 extension 自己 tokenize。兩種寫法都收:$…$ / $$…$$ 是
|
|
2
|
+
// TeX 的慣例,\( … \) / \[ … \] 是 LaTeX 自己的,兩種模型都會吐。
|
|
3
|
+
//
|
|
4
|
+
// `$` 的難處在它同時是錢:「$5 到 $10」不是公式。所以開頭的 `$` 後面不能是空白,結尾的
|
|
5
|
+
// `$` 前面不能是空白、後面不能接數字——GitHub 與 KaTeX 的 auto-render 用的是同一組條件。
|
|
6
|
+
const INLINE_DOLLAR = /^\$(?![\s$])((?:\\.|[^\n$\\])+?)(?<!\s)\$(?!\d)/;
|
|
7
|
+
const INLINE_PAREN = /^\\\(([\s\S]+?)\\\)/;
|
|
8
|
+
const BLOCK_DOLLAR = /^\$\$([\s\S]+?)\$\$/;
|
|
9
|
+
const BLOCK_BRACKET = /^\\\[([\s\S]+?)\\\]/;
|
|
10
|
+
function first(src, ...patterns) {
|
|
11
|
+
for (const pattern of patterns) {
|
|
12
|
+
const match = pattern.exec(src);
|
|
13
|
+
if (match)
|
|
14
|
+
return match;
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Where the next real match starts.
|
|
20
|
+
*
|
|
21
|
+
* This has to be exact, not merely "the next `$`". marked splits the surrounding text token at
|
|
22
|
+
* whatever index `start` names, and with `breaks: true` a fragment that is left as trailing
|
|
23
|
+
* whitespace before the split becomes a `<br>` — so pointing at a `$` that turns out to be a
|
|
24
|
+
* dollar sign inserted line breaks into "從 $5 漲到 $10".
|
|
25
|
+
*/
|
|
26
|
+
function startWith(...patterns) {
|
|
27
|
+
return (src) => {
|
|
28
|
+
let at = src.search(/[$\\]/);
|
|
29
|
+
while (at !== -1) {
|
|
30
|
+
if (first(src.slice(at), ...patterns))
|
|
31
|
+
return at;
|
|
32
|
+
const next = src.slice(at + 1).search(/[$\\]/);
|
|
33
|
+
if (next === -1)
|
|
34
|
+
return undefined;
|
|
35
|
+
at += 1 + next;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
// The block hint must ignore inline maths. `start` is where marked *cuts*, and a block-level
|
|
41
|
+
// extension cuts the paragraph itself — so pointing at the `$` in "行內數學 $E = mc^2$" split that
|
|
42
|
+
// sentence in half and left the two pieces as separate lines.
|
|
43
|
+
const startBlock = startWith(BLOCK_DOLLAR, BLOCK_BRACKET);
|
|
44
|
+
const startInline = startWith(BLOCK_DOLLAR, BLOCK_BRACKET, INLINE_DOLLAR, INLINE_PAREN);
|
|
45
|
+
export const mathExtensions = [
|
|
46
|
+
{
|
|
47
|
+
name: "math",
|
|
48
|
+
level: "block",
|
|
49
|
+
start: startBlock,
|
|
50
|
+
tokenizer(src) {
|
|
51
|
+
const match = first(src, BLOCK_DOLLAR, BLOCK_BRACKET);
|
|
52
|
+
if (!match)
|
|
53
|
+
return undefined;
|
|
54
|
+
const token = {
|
|
55
|
+
type: "math",
|
|
56
|
+
raw: match[0],
|
|
57
|
+
text: (match[1] ?? "").trim(),
|
|
58
|
+
display: true,
|
|
59
|
+
};
|
|
60
|
+
return token;
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "inlineMath",
|
|
65
|
+
level: "inline",
|
|
66
|
+
start: startInline,
|
|
67
|
+
tokenizer(src) {
|
|
68
|
+
// A `$$…$$` that sits inside a paragraph is still display maths; the block tokenizer
|
|
69
|
+
// only ever sees it when it starts its own line.
|
|
70
|
+
const block = first(src, BLOCK_DOLLAR, BLOCK_BRACKET);
|
|
71
|
+
const match = block ?? first(src, INLINE_DOLLAR, INLINE_PAREN);
|
|
72
|
+
if (!match)
|
|
73
|
+
return undefined;
|
|
74
|
+
const token = {
|
|
75
|
+
type: "math",
|
|
76
|
+
raw: match[0],
|
|
77
|
+
text: (match[1] ?? "").trim(),
|
|
78
|
+
display: block !== null,
|
|
79
|
+
};
|
|
80
|
+
return token;
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
export function isMathToken(token) {
|
|
85
|
+
return token.type === "math";
|
|
86
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { ComponentProps, ElementType } from "react";
|
|
2
|
+
import { type StyleArg } from "../../lib/styled.js";
|
|
2
3
|
type TextProps = ComponentProps<"p"> & {
|
|
3
4
|
as?: ElementType;
|
|
4
5
|
variant?: "display" | "title" | "body" | "caption" | "mono";
|
|
6
|
+
sx?: StyleArg;
|
|
5
7
|
};
|
|
6
|
-
export declare function Text({ as: Tag, variant, ...props }: TextProps): import("react").JSX.Element;
|
|
8
|
+
export declare function Text({ as: Tag, variant, sx, ...props }: TextProps): import("react").JSX.Element;
|
|
7
9
|
export {};
|
|
@@ -26,6 +26,6 @@ const styles = stylex.create({
|
|
|
26
26
|
caption: { fontSize: text.sm, color: color.textMuted },
|
|
27
27
|
mono: { fontFamily: font.mono, fontSize: text.sm },
|
|
28
28
|
});
|
|
29
|
-
export function Text({ as: Tag = "p", variant = "body", ...props }) {
|
|
30
|
-
return _jsx(Tag, { ...props, ...styled(props, styles.base, styles[variant]) });
|
|
29
|
+
export function Text({ as: Tag = "p", variant = "body", sx, ...props }) {
|
|
30
|
+
return _jsx(Tag, { ...props, ...styled(props, styles.base, styles[variant], sx) });
|
|
31
31
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -53,3 +53,5 @@ export type { FileRowProps, FileListProps, FileItem, FileRowAction } from "./com
|
|
|
53
53
|
export { DiffViewer, type DiffViewerProps, type DiffFile } from "./components/diff-viewer/DiffViewer.js";
|
|
54
54
|
export { DataTable } from "./components/data-table/DataTable.js";
|
|
55
55
|
export type { DataTableProps, DataTableColumn, SortState } from "./components/data-table/DataTable.js";
|
|
56
|
+
export { Markdown, type MarkdownProps, type MarkdownBlock } from "./components/markdown/Markdown.js";
|
|
57
|
+
export { Formula, type FormulaProps } from "./components/markdown/Formula.js";
|
package/dist/index.js
CHANGED
|
@@ -36,3 +36,5 @@ export { Dropzone, UploadList } from "./components/dropzone/Dropzone.js";
|
|
|
36
36
|
export { FileRow, FileList } from "./components/file-row/FileRow.js";
|
|
37
37
|
export { DiffViewer } from "./components/diff-viewer/DiffViewer.js";
|
|
38
38
|
export { DataTable } from "./components/data-table/DataTable.js";
|
|
39
|
+
export { Markdown } from "./components/markdown/Markdown.js";
|
|
40
|
+
export { Formula } from "./components/markdown/Formula.js";
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anyknown/ui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "AnyKnown design system — StyleX tokens, themes, and
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "AnyKnown design system — StyleX tokens, themes, and 36 components",
|
|
5
5
|
"homepage": "https://ui.anyknown.com",
|
|
6
6
|
"bugs": {
|
|
7
7
|
"url": "https://github.com/anyknown-com/ui/issues"
|
|
@@ -56,7 +56,9 @@
|
|
|
56
56
|
"dependencies": {
|
|
57
57
|
"@base-ui/react": "^1.7.0",
|
|
58
58
|
"@stylexjs/stylex": "^0.19.0",
|
|
59
|
-
"diff": "^9.0.0"
|
|
59
|
+
"diff": "^9.0.0",
|
|
60
|
+
"marked": "^18.0.11",
|
|
61
|
+
"temml": "^0.13.5"
|
|
60
62
|
},
|
|
61
63
|
"devDependencies": {
|
|
62
64
|
"@babel/core": "^8.0.1",
|
|
@@ -73,7 +75,6 @@
|
|
|
73
75
|
"@vitest/coverage-v8": "^4.1.11",
|
|
74
76
|
"autoprefixer": "^10.5.4",
|
|
75
77
|
"jsdom": "^30.0.1",
|
|
76
|
-
"marked": "^18.0.11",
|
|
77
78
|
"oxfmt": "^0.65.0",
|
|
78
79
|
"oxlint": "^1.80.0",
|
|
79
80
|
"postcss": "^8.5.26",
|