@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.
Files changed (70) hide show
  1. package/README.md +6 -4
  2. package/package.json +20 -8
  3. package/src/client/block.ts +70 -0
  4. package/src/client/code/code-block.ts +54 -0
  5. package/src/client/code/code-editor.ts +73 -0
  6. package/src/client/code/code-view.ts +22 -0
  7. package/src/{highlight.ts → client/code/highlight.ts} +2 -2
  8. package/src/{outputs.ts → client/code/outputs.ts} +23 -36
  9. package/src/client/content-marks.ts +15 -0
  10. package/src/client/element.ts +27 -0
  11. package/src/client/index.ts +40 -0
  12. package/src/client/layout.ts +22 -0
  13. package/src/{markdown.ts → client/markdown/handlers.ts} +22 -116
  14. package/src/client/markdown/hast.ts +20 -0
  15. package/src/client/markdown/layout-handlers.ts +57 -0
  16. package/src/client/markdown/math.ts +10 -0
  17. package/src/client/markdown/mermaid.ts +75 -0
  18. package/src/client/markdown/render.ts +79 -0
  19. package/src/client/markdown/role-mark.ts +9 -0
  20. package/src/client/markdown/tables.ts +49 -0
  21. package/src/client/markdown/tabs.ts +52 -0
  22. package/src/client/markdown/tree-cache.ts +77 -0
  23. package/src/client/operations.ts +9 -0
  24. package/src/client/registry.ts +21 -0
  25. package/src/client/section-context.ts +5 -0
  26. package/src/client/section.ts +30 -0
  27. package/src/client/slidev-compat/click-directives.ts +16 -0
  28. package/src/client/slidev-compat/slide-globals.ts +3 -0
  29. package/src/{style.css → client/style.css} +306 -81
  30. package/src/client/viewer-messages.ts +7 -0
  31. package/src/{plugins.ts → client/viewer-plugins.ts} +3 -2
  32. package/src/client/viewer-state.ts +47 -0
  33. package/src/client/views/actions.ts +52 -0
  34. package/src/client/views/controls.css +44 -0
  35. package/src/client/views/document.ts +12 -0
  36. package/src/client/views/outlets.ts +37 -0
  37. package/src/client/views/overlay.ts +16 -0
  38. package/src/client/views/present.ts +132 -0
  39. package/src/client/views/presenter.ts +76 -0
  40. package/src/client/views/scrolly.ts +4 -0
  41. package/src/client/views/slide.css +8 -0
  42. package/src/client/views/slide.ts +57 -0
  43. package/src/domain/clicks.ts +1 -1
  44. package/src/domain/code-width.ts +11 -0
  45. package/src/domain/document-content.ts +6 -0
  46. package/src/domain/{text.ts → footnotes.ts} +0 -12
  47. package/src/domain/index.ts +5 -4
  48. package/src/domain/live.ts +11 -10
  49. package/src/domain/messages.ts +54 -30
  50. package/src/domain/origin.ts +17 -32
  51. package/src/domain/outputs.ts +27 -0
  52. package/src/domain/steps.ts +2 -1
  53. package/src/block.ts +0 -122
  54. package/src/cards.ts +0 -130
  55. package/src/clicks.ts +0 -124
  56. package/src/code.ts +0 -235
  57. package/src/context.ts +0 -16
  58. package/src/dark.ts +0 -5
  59. package/src/domain/follow.ts +0 -32
  60. package/src/globals.ts +0 -66
  61. package/src/i18n.ts +0 -7
  62. package/src/index.ts +0 -55
  63. package/src/operations.ts +0 -28
  64. package/src/present.ts +0 -327
  65. package/src/registry.ts +0 -81
  66. package/src/rich.ts +0 -155
  67. package/src/section.ts +0 -209
  68. package/src/viewer.ts +0 -116
  69. package/src/views.ts +0 -101
  70. /package/src/{env.d.ts → client/env.d.ts} +0 -0
@@ -1,19 +1,16 @@
1
- import { roleMark } from '@lincyaw/kernel'
2
- import type { IRBlock, MarkdownNode, MdastNode } from '@lincyaw/kernel'
3
- import type { Element, ElementContent, Nodes, Properties, Root, RootContent } from 'hast'
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 { find, html as schema } from 'property-information'
8
- import type { Component, PropType, VNodeChild } from 'vue'
9
- import { computed, defineComponent, h, inject } from 'vue'
10
- import { cardHandlers, isButton, tabEntries, TabSetView } from './cards.ts'
11
- import { CodeView } from './code.ts'
12
- import { itemVisible } from './domain/index.ts'
13
- import { messages } from './i18n.ts'
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
- function element(tagName: string, properties: Properties, children: ElementContent[] = []): Element {
35
- return { type: 'element', tagName, properties, children }
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 mark = (node: { identifier?: unknown, label?: unknown, enumerator?: unknown }) => (node.enumerator !== undefined ? String(node.enumerator) : label(node))
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 = messages().markdown.admonitions
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: mark(node), ariaLabel: messages().markdown.footnote(mark(node)) }, []),
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: mark(node) }, []),
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, decoded(url.slice(1)), url.slice(1)) : reference(el, '', url)
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
- ...cardHandlers(),
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}-${slug(textOf(child))}`
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
+ }