@lincyaw/viewer 1.0.0-rc.0 → 1.0.0-rc.2
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/README.md +6 -4
- package/package.json +20 -8
- package/src/client/block.ts +70 -0
- package/src/client/code/code-block.ts +54 -0
- package/src/client/code/code-editor.ts +73 -0
- package/src/client/code/code-view.ts +22 -0
- package/src/{highlight.ts → client/code/highlight.ts} +2 -2
- package/src/{outputs.ts → client/code/outputs.ts} +23 -36
- package/src/client/content-marks.ts +15 -0
- package/src/client/element.ts +27 -0
- package/src/client/index.ts +40 -0
- package/src/client/layout.ts +22 -0
- package/src/{markdown.ts → client/markdown/handlers.ts} +22 -116
- package/src/client/markdown/hast.ts +20 -0
- package/src/client/markdown/layout-handlers.ts +57 -0
- package/src/client/markdown/math.ts +10 -0
- package/src/client/markdown/mermaid.ts +75 -0
- package/src/client/markdown/render.ts +79 -0
- package/src/client/markdown/role-mark.ts +9 -0
- package/src/client/markdown/tables.ts +49 -0
- package/src/client/markdown/tabs.ts +52 -0
- package/src/client/markdown/tree-cache.ts +77 -0
- package/src/client/operations.ts +9 -0
- package/src/client/registry.ts +21 -0
- package/src/client/section-context.ts +5 -0
- package/src/client/section.ts +30 -0
- package/src/client/slidev-compat/click-directives.ts +16 -0
- package/src/client/slidev-compat/slide-globals.ts +3 -0
- package/src/{style.css → client/style.css} +306 -81
- package/src/client/viewer-messages.ts +7 -0
- package/src/{plugins.ts → client/viewer-plugins.ts} +3 -2
- package/src/client/viewer-state.ts +47 -0
- package/src/client/views/actions.ts +52 -0
- package/src/client/views/controls.css +44 -0
- package/src/client/views/document.ts +12 -0
- package/src/client/views/outlets.ts +37 -0
- package/src/client/views/overlay.ts +16 -0
- package/src/client/views/present.ts +132 -0
- package/src/client/views/presenter.ts +76 -0
- package/src/client/views/scrolly.ts +4 -0
- package/src/client/views/slide.css +8 -0
- package/src/client/views/slide.ts +57 -0
- package/src/domain/clicks.ts +1 -1
- package/src/domain/code-width.ts +11 -0
- package/src/domain/document-content.ts +6 -0
- package/src/domain/{text.ts → footnotes.ts} +0 -12
- package/src/domain/index.ts +5 -4
- package/src/domain/live.ts +11 -10
- package/src/domain/messages.ts +54 -30
- package/src/domain/origin.ts +17 -32
- package/src/domain/outputs.ts +27 -0
- package/src/domain/steps.ts +2 -1
- package/src/block.ts +0 -122
- package/src/cards.ts +0 -130
- package/src/clicks.ts +0 -124
- package/src/code.ts +0 -235
- package/src/context.ts +0 -16
- package/src/dark.ts +0 -5
- package/src/domain/follow.ts +0 -32
- package/src/globals.ts +0 -66
- package/src/i18n.ts +0 -7
- package/src/index.ts +0 -55
- package/src/operations.ts +0 -28
- package/src/present.ts +0 -327
- package/src/registry.ts +0 -81
- package/src/rich.ts +0 -155
- package/src/section.ts +0 -209
- package/src/viewer.ts +0 -116
- package/src/views.ts +0 -101
- /package/src/{env.d.ts → client/env.d.ts} +0 -0
|
@@ -1,19 +1,16 @@
|
|
|
1
|
-
import { roleMark } from '@lincyaw/kernel'
|
|
2
|
-
import type {
|
|
3
|
-
import type { Element, ElementContent, Nodes,
|
|
1
|
+
import { isButton, roleMark, safeDecodeURIComponent, urlSlug } from '@lincyaw/kernel'
|
|
2
|
+
import type { MdastNode } from '@lincyaw/kernel'
|
|
3
|
+
import type { Element, ElementContent, Nodes, Root } from 'hast'
|
|
4
4
|
import { raw } from 'hast-util-raw'
|
|
5
5
|
import type { Handler, Handlers, State } from 'mdast-util-to-hast'
|
|
6
6
|
import { defaultHandlers, toHast } from 'mdast-util-to-hast'
|
|
7
|
-
import {
|
|
8
|
-
import
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import { PmMath, PmMermaid, PmRoleMark } from './rich.ts'
|
|
15
|
-
import { footnoteId, VIEWER } from './viewer.ts'
|
|
16
|
-
import type { Viewer } from './viewer.ts'
|
|
7
|
+
import { viewerMessages } from '../viewer-messages.ts'
|
|
8
|
+
import { footnoteId } from '../viewer-state.ts'
|
|
9
|
+
import type { Viewer } from '../viewer-state.ts'
|
|
10
|
+
import { classed, classes, element, text } from './hast.ts'
|
|
11
|
+
import { layoutHandlers } from './layout-handlers.ts'
|
|
12
|
+
import { tabHandlers } from './tabs.ts'
|
|
13
|
+
import { tableHandler } from './tables.ts'
|
|
17
14
|
|
|
18
15
|
export interface MarkdownLinks {
|
|
19
16
|
footnoteHref?(identifier: string): string
|
|
@@ -31,15 +28,8 @@ export function viewerLinks(viewer: Viewer | undefined): MarkdownLinks {
|
|
|
31
28
|
}
|
|
32
29
|
}
|
|
33
30
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function text(value: string): ElementContent {
|
|
39
|
-
return { type: 'text', value }
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
type ViewerNode =
|
|
31
|
+
/** The nodes the viewer renders with its own components; they pass through the HAST tree untouched. */
|
|
32
|
+
export type ViewerNode =
|
|
43
33
|
| { type: 'pmMath', tex: string, display: boolean, html?: string }
|
|
44
34
|
| { type: 'pmMermaid', code: string }
|
|
45
35
|
| { type: 'pmCode', lang: string, code: string }
|
|
@@ -51,7 +41,7 @@ function viewerNode(node: ViewerNode): ElementContent {
|
|
|
51
41
|
return node as unknown as ElementContent
|
|
52
42
|
}
|
|
53
43
|
|
|
54
|
-
function asViewerNode(node: { type: string }): ViewerNode | undefined {
|
|
44
|
+
export function asViewerNode(node: { type: string }): ViewerNode | undefined {
|
|
55
45
|
return (VIEWER_NODES as string[]).includes(node.type) ? node as ViewerNode : undefined
|
|
56
46
|
}
|
|
57
47
|
|
|
@@ -66,29 +56,12 @@ function isBlock(node: ElementContent): boolean {
|
|
|
66
56
|
return node.type === 'element' && BLOCK_ELEMENTS.has(node.tagName)
|
|
67
57
|
}
|
|
68
58
|
|
|
69
|
-
export function decoded(value: string): string {
|
|
70
|
-
try {
|
|
71
|
-
return decodeURIComponent(value)
|
|
72
|
-
} catch {
|
|
73
|
-
return value
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
59
|
function unknown(state: State, node: MdastNode): ElementContent | undefined {
|
|
78
60
|
if (!node.children) return typeof node.value === 'string' ? text(node.value) : undefined
|
|
79
61
|
const children = state.all(node as never)
|
|
80
62
|
return element(children.some(isBlock) ? 'div' : 'span', {}, children)
|
|
81
63
|
}
|
|
82
64
|
|
|
83
|
-
function classes(node: MdastNode): string[] {
|
|
84
|
-
return typeof node.class === 'string' ? node.class.split(/\s+/).filter(Boolean) : []
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function classed(node: MdastNode, properties: Properties = {}): Properties {
|
|
88
|
-
const names = classes(node)
|
|
89
|
-
return names.length ? { ...properties, className: names } : properties
|
|
90
|
-
}
|
|
91
|
-
|
|
92
65
|
function handlers(links: MarkdownLinks): Handlers {
|
|
93
66
|
const footnoteHref = links.footnoteHref ?? ((identifier: string) => `#${footnoteId(identifier)}`)
|
|
94
67
|
const wrap = (tagName: string, className?: string): Handler => (state, node) =>
|
|
@@ -96,7 +69,7 @@ function handlers(links: MarkdownLinks): Handlers {
|
|
|
96
69
|
const value = (node: { value?: unknown }) => (typeof node.value === 'string' ? node.value : '')
|
|
97
70
|
const identifier = (node: { identifier?: unknown, label?: unknown }) => String(node.identifier ?? node.label)
|
|
98
71
|
const label = (node: { identifier?: unknown, label?: unknown }) => String(node.label ?? node.identifier)
|
|
99
|
-
const
|
|
72
|
+
const footnoteMark = (node: { identifier?: unknown, label?: unknown, enumerator?: unknown }) => (node.enumerator !== undefined ? String(node.enumerator) : label(node))
|
|
100
73
|
const math = (node: MdastNode, display: boolean) => viewerNode({ type: 'pmMath', tex: value(node), display, html: typeof node.html === 'string' ? node.html : undefined })
|
|
101
74
|
const reference = (el: Element, id: string, fallback: string): Element => {
|
|
102
75
|
const target = id ? links.section?.(id) : undefined
|
|
@@ -105,6 +78,7 @@ function handlers(links: MarkdownLinks): Handlers {
|
|
|
105
78
|
return el
|
|
106
79
|
}
|
|
107
80
|
const custom: Record<string, Handler> = {
|
|
81
|
+
table: tableHandler,
|
|
108
82
|
math: (_, node) => math(node, true),
|
|
109
83
|
inlineMath: (_, node) => math(node, false),
|
|
110
84
|
mermaid: (_, node) => viewerNode({ type: 'pmMermaid', code: value(node) }),
|
|
@@ -116,7 +90,7 @@ function handlers(links: MarkdownLinks): Handlers {
|
|
|
116
90
|
loading: 'lazy',
|
|
117
91
|
}),
|
|
118
92
|
admonition(state, node) {
|
|
119
|
-
const names =
|
|
93
|
+
const names = viewerMessages().markdown.admonitions
|
|
120
94
|
const extra = classes(node)
|
|
121
95
|
const kind = typeof node.kind === 'string' ? node.kind : extra.find(c => c in names) ?? 'note'
|
|
122
96
|
const children = state.all(node)
|
|
@@ -126,10 +100,10 @@ function handlers(links: MarkdownLinks): Handlers {
|
|
|
126
100
|
},
|
|
127
101
|
admonitionTitle: wrap('p', 'pm-admonition__title'),
|
|
128
102
|
footnoteReference: (_, node) => element('sup', { className: ['pm-footnote-ref'] }, [
|
|
129
|
-
element('a', { href: footnoteHref(identifier(node)), dataLabel:
|
|
103
|
+
element('a', { href: footnoteHref(identifier(node)), dataLabel: footnoteMark(node), ariaLabel: viewerMessages().markdown.footnote(footnoteMark(node)) }, []),
|
|
130
104
|
]),
|
|
131
105
|
footnoteDefinition: (state, node) => element('div', { id: footnoteId(identifier(node)), className: ['pm-footnote'] }, [
|
|
132
|
-
element('sup', { dataLabel:
|
|
106
|
+
element('sup', { dataLabel: footnoteMark(node) }, []),
|
|
133
107
|
...state.all(node),
|
|
134
108
|
]),
|
|
135
109
|
container: (state, node) => element('figure', { id: typeof node.identifier === 'string' ? node.identifier : undefined, className: ['pm-figure'] }, state.all(node)),
|
|
@@ -141,7 +115,7 @@ function handlers(links: MarkdownLinks): Handlers {
|
|
|
141
115
|
const el = defaultHandlers.link(state, node as never)
|
|
142
116
|
if (isButton(node)) el.properties.className = ['pm-button']
|
|
143
117
|
const url = typeof node.url === 'string' ? node.url : ''
|
|
144
|
-
return url.startsWith('#') ? reference(el,
|
|
118
|
+
return url.startsWith('#') ? reference(el, safeDecodeURIComponent(url.slice(1)), url.slice(1)) : reference(el, '', url)
|
|
145
119
|
},
|
|
146
120
|
keyboard: wrap('kbd'),
|
|
147
121
|
subscript: wrap('sub'),
|
|
@@ -154,7 +128,8 @@ function handlers(links: MarkdownLinks): Handlers {
|
|
|
154
128
|
summary: wrap('summary'),
|
|
155
129
|
div: (state, node) => element('div', classed(node), state.all(node)),
|
|
156
130
|
span: (state, node) => element('span', isButton(node) ? { className: ['pm-button'] } : classed(node), state.all(node)),
|
|
157
|
-
...
|
|
131
|
+
...layoutHandlers(),
|
|
132
|
+
...tabHandlers(),
|
|
158
133
|
presmiumRole(state, node) {
|
|
159
134
|
const children = state.all(node)
|
|
160
135
|
return children.length
|
|
@@ -165,10 +140,6 @@ function handlers(links: MarkdownLinks): Handlers {
|
|
|
165
140
|
return custom as Handlers
|
|
166
141
|
}
|
|
167
142
|
|
|
168
|
-
function slug(text: string): string {
|
|
169
|
-
return text.normalize('NFKC').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '')
|
|
170
|
-
}
|
|
171
|
-
|
|
172
143
|
function textOf(node: ElementContent | Root): string {
|
|
173
144
|
if (node.type === 'text') return node.value
|
|
174
145
|
return 'children' in node ? node.children.map(c => textOf(c as ElementContent)).join('') : ''
|
|
@@ -180,7 +151,7 @@ export function markdownToHast(mdast: MdastNode, prefix: string, links: Markdown
|
|
|
180
151
|
let item = 0
|
|
181
152
|
for (const child of tree.children) {
|
|
182
153
|
if (child.type !== 'element') continue
|
|
183
|
-
if (/^h[1-6]$/.test(child.tagName) && child.properties.id === undefined) child.properties.id = `${prefix}-${
|
|
154
|
+
if (/^h[1-6]$/.test(child.tagName) && child.properties.id === undefined) child.properties.id = `${prefix}-${urlSlug(textOf(child))}`
|
|
184
155
|
if (child.tagName !== 'ul' && child.tagName !== 'ol') continue
|
|
185
156
|
for (const li of child.children) {
|
|
186
157
|
if (li.type === 'element' && li.tagName === 'li') li.properties.dataPmItem = item++
|
|
@@ -188,68 +159,3 @@ export function markdownToHast(mdast: MdastNode, prefix: string, links: Markdown
|
|
|
188
159
|
}
|
|
189
160
|
return tree
|
|
190
161
|
}
|
|
191
|
-
|
|
192
|
-
function attributes(properties: Properties): Record<string, unknown> {
|
|
193
|
-
const attrs: Record<string, unknown> = {}
|
|
194
|
-
for (const [key, value] of Object.entries(properties)) {
|
|
195
|
-
if (value === null || value === undefined || value === false) continue
|
|
196
|
-
const info = find(schema, key)
|
|
197
|
-
attrs[info.attribute] = value === true ? (info.boolean ? true : '') : Array.isArray(value) ? value.join(info.commaSeparated ? ', ' : ' ') : value
|
|
198
|
-
}
|
|
199
|
-
return attrs
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function viewerComponent(node: ViewerNode): [Component, Record<string, unknown>] {
|
|
203
|
-
switch (node.type) {
|
|
204
|
-
case 'pmMath': return [PmMath, { tex: node.tex, display: node.display, html: node.html }]
|
|
205
|
-
case 'pmMermaid': return [PmMermaid, { code: node.code }]
|
|
206
|
-
case 'pmCode': return [CodeView, { lang: node.lang, value: node.code }]
|
|
207
|
-
case 'pmRole': return [PmRoleMark, { mark: node.mark }]
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
export interface HastRenderContext {
|
|
212
|
-
hidden?(item: number): boolean
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const RAW_TEXT = new Set(['script', 'style'])
|
|
216
|
-
|
|
217
|
-
export function hastToVNodes(nodes: readonly (RootContent | ElementContent)[], ctx: HastRenderContext): VNodeChild[] {
|
|
218
|
-
return nodes.map((node): VNodeChild => {
|
|
219
|
-
const own = asViewerNode(node)
|
|
220
|
-
if (own) {
|
|
221
|
-
const [component, props] = viewerComponent(own)
|
|
222
|
-
return h(component, props)
|
|
223
|
-
}
|
|
224
|
-
if (node.type === 'text') return node.value
|
|
225
|
-
if (node.type !== 'element') return null
|
|
226
|
-
const attrs = attributes(node.properties)
|
|
227
|
-
const item = node.properties.dataPmItem
|
|
228
|
-
if (typeof item === 'number' && ctx.hidden?.(item)) attrs.class = [attrs.class, 'pm-hidden']
|
|
229
|
-
if (RAW_TEXT.has(node.tagName)) return h(node.tagName, { ...attrs, innerHTML: node.children.map(c => (c.type === 'text' ? c.value : '')).join('') })
|
|
230
|
-
if (node.properties.dataPmTabs !== undefined) {
|
|
231
|
-
return h(TabSetView, { class: attrs.class, tabs: tabEntries(node, children => hastToVNodes(children, ctx)) })
|
|
232
|
-
}
|
|
233
|
-
return h(node.tagName, attrs, hastToVNodes(node.children, ctx))
|
|
234
|
-
})
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
export const MarkdownView = defineComponent({
|
|
238
|
-
name: 'PmMarkdown',
|
|
239
|
-
props: {
|
|
240
|
-
block: { type: Object as PropType<IRBlock>, required: true },
|
|
241
|
-
step: { type: Number, required: true },
|
|
242
|
-
expanded: Boolean,
|
|
243
|
-
prefix: { type: String, default: '' },
|
|
244
|
-
},
|
|
245
|
-
setup(props) {
|
|
246
|
-
const viewer = inject(VIEWER, undefined)
|
|
247
|
-
const links = viewerLinks(viewer)
|
|
248
|
-
const tree = computed(() => markdownToHast((props.block.node as MarkdownNode).mdast, props.prefix, links))
|
|
249
|
-
return () => {
|
|
250
|
-
const node = props.block.node as MarkdownNode
|
|
251
|
-
const hidden = node.reveal === 'items' && !props.expanded ? (k: number) => !itemVisible(props.block, k, props.step) : undefined
|
|
252
|
-
return hastToVNodes(tree.value.children, { hidden })
|
|
253
|
-
}
|
|
254
|
-
},
|
|
255
|
-
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { MdastNode } from '@lincyaw/kernel'
|
|
2
|
+
import type { Element, ElementContent, Properties } from 'hast'
|
|
3
|
+
|
|
4
|
+
export function element(tagName: string, properties: Properties, children: ElementContent[] = []): Element {
|
|
5
|
+
return { type: 'element', tagName, properties, children }
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function text(value: string): ElementContent {
|
|
9
|
+
return { type: 'text', value }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The classes a MyST node names in its `class` option. */
|
|
13
|
+
export function classes(node: MdastNode): string[] {
|
|
14
|
+
return typeof node.class === 'string' ? node.class.split(/\s+/).filter(Boolean) : []
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function classed(node: MdastNode, properties: Properties = {}): Properties {
|
|
18
|
+
const names = classes(node)
|
|
19
|
+
return names.length ? { ...properties, className: names } : properties
|
|
20
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { MdastNode } from '@lincyaw/kernel'
|
|
2
|
+
import type { ElementContent } from 'hast'
|
|
3
|
+
import type { Handler, State } from 'mdast-util-to-hast'
|
|
4
|
+
import { classes, element } from './hast.ts'
|
|
5
|
+
|
|
6
|
+
const WIDTHS = ['xs', 'sm', 'md', 'lg'] as const
|
|
7
|
+
|
|
8
|
+
function gridColumns(node: MdastNode): number[] {
|
|
9
|
+
const given = Array.isArray(node.columns) ? node.columns.map(Number).filter(n => Number.isInteger(n) && n >= 1) : []
|
|
10
|
+
return WIDTHS.map((_, i) => given[Math.min(i, given.length - 1)] ?? 1)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const style = (values: Record<string, number>) => Object.entries(values).map(([key, value]) => `${key}: ${value}`).join('; ')
|
|
14
|
+
|
|
15
|
+
const grid: Handler = (state, node: MdastNode) => {
|
|
16
|
+
const columns = gridColumns(node)
|
|
17
|
+
const children = state.all(node as never).map((child) => {
|
|
18
|
+
const twelfths = child.type === 'element' ? Number(child.properties.dataPmColumns) : Number.NaN
|
|
19
|
+
if (child.type !== 'element' || !(twelfths >= 1)) return child
|
|
20
|
+
const spans = Object.fromEntries(WIDTHS.map((width, i) => [`--pm-span-${width}`, Math.min(columns[i]!, Math.max(1, Math.round(twelfths * columns[i]! / 12)))]))
|
|
21
|
+
return { ...child, properties: { ...child.properties, style: style(spans) } }
|
|
22
|
+
})
|
|
23
|
+
const widths = Object.fromEntries(WIDTHS.map((width, i) => [`--pm-grid-${width}`, columns[i]!]))
|
|
24
|
+
return element('div', { className: ['pm-grid', ...classes(node)], style: style(widths) }, children)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const gridItem: Handler = (state, node: MdastNode) =>
|
|
28
|
+
element('div', { className: ['pm-grid__item', ...classes(node)], dataPmColumns: typeof node.columns === 'number' ? node.columns : undefined }, state.all(node as never))
|
|
29
|
+
|
|
30
|
+
const card: Handler = (state: State, node: MdastNode) => {
|
|
31
|
+
const items = node.children ?? []
|
|
32
|
+
const part = (type: string) => items.find(child => child.type === type)
|
|
33
|
+
const url = typeof node.url === 'string' && node.url ? node.url : undefined
|
|
34
|
+
const header = part('header')
|
|
35
|
+
const footer = part('footer')
|
|
36
|
+
const title = part('cardTitle')
|
|
37
|
+
const content: ElementContent[] = []
|
|
38
|
+
if (header) content.push(element('header', { className: ['pm-card__header'] }, state.all(header as never)))
|
|
39
|
+
if (title || url) {
|
|
40
|
+
const words: MdastNode[] = title?.children?.length ? title.children : [{ type: 'text', value: url ?? '' }]
|
|
41
|
+
const inner = url ? [state.one({ type: 'link', url, children: words } as never, node as never) as ElementContent] : state.all({ type: 'paragraph', children: words } as never)
|
|
42
|
+
content.push(element('p', { className: ['pm-card__title'] }, inner))
|
|
43
|
+
}
|
|
44
|
+
const body = items.filter(child => child !== header && child !== title && child !== footer)
|
|
45
|
+
content.push(element('div', { className: ['pm-card__body'] }, state.all({ type: 'root', children: body } as never)))
|
|
46
|
+
if (footer) content.push(element('footer', { className: ['pm-card__footer'] }, state.all(footer as never)))
|
|
47
|
+
return element('div', { className: ['pm-card', ...url ? ['pm-card--link'] : [], ...classes(node)] }, content)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The handlers of MyST's grids and cards. */
|
|
51
|
+
export function layoutHandlers(): Record<string, Handler> {
|
|
52
|
+
return {
|
|
53
|
+
'grid': grid,
|
|
54
|
+
'grid-item': gridItem,
|
|
55
|
+
'card': card,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { escapeHtml } from '@lincyaw/kernel'
|
|
3
|
+
import { h } from '../element.ts'
|
|
4
|
+
import 'katex/dist/katex.min.css'
|
|
5
|
+
export function PmMath({ tex, display, html }: { tex: string, display?: boolean, html?: string }) {
|
|
6
|
+
const [rendered, setRendered] = useState<string>()
|
|
7
|
+
useEffect(() => { let active = true; if (!html) void import('katex').then(m => { if (active) setRendered(m.default.renderToString(tex, { displayMode: display, throwOnError: false })) }); return () => { active = false } }, [tex, display, html])
|
|
8
|
+
const output = html ?? rendered
|
|
9
|
+
return h(display ? 'div' : 'span', { className: `pm-math pm-math--${display ? 'display' : 'inline'}`, 'data-rendered': output ? '' : undefined, innerHTML: output ?? escapeHtml(tex) })
|
|
10
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useAppearance } from '@lincyaw/host'
|
|
2
|
+
import { errorMessage, escapeHtml } from '@lincyaw/kernel'
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import { h } from '../element.ts'
|
|
5
|
+
|
|
6
|
+
type Mermaid = typeof import('mermaid').default
|
|
7
|
+
|
|
8
|
+
let mermaid: Promise<Mermaid> | undefined
|
|
9
|
+
let diagrams = 0
|
|
10
|
+
|
|
11
|
+
function loadMermaid(): Promise<Mermaid> {
|
|
12
|
+
if (import.meta.env.SSR) return new Promise(() => {})
|
|
13
|
+
mermaid ??= import('mermaid').then(m => m.default)
|
|
14
|
+
return mermaid
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Mermaid's theme variables from the page's design tokens; undefined when the page has none. */
|
|
18
|
+
function mermaidThemeVariables(dark: boolean): Record<string, string | boolean> | undefined {
|
|
19
|
+
const style = getComputedStyle(document.documentElement)
|
|
20
|
+
const colorToken = (name: string) => style.getPropertyValue(`--pm-color-${name}`).trim()
|
|
21
|
+
const [ink, muted, surface, sunk, line, accent, accentSoft, warn, warnSoft] = ['ink', 'muted', 'surface', 'sunk', 'line', 'accent', 'accent-soft', 'warn', 'warn-soft'].map(colorToken)
|
|
22
|
+
if (!ink || !surface || !accent) return undefined
|
|
23
|
+
return {
|
|
24
|
+
darkMode: dark,
|
|
25
|
+
background: surface,
|
|
26
|
+
fontFamily: style.getPropertyValue('--pm-font-sans').trim() || 'sans-serif',
|
|
27
|
+
textColor: ink,
|
|
28
|
+
lineColor: muted!,
|
|
29
|
+
primaryColor: accentSoft!,
|
|
30
|
+
primaryTextColor: ink,
|
|
31
|
+
primaryBorderColor: accent,
|
|
32
|
+
secondaryColor: sunk!,
|
|
33
|
+
secondaryTextColor: ink,
|
|
34
|
+
secondaryBorderColor: line!,
|
|
35
|
+
tertiaryColor: surface,
|
|
36
|
+
tertiaryTextColor: ink,
|
|
37
|
+
tertiaryBorderColor: line!,
|
|
38
|
+
edgeLabelBackground: surface,
|
|
39
|
+
clusterBkg: sunk!,
|
|
40
|
+
clusterBorder: line!,
|
|
41
|
+
titleColor: ink,
|
|
42
|
+
noteBkgColor: warnSoft!,
|
|
43
|
+
noteTextColor: ink,
|
|
44
|
+
noteBorderColor: warn!,
|
|
45
|
+
actorBkg: accentSoft!,
|
|
46
|
+
actorBorder: accent,
|
|
47
|
+
actorTextColor: ink,
|
|
48
|
+
signalColor: ink,
|
|
49
|
+
signalTextColor: ink,
|
|
50
|
+
labelBoxBkgColor: sunk!,
|
|
51
|
+
labelTextColor: ink,
|
|
52
|
+
loopTextColor: ink,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function PmMermaid({ code }: { code: string }) {
|
|
57
|
+
const [drawn, setDrawn] = useState<{ svg: string, theme: string }>()
|
|
58
|
+
const [failed, setFailed] = useState<string>()
|
|
59
|
+
const { dark } = useAppearance()
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
let active = true
|
|
62
|
+
void (async () => {
|
|
63
|
+
try {
|
|
64
|
+
const library = await loadMermaid()
|
|
65
|
+
const variables = mermaidThemeVariables(dark)
|
|
66
|
+
const appearance = dark ? 'dark' : 'default'
|
|
67
|
+
library.initialize({ startOnLoad: false, securityLevel: 'strict', theme: variables ? 'base' : appearance, ...variables ? { themeVariables: variables } : {}, flowchart: { useMaxWidth: false } })
|
|
68
|
+
const { svg } = await library.render(`pm-mermaid-${++diagrams}`, code)
|
|
69
|
+
if (active) { setDrawn({ svg, theme: appearance }); setFailed(undefined) }
|
|
70
|
+
} catch (error) { if (active) setFailed(errorMessage(error)) }
|
|
71
|
+
})()
|
|
72
|
+
return () => { active = false }
|
|
73
|
+
}, [code, dark])
|
|
74
|
+
return h('div', { className: `pm-mermaid${failed ? ' pm-mermaid--error' : ''}`, 'data-rendered': drawn ? '' : undefined, 'data-theme': drawn?.theme, title: failed, innerHTML: drawn?.svg ?? `<pre>${escapeHtml(code)}</pre>` })
|
|
75
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { blockStepState } from '@lincyaw/kernel'
|
|
2
|
+
import { currentLocale } from '@lincyaw/host'
|
|
3
|
+
import type { IRBlock, MarkdownNode } from '@lincyaw/kernel'
|
|
4
|
+
import type { ElementContent, Properties, RootContent } from 'hast'
|
|
5
|
+
import { find, html as schema } from 'property-information'
|
|
6
|
+
import type { ComponentType, ReactNode } from 'react'
|
|
7
|
+
import { useContext } from 'react'
|
|
8
|
+
import { useStore } from '@lincyaw/host'
|
|
9
|
+
import { h } from '../element.ts'
|
|
10
|
+
import { itemVisible } from '../../domain/index.ts'
|
|
11
|
+
import { CodeView } from '../code/code-view.ts'
|
|
12
|
+
import { VIEWER } from '../viewer-state.ts'
|
|
13
|
+
import { asViewerNode, markdownToHast, viewerLinks } from './handlers.ts'
|
|
14
|
+
import type { ViewerNode } from './handlers.ts'
|
|
15
|
+
import { PmMath } from './math.ts'
|
|
16
|
+
import { PmMermaid } from './mermaid.ts'
|
|
17
|
+
import { PmRoleMark } from './role-mark.ts'
|
|
18
|
+
import { tabEntries, TabSetView } from './tabs.ts'
|
|
19
|
+
import { cachedTree } from './tree-cache.ts'
|
|
20
|
+
|
|
21
|
+
function attributes(properties: Properties): Record<string, unknown> {
|
|
22
|
+
const attrs: Record<string, unknown> = {}
|
|
23
|
+
for (const [key, value] of Object.entries(properties)) {
|
|
24
|
+
if (value === null || value === undefined || value === false) continue
|
|
25
|
+
const info = find(schema, key)
|
|
26
|
+
attrs[info.attribute] = value === true ? (info.boolean ? true : '') : Array.isArray(value) ? value.join(info.commaSeparated ? ', ' : ' ') : value
|
|
27
|
+
}
|
|
28
|
+
return attrs
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function viewerComponent(node: ViewerNode): [ComponentType<any>, Record<string, unknown>] {
|
|
32
|
+
switch (node.type) {
|
|
33
|
+
case 'pmMath': return [PmMath, { tex: node.tex, display: node.display, html: node.html }]
|
|
34
|
+
case 'pmMermaid': return [PmMermaid, { code: node.code }]
|
|
35
|
+
case 'pmCode': return [CodeView, { lang: node.lang, value: node.code }]
|
|
36
|
+
case 'pmRole': return [PmRoleMark, { mark: node.mark }]
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface HastRenderContext {
|
|
41
|
+
hidden?(item: number): boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const RAW_TEXT = new Set(['script', 'style'])
|
|
45
|
+
const VOID_ELEMENTS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'])
|
|
46
|
+
|
|
47
|
+
export function hastToVNodes(nodes: readonly (RootContent | ElementContent)[], ctx: HastRenderContext): ReactNode[] {
|
|
48
|
+
return nodes.map((node): ReactNode => {
|
|
49
|
+
const own = asViewerNode(node)
|
|
50
|
+
if (own) {
|
|
51
|
+
const [component, props] = viewerComponent(own)
|
|
52
|
+
return h(component, props)
|
|
53
|
+
}
|
|
54
|
+
if (node.type === 'text') return node.value
|
|
55
|
+
if (node.type !== 'element') return null
|
|
56
|
+
const attrs = attributes(node.properties)
|
|
57
|
+
const item = node.properties.dataPmItem
|
|
58
|
+
if (typeof item === 'number' && ctx.hidden?.(item)) attrs.class = [attrs.class, 'pm-hidden']
|
|
59
|
+
// HAST represents a void element with an empty children array. React treats
|
|
60
|
+
// even that array as children and refuses img/br/input before mounting.
|
|
61
|
+
if (VOID_ELEMENTS.has(node.tagName)) return h(node.tagName, attrs)
|
|
62
|
+
if (RAW_TEXT.has(node.tagName)) return h(node.tagName, { ...attrs, innerHTML: node.children.map(c => (c.type === 'text' ? c.value : '')).join('') })
|
|
63
|
+
if (node.properties.dataPmTabs !== undefined) {
|
|
64
|
+
return h(TabSetView, { class: attrs.class, tabs: tabEntries(node, children => hastToVNodes(children, ctx)) })
|
|
65
|
+
}
|
|
66
|
+
return h(node.tagName, attrs, hastToVNodes(node.children, ctx))
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function MarkdownView(props: { block: IRBlock, step: number, expanded?: boolean, prefix?: string }) {
|
|
71
|
+
const viewer = useContext(VIEWER)
|
|
72
|
+
const doc = useStore(viewer?.doc)
|
|
73
|
+
const links = viewerLinks(viewer)
|
|
74
|
+
const node = props.block.node as MarkdownNode
|
|
75
|
+
const tree = viewer && doc ? cachedTree(viewer, doc, node.mdast, props.prefix ?? '', links, currentLocale()) : markdownToHast(node.mdast, props.prefix ?? '', links)
|
|
76
|
+
const revealed = blockStepState(props.block, props.step)?.parameters?.revealedItems
|
|
77
|
+
const hidden = node.reveal === 'items' && !props.expanded ? (k: number) => typeof revealed === 'number' ? k >= revealed : !itemVisible(props.block, k, props.step) : undefined
|
|
78
|
+
return h('div', { className: 'pm-markdown' }, hastToVNodes(tree.children, { hidden }))
|
|
79
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { AUTHOR_MARKS_CLASS } from '@lincyaw/kernel'
|
|
3
|
+
import { useSectionContext } from '../section-context.ts'
|
|
4
|
+
import { h } from '../element.ts'
|
|
5
|
+
export function PmRoleMark({ mark }: { mark: string }) {
|
|
6
|
+
const section = useSectionContext(); const [shown, setShown] = useState(false)
|
|
7
|
+
useEffect(() => { const root = document.documentElement; const update = () => setShown(root.classList.contains(AUTHOR_MARKS_CLASS)); update(); const observer = new MutationObserver(update); observer.observe(root, { attributes: true, attributeFilter: ['class'] }); return () => observer.disconnect() }, [])
|
|
8
|
+
return shown || section?.view === 'presenter' ? h('span', { className: 'pm-role-mark' }, mark) : null
|
|
9
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { MdastNode } from '@lincyaw/kernel'
|
|
2
|
+
import type { Element } from 'hast'
|
|
3
|
+
import type { Handler, State } from 'mdast-util-to-hast'
|
|
4
|
+
import { element } from './hast.ts'
|
|
5
|
+
|
|
6
|
+
function sourceElement(state: State, node: MdastNode, result: Element): Element {
|
|
7
|
+
if (typeof node.identifier === 'string') result.properties.id = node.identifier
|
|
8
|
+
state.patch(node as never, result)
|
|
9
|
+
return state.applyData(node as never, result)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Preserve the canonical logical table grid instead of GFM's first-row-only model. */
|
|
13
|
+
export const tableHandler: Handler = (state, node: MdastNode) => {
|
|
14
|
+
const rows = node.children ?? []
|
|
15
|
+
const headerRows = typeof node.headerRows === 'number' ? node.headerRows : Math.min(1, rows.length)
|
|
16
|
+
const headerColumns = typeof node.headerColumns === 'number' ? node.headerColumns : 0
|
|
17
|
+
let spans: { from: number, to: number, until: number }[] = []
|
|
18
|
+
let crossesHeader = false
|
|
19
|
+
const rendered = rows.map((row, rowIndex) => {
|
|
20
|
+
spans = spans.filter(span => span.until > rowIndex).sort((a, b) => a.from - b.from)
|
|
21
|
+
let column = 0, index = 0
|
|
22
|
+
const added: typeof spans = []
|
|
23
|
+
const cells = (row.children ?? []).map(cell => {
|
|
24
|
+
const colspan = typeof cell.colspan === 'number' ? cell.colspan : 1
|
|
25
|
+
const rowspan = typeof cell.rowspan === 'number' ? cell.rowspan : 1
|
|
26
|
+
while (spans[index] && spans[index]!.from <= column) { column = Math.max(column, spans[index]!.to); index++ }
|
|
27
|
+
const columnHeader = rowIndex < headerRows, rowHeader = column < headerColumns
|
|
28
|
+
const result = sourceElement(state, cell, element(columnHeader || rowHeader ? 'th' : 'td', {
|
|
29
|
+
...colspan > 1 ? { colSpan: colspan } : {},
|
|
30
|
+
...rowspan > 1 ? { rowSpan: rowspan } : {},
|
|
31
|
+
...columnHeader ? { scope: colspan > 1 ? 'colgroup' : 'col' } : rowHeader ? { scope: rowspan > 1 ? 'rowgroup' : 'row' } : {},
|
|
32
|
+
}, state.all(cell as never)))
|
|
33
|
+
if (rowspan > 1) added.push({ from: column, to: column + colspan, until: rowIndex + rowspan })
|
|
34
|
+
if (rowIndex < headerRows && rowIndex + rowspan > headerRows) crossesHeader = true
|
|
35
|
+
column += colspan
|
|
36
|
+
return result
|
|
37
|
+
})
|
|
38
|
+
spans.push(...added)
|
|
39
|
+
return sourceElement(state, row, element('tr', {}, cells))
|
|
40
|
+
})
|
|
41
|
+
const widths = Array.isArray(node.columnWidths) ? node.columnWidths : []
|
|
42
|
+
const columns = widths.length ? [element('colgroup', {}, widths.map(width => element('col', { style: `width:${Number(width)}px` })))] : []
|
|
43
|
+
// HTML spans cannot cross row-group boundaries. Keep those tables in one group,
|
|
44
|
+
// with each header cell's scope retaining the authored header semantics.
|
|
45
|
+
const groups = headerRows > 0 && !crossesHeader
|
|
46
|
+
? [element('thead', {}, rendered.slice(0, headerRows)), ...rendered.length > headerRows ? [element('tbody', {}, rendered.slice(headerRows))] : []]
|
|
47
|
+
: [element('tbody', {}, rendered)]
|
|
48
|
+
return sourceElement(state, node, element('table', {}, [...columns, ...groups]))
|
|
49
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import * as Tabs from '@radix-ui/react-tabs'
|
|
2
|
+
import { createStore, useStore } from '@lincyaw/host'
|
|
3
|
+
import type { MdastNode } from '@lincyaw/kernel'
|
|
4
|
+
import type { Element, ElementContent } from 'hast'
|
|
5
|
+
import type { Handler } from 'mdast-util-to-hast'
|
|
6
|
+
import type { ReactNode } from 'react'
|
|
7
|
+
import { useEffect, useState } from 'react'
|
|
8
|
+
import { h } from '../element.ts'
|
|
9
|
+
import { classes, element } from './hast.ts'
|
|
10
|
+
|
|
11
|
+
const tabSet: Handler = (state, node: MdastNode) => {
|
|
12
|
+
const panels = (node.children ?? []).filter(child => child.type === 'tabItem').map(item => element('div', {
|
|
13
|
+
className: ['pm-tabs__panel'],
|
|
14
|
+
dataTitle: typeof item.title === 'string' ? item.title : '',
|
|
15
|
+
dataSync: typeof item.sync === 'string' && item.sync ? item.sync : undefined,
|
|
16
|
+
dataSelected: item.selected === true ? 'true' : undefined,
|
|
17
|
+
}, state.all(item as never)))
|
|
18
|
+
return element('div', { className: ['pm-tabs', ...classes(node)], dataPmTabs: '' }, panels)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The handlers of MyST's tab sets: a tab set becomes an element that `hastToVNodes` renders as `TabSetView`. */
|
|
22
|
+
export function tabHandlers(): Record<string, Handler> {
|
|
23
|
+
return {
|
|
24
|
+
tabSet,
|
|
25
|
+
tabItem: (state, node) => element('div', {}, state.all(node)),
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TabEntry {
|
|
30
|
+
title: string
|
|
31
|
+
sync?: string
|
|
32
|
+
selected: boolean
|
|
33
|
+
content: () => ReactNode[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function tabEntries(set: Element, render: (nodes: ElementContent[]) => ReactNode[]): TabEntry[] {
|
|
37
|
+
return set.children.flatMap((panel): TabEntry[] => panel.type !== 'element' ? [] : [{
|
|
38
|
+
title: String(panel.properties.dataTitle ?? ''),
|
|
39
|
+
sync: typeof panel.properties.dataSync === 'string' ? panel.properties.dataSync : undefined,
|
|
40
|
+
selected: panel.properties.dataSelected === 'true',
|
|
41
|
+
content: () => render(panel.children),
|
|
42
|
+
}])
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const synced = createStore<string | undefined>(undefined)
|
|
46
|
+
export function TabSetView({ tabs, className }: { tabs: TabEntry[], className?: string }) {
|
|
47
|
+
const key = useStore(synced)
|
|
48
|
+
const [active, setActive] = useState(() => Math.max(0, tabs.findIndex(tab => tab.selected)))
|
|
49
|
+
useEffect(() => { const index = tabs.findIndex(tab => tab.sync && tab.sync === key); if (index >= 0) setActive(index) }, [key, tabs])
|
|
50
|
+
const choose = (value: string) => { const index = Number(value); setActive(index); const key = tabs[index]?.sync; if (key) synced.set(key) }
|
|
51
|
+
return h(Tabs.Root, { value: String(active), onValueChange: choose, className }, [h(Tabs.List, { className: 'pm-tabs__list' }, tabs.map((tab, k) => h(Tabs.Trigger, { key: k, value: String(k), className: 'pm-tabs__tab' }, tab.title))), ...tabs.map((tab, k) => h(Tabs.Content, { key: k, value: String(k), className: 'pm-tabs__panel', forceMount: true, hidden: k !== active, tabIndex: 0 }, tab.content()))])
|
|
52
|
+
}
|