@lincyaw/viewer 0.0.0-stage → 1.0.0-rc.1

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 (54) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +31 -2
  3. package/package.json +65 -3
  4. package/src/client/block.ts +70 -0
  5. package/src/client/code/code-block.ts +54 -0
  6. package/src/client/code/code-editor.ts +73 -0
  7. package/src/client/code/code-view.ts +22 -0
  8. package/src/client/code/highlight.ts +73 -0
  9. package/src/client/code/outputs.ts +84 -0
  10. package/src/client/content-marks.ts +15 -0
  11. package/src/client/element.ts +27 -0
  12. package/src/client/env.d.ts +3 -0
  13. package/src/client/index.ts +40 -0
  14. package/src/client/layout.ts +22 -0
  15. package/src/client/markdown/handlers.ts +161 -0
  16. package/src/client/markdown/hast.ts +20 -0
  17. package/src/client/markdown/layout-handlers.ts +57 -0
  18. package/src/client/markdown/math.ts +10 -0
  19. package/src/client/markdown/mermaid.ts +75 -0
  20. package/src/client/markdown/render.ts +79 -0
  21. package/src/client/markdown/role-mark.ts +9 -0
  22. package/src/client/markdown/tables.ts +49 -0
  23. package/src/client/markdown/tabs.ts +52 -0
  24. package/src/client/markdown/tree-cache.ts +77 -0
  25. package/src/client/operations.ts +9 -0
  26. package/src/client/registry.ts +21 -0
  27. package/src/client/section-context.ts +5 -0
  28. package/src/client/section.ts +30 -0
  29. package/src/client/slidev-compat/click-directives.ts +16 -0
  30. package/src/client/slidev-compat/slide-globals.ts +3 -0
  31. package/src/client/style.css +1058 -0
  32. package/src/client/viewer-messages.ts +7 -0
  33. package/src/client/viewer-plugins.ts +35 -0
  34. package/src/client/viewer-state.ts +47 -0
  35. package/src/client/views/actions.ts +52 -0
  36. package/src/client/views/controls.css +44 -0
  37. package/src/client/views/document.ts +12 -0
  38. package/src/client/views/outlets.ts +37 -0
  39. package/src/client/views/overlay.ts +16 -0
  40. package/src/client/views/present.ts +132 -0
  41. package/src/client/views/presenter.ts +76 -0
  42. package/src/client/views/scrolly.ts +4 -0
  43. package/src/client/views/slide.css +8 -0
  44. package/src/client/views/slide.ts +57 -0
  45. package/src/domain/clicks.ts +38 -0
  46. package/src/domain/code-width.ts +11 -0
  47. package/src/domain/document-content.ts +6 -0
  48. package/src/domain/footnotes.ts +15 -0
  49. package/src/domain/index.ts +22 -0
  50. package/src/domain/live.ts +44 -0
  51. package/src/domain/messages.ts +139 -0
  52. package/src/domain/origin.ts +78 -0
  53. package/src/domain/outputs.ts +27 -0
  54. package/src/domain/steps.ts +46 -0
@@ -0,0 +1,84 @@
1
+ import { currentLocale } from '@lincyaw/host'
2
+ import { htmlAsText, isProseRun } from '@lincyaw/kernel'
3
+ import type { MdastNode, RunOutput, RunResult } from '@lincyaw/kernel'
4
+ import { AnsiUp } from 'ansi_up'
5
+ import { fromMarkdown } from 'mdast-util-from-markdown'
6
+ import { gfmFromMarkdown } from 'mdast-util-gfm'
7
+ import { gfm } from 'micromark-extension-gfm'
8
+ import type { ReactElement, ReactNode } from 'react'
9
+ import { useEffect, useState } from 'react'
10
+ import { h } from '../element.ts'
11
+ import { outputNotes } from '../../domain/index.ts'
12
+ import { markdownToHast } from '../markdown/handlers.ts'
13
+ import { PmMath } from '../markdown/math.ts'
14
+ import { hastToVNodes } from '../markdown/render.ts'
15
+ import { findNodeRenderer } from '../registry.ts'
16
+
17
+ const ORDER = ['text/html', 'text/markdown', 'text/latex', 'image/svg+xml', 'image/png', 'image/jpeg', 'image/gif', 'application/json', 'text/plain']
18
+
19
+ function text(value: unknown): string {
20
+ return Array.isArray(value) ? value.join('') : typeof value === 'string' ? value : JSON.stringify(value, null, 2)
21
+ }
22
+
23
+ function ansi(value: string): string {
24
+ return new AnsiUp().ansi_to_html(value)
25
+ }
26
+
27
+ function markdownOutput(markdown: string, prefix: string): ReactNode[] {
28
+ const mdast = htmlAsText(fromMarkdown(markdown, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }) as unknown as MdastNode)
29
+ return hastToVNodes(markdownToHast(mdast, prefix).children, {})
30
+ }
31
+
32
+ let purify: Promise<(html: string) => string> | undefined
33
+
34
+ function HtmlOutput({ html, plain = '' }: { html: string, plain?: string }) {
35
+ const [clean, setClean] = useState<string>()
36
+ useEffect(() => { let active = true; purify ??= import('dompurify').then(m => (html: string) => m.default.sanitize(html)); void purify.then(sanitize => { if (active) setClean(sanitize(html)) }); return () => { active = false } }, [html])
37
+ return clean === undefined ? h('pre', { className: 'pm-output__text' }, plain) : h('div', { className: 'pm-output__html', innerHTML: clean })
38
+ }
39
+
40
+ function display(data: Record<string, unknown>, metadata: Record<string, unknown>, prefix: string, key: number): ReactNode {
41
+ for (const type of Object.keys(data)) {
42
+ const renderer = findNodeRenderer(`mime:${type}`)
43
+ if (renderer) return h(renderer, { key, type, data: data[type], metadata })
44
+ }
45
+ const type = ORDER.find(t => data[t] !== undefined)
46
+ if (!type) return null
47
+ const value = data[type]
48
+ const attrs = { 'key': key, 'class': 'pm-output__display', 'data-mime': type }
49
+ if (type === 'text/html') return h('div', attrs, [h(HtmlOutput, { html: text(value), plain: text(data['text/plain'] ?? '') })])
50
+ if (type === 'text/markdown') return h('div', { ...attrs, class: ['pm-output__display', 'pm-output__markdown'] }, markdownOutput(text(value), prefix))
51
+ if (type === 'text/latex') return h('div', attrs, [h(PmMath, { tex: text(value).trim().replace(/^\$\$?|\$\$?$/g, ''), display: true })])
52
+ if (type === 'image/svg+xml') return h('div', attrs, [h('img', { src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(text(value))}`, alt: '' })])
53
+ if (type.startsWith('image/')) return h('div', attrs, [h('img', { src: `data:${type};base64,${text(value).replace(/\s/g, '')}`, alt: '' })])
54
+ if (type === 'application/json') return h('pre', attrs, JSON.stringify(value, null, 2))
55
+ return h('pre', { ...attrs, innerHTML: ansi(text(value)) })
56
+ }
57
+
58
+ function richOutput(output: RunOutput, prefix: string, key: number): ReactNode {
59
+ switch (output.output_type) {
60
+ case 'stream': return h('pre', { key, class: `pm-output__${output.name}`, innerHTML: ansi(text(output.text)) })
61
+ case 'error': return h('pre', { key, class: 'pm-output__error', innerHTML: ansi(text(output.traceback.join('\n')) || `${output.ename}: ${output.evalue}`) })
62
+ default: return display(output.data, output.metadata ?? {}, prefix, key)
63
+ }
64
+ }
65
+
66
+ /** Whether a run only displays Markdown that no plugin renders itself, which shows as prose. */
67
+ export function isProseOutput(run: RunResult): boolean {
68
+ return isProseRun(run, type => Boolean(findNodeRenderer(`mime:${type}`)))
69
+ }
70
+
71
+ export function runOutput(run: RunResult & { cacheKey?: string }, key: string | number, prefix: string, options: { prose?: boolean, note?: string } = {}): ReactElement {
72
+ const notes = [...outputNotes(run, currentLocale()), ...options.note ? [options.note] : []]
73
+ const content = run.outputs
74
+ ? run.outputs.map((output, i) => richOutput(output, prefix, i))
75
+ : [
76
+ run.stdout ? h('pre', { class: 'pm-output__stdout' }, run.stdout) : null,
77
+ run.stderr ? h('pre', { class: 'pm-output__stderr' }, run.stderr) : null,
78
+ ]
79
+ if (options.prose) return h('div', { 'key': key, 'class': 'pm-output pm-output--prose', 'data-executor': run.executorId }, content)
80
+ return h('div', { 'key': key, 'class': ['pm-output', { 'pm-output--failed': !run.ok }], 'data-executor': run.executorId }, [
81
+ ...content,
82
+ notes.length ? h('p', { class: 'pm-output__note' }, notes.join(' · ')) : null,
83
+ ])
84
+ }
@@ -0,0 +1,15 @@
1
+ import { PmIcon } from '@lincyaw/host'
2
+ import type { IRSection } from '@lincyaw/kernel'
3
+ import { h } from './element.ts'
4
+ import { viewerMessages } from './viewer-messages.ts'
5
+
6
+ /** Author controls are excluded by the preview's text reader and are hidden outside document editing. */
7
+ export function contentMarks(section: IRSection, role: 'visual' | 'narration' | 'reading', at: number, span = 0) {
8
+ const label = viewerMessages().contentRoles[role]
9
+ const place = `${section.index} · ${at + 1}${span > 0 ? `–${at + span + 1}` : ''}`
10
+ return h('span', { class: 'pm-content-marks', contenteditable: 'false', 'data-preview-ui': '' }, [h('button', {
11
+ 'type': 'button', 'class': 'pm-content-mark',
12
+ 'data-content-section': section.id, 'data-content-step': at,
13
+ 'title': `${label} · ${place}`, 'aria-label': `${label} · ${place}`,
14
+ }, [h(PmIcon, { name: role === 'visual' ? 'slides' : role === 'reading' ? 'notebook' : 'captions' }), h('span', place)])])
15
+ }
@@ -0,0 +1,27 @@
1
+ import { createElement, isValidElement } from 'react'
2
+ import type { ElementType, ReactElement, ReactNode } from 'react'
3
+ import styleToJs from 'style-to-js'
4
+ export function classNames(value: unknown): string | undefined {
5
+ if (!value) return undefined
6
+ if (typeof value === 'string') return value
7
+ if (Array.isArray(value)) return value.map(classNames).filter(Boolean).join(' ')
8
+ if (typeof value === 'object') return Object.entries(value).filter(([, active]) => active).map(([key]) => key).join(' ')
9
+ return undefined
10
+ }
11
+ /** Shared DOM attribute normalization for HAST and the viewer's generated elements. */
12
+ export function h(type: ElementType | any, attrs?: Record<string, any> | ReactNode | null, children?: ReactNode, ...rest: ReactNode[]): ReactElement {
13
+ if (attrs !== null && attrs !== undefined && (typeof attrs !== 'object' || Array.isArray(attrs) || isValidElement(attrs))) return createElement(type, null, attrs as ReactNode, children, ...rest)
14
+ const props = { ...attrs as Record<string, any> }
15
+ if (typeof props.style === 'string') {
16
+ try { props.style = styleToJs(props.style, { reactCompat: true }) }
17
+ catch {
18
+ props.style = {}
19
+ console.warn('[presmium.viewer.style] Invalid inline style was ignored; content is preserved.')
20
+ }
21
+ }
22
+ if (props.class !== undefined) { props.className = classNames(props.class); delete props.class }
23
+ if (props.innerHTML !== undefined) { props.dangerouslySetInnerHTML = { __html: props.innerHTML }; delete props.innerHTML }
24
+ if (props.tabindex !== undefined) { props.tabIndex = props.tabindex; delete props.tabindex }
25
+ if (props.contenteditable !== undefined) { props.contentEditable = props.contenteditable; delete props.contenteditable }
26
+ return createElement(type, props, children, ...rest)
27
+ }
@@ -0,0 +1,3 @@
1
+ interface ImportMetaEnv {
2
+ SSR: boolean
3
+ }
@@ -0,0 +1,40 @@
1
+ import { createElement } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import { registerAction, registerComponent, registerView } from './registry.ts'
4
+ import { DOCUMENT_LAYOUT_COMPONENTS } from './layout.ts'
5
+ import { viewerMessages } from './viewer-messages.ts'
6
+ import { VIEWER } from './viewer-state.ts'
7
+ import type { Viewer } from './viewer-state.ts'
8
+ import { BUILTIN_ACTIONS } from './views/actions.ts'
9
+ import { DocumentView } from './views/document.ts'
10
+ import { PresentView } from './views/present.ts'
11
+ import { PresenterView } from './views/presenter.ts'
12
+ import { ScrollyView } from './views/scrolly.ts'
13
+ import './style.css'
14
+
15
+ registerView({ id: 'document', get label() { return viewerMessages().views.document }, kind: 'page', component: DocumentView })
16
+ registerView({ id: 'scrolly', get label() { return viewerMessages().views.scrolly }, kind: 'page', component: ScrollyView })
17
+ registerView({ id: 'present', get label() { return viewerMessages().views.present }, kind: 'overlay', component: PresentView })
18
+ registerView({ id: 'presenter', get label() { return viewerMessages().views.presenter }, kind: 'overlay', component: PresenterView, hidden: true })
19
+ BUILTIN_ACTIONS.forEach(registerAction)
20
+ Object.entries(DOCUMENT_LAYOUT_COMPONENTS).forEach(([name, component]) => registerComponent(name, component))
21
+
22
+ export function ViewerProvider({ viewer, children }: { viewer: Viewer, children?: ReactNode }) { return createElement(VIEWER.Provider, { value: viewer }, children) }
23
+
24
+ export { BlockView, BlockPreview, PmComponentFallback } from './block.ts'
25
+ export { syncViewerWithOperations } from './operations.ts'
26
+ export { findView, findComponent, listViews, registerComponent, registryRevision } from './registry.ts'
27
+ export type { NodeRenderer, ViewDefinition, ViewerAction, ViewerLayer } from './registry.ts'
28
+ export { SectionView } from './section.ts'
29
+ export type { SectionLayout } from './section.ts'
30
+ export { useSectionContext } from './section-context.ts'
31
+ export type { SectionContext } from './section-context.ts'
32
+ export { withSlideGlobals } from './slidev-compat/slide-globals.ts'
33
+ export { tryInstallViewerPlugin } from './viewer-plugins.ts'
34
+ export type { ViewerPlugin, ViewerPluginContext } from './viewer-plugins.ts'
35
+ export { createViewer, useViewer } from './viewer-state.ts'
36
+ export type { Viewer, ViewerOptions } from './viewer-state.ts'
37
+ export { OverlayViewOutlet, PageViewOutlet } from './views/outlets.ts'
38
+ export { SlideFace, SlideFit, SlidesPrint } from './views/slide.ts'
39
+
40
+ export { VClick, VAfter, VClicks } from "./slidev-compat/click-directives.ts"
@@ -0,0 +1,22 @@
1
+ import { Children, createElement as h, isValidElement } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import { PmColumns, PmStack, PmTabGroup, useMessages, useStaticRender } from '@lincyaw/host'
4
+ import type { ContentLayoutProps } from '@lincyaw/host'
5
+ import { blockVisible } from '@lincyaw/kernel'
6
+ import type { BlockProps } from './block.ts'
7
+ import { FINAL_STEP } from '../domain/index.ts'
8
+ import { useSectionContext } from './section-context.ts'
9
+
10
+ const MESSAGES = { 'zh-CN': { tabs: '分组内容', item: (index: number) => `内容 ${index}` }, en: { tabs: 'Grouped content', item: (index: number) => `Content ${index}` } }
11
+ function LayoutTabs({ title, tabTitles, children }: ContentLayoutProps & { tabTitles?: string[] }) {
12
+ const t = useMessages(MESSAGES), printed = useStaticRender(), section = useSectionContext()
13
+ const tabs = Children.toArray(children).map((content, index) => {
14
+ const props = isValidElement<BlockProps>(content) ? content.props : undefined
15
+ return { id: props?.block?.id ?? String(index), title: tabTitles?.[index] || t.item(index + 1), content,
16
+ visible: !props?.block || props.expanded || blockVisible(props.block, section?.step ?? FINAL_STEP) }
17
+ })
18
+ return h(PmTabGroup, { title, label: t.tabs, tabs, staticView: printed })
19
+ }
20
+
21
+ /** Layout nodes are part of the document runtime and need no separately installed plugin. */
22
+ export const DOCUMENT_LAYOUT_COMPONENTS = { PmColumns, PmStack, PmTabs: LayoutTabs }
@@ -0,0 +1,161 @@
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
+ import { raw } from 'hast-util-raw'
5
+ import type { Handler, Handlers, State } from 'mdast-util-to-hast'
6
+ import { defaultHandlers, toHast } from 'mdast-util-to-hast'
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'
14
+
15
+ export interface MarkdownLinks {
16
+ footnoteHref?(identifier: string): string
17
+ section?(id: string): { href: string, title: string } | undefined
18
+ }
19
+
20
+ export function viewerLinks(viewer: Viewer | undefined): MarkdownLinks {
21
+ if (!viewer) return {}
22
+ return {
23
+ footnoteHref: viewer.footnoteHref,
24
+ section(id) {
25
+ const section = viewer.section(id)
26
+ return section && { href: viewer.options.sectionHref?.(id) ?? `#${id}`, title: section.title }
27
+ },
28
+ }
29
+ }
30
+
31
+ /** The nodes the viewer renders with its own components; they pass through the HAST tree untouched. */
32
+ export type ViewerNode =
33
+ | { type: 'pmMath', tex: string, display: boolean, html?: string }
34
+ | { type: 'pmMermaid', code: string }
35
+ | { type: 'pmCode', lang: string, code: string }
36
+ | { type: 'pmRole', mark: string }
37
+
38
+ const VIEWER_NODES: ViewerNode['type'][] = ['pmMath', 'pmMermaid', 'pmCode', 'pmRole']
39
+
40
+ function viewerNode(node: ViewerNode): ElementContent {
41
+ return node as unknown as ElementContent
42
+ }
43
+
44
+ export function asViewerNode(node: { type: string }): ViewerNode | undefined {
45
+ return (VIEWER_NODES as string[]).includes(node.type) ? node as ViewerNode : undefined
46
+ }
47
+
48
+ const BLOCK_ELEMENTS = new Set([
49
+ 'address', 'article', 'aside', 'blockquote', 'details', 'div', 'dl', 'dt', 'dd', 'fieldset', 'figcaption', 'figure', 'footer',
50
+ 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hr', 'iframe', 'li', 'main', 'nav', 'ol', 'p', 'pre', 'section', 'table', 'ul',
51
+ ])
52
+
53
+ function isBlock(node: ElementContent): boolean {
54
+ const own = asViewerNode(node)
55
+ if (own) return own.type === 'pmMermaid' || own.type === 'pmCode' || (own.type === 'pmMath' && own.display)
56
+ return node.type === 'element' && BLOCK_ELEMENTS.has(node.tagName)
57
+ }
58
+
59
+ function unknown(state: State, node: MdastNode): ElementContent | undefined {
60
+ if (!node.children) return typeof node.value === 'string' ? text(node.value) : undefined
61
+ const children = state.all(node as never)
62
+ return element(children.some(isBlock) ? 'div' : 'span', {}, children)
63
+ }
64
+
65
+ function handlers(links: MarkdownLinks): Handlers {
66
+ const footnoteHref = links.footnoteHref ?? ((identifier: string) => `#${footnoteId(identifier)}`)
67
+ const wrap = (tagName: string, className?: string): Handler => (state, node) =>
68
+ element(tagName, className ? { className: [className] } : {}, state.all(node))
69
+ const value = (node: { value?: unknown }) => (typeof node.value === 'string' ? node.value : '')
70
+ const identifier = (node: { identifier?: unknown, label?: unknown }) => String(node.identifier ?? node.label)
71
+ const label = (node: { identifier?: unknown, label?: unknown }) => String(node.label ?? node.identifier)
72
+ const footnoteMark = (node: { identifier?: unknown, label?: unknown, enumerator?: unknown }) => (node.enumerator !== undefined ? String(node.enumerator) : label(node))
73
+ const math = (node: MdastNode, display: boolean) => viewerNode({ type: 'pmMath', tex: value(node), display, html: typeof node.html === 'string' ? node.html : undefined })
74
+ const reference = (el: Element, id: string, fallback: string): Element => {
75
+ const target = id ? links.section?.(id) : undefined
76
+ if (target) el.properties.href = target.href
77
+ if (el.children.length === 0) el.children = [text(target?.title ?? fallback)]
78
+ return el
79
+ }
80
+ const custom: Record<string, Handler> = {
81
+ table: tableHandler,
82
+ math: (_, node) => math(node, true),
83
+ inlineMath: (_, node) => math(node, false),
84
+ mermaid: (_, node) => viewerNode({ type: 'pmMermaid', code: value(node) }),
85
+ code: (_, node) => viewerNode({ type: 'pmCode', lang: typeof node.lang === 'string' ? node.lang : '', code: value(node) }),
86
+ iframe: (_, node) => element('iframe', {
87
+ src: typeof node.src === 'string' ? node.src : '',
88
+ width: node.width == null || node.width === '' ? undefined : String(node.width),
89
+ height: node.height == null || node.height === '' ? undefined : String(node.height),
90
+ loading: 'lazy',
91
+ }),
92
+ admonition(state, node) {
93
+ const names = viewerMessages().markdown.admonitions
94
+ const extra = classes(node)
95
+ const kind = typeof node.kind === 'string' ? node.kind : extra.find(c => c in names) ?? 'note'
96
+ const children = state.all(node)
97
+ const titled = (node.children as MdastNode[] | undefined)?.some(c => c.type === 'admonitionTitle')
98
+ const title = titled ? [] : [element('p', { className: ['pm-admonition__title'] }, [text(names[kind] ?? kind)])]
99
+ return element('aside', { className: [...new Set(['pm-admonition', `pm-admonition--${kind}`, ...extra])] }, [...title, ...children])
100
+ },
101
+ admonitionTitle: wrap('p', 'pm-admonition__title'),
102
+ footnoteReference: (_, node) => element('sup', { className: ['pm-footnote-ref'] }, [
103
+ element('a', { href: footnoteHref(identifier(node)), dataLabel: footnoteMark(node), ariaLabel: viewerMessages().markdown.footnote(footnoteMark(node)) }, []),
104
+ ]),
105
+ footnoteDefinition: (state, node) => element('div', { id: footnoteId(identifier(node)), className: ['pm-footnote'] }, [
106
+ element('sup', { dataLabel: footnoteMark(node) }, []),
107
+ ...state.all(node),
108
+ ]),
109
+ container: (state, node) => element('figure', { id: typeof node.identifier === 'string' ? node.identifier : undefined, className: ['pm-figure'] }, state.all(node)),
110
+ caption: wrap('figcaption'),
111
+ legend: wrap('div', 'pm-legend'),
112
+ abbreviation: (state, node) => element('abbr', typeof node.title === 'string' ? { title: node.title } : {}, state.all(node)),
113
+ crossReference: (state, node) => reference(element('a', { href: `#${identifier(node)}` }, state.all(node)), identifier(node), label(node)),
114
+ link(state, node) {
115
+ const el = defaultHandlers.link(state, node as never)
116
+ if (isButton(node)) el.properties.className = ['pm-button']
117
+ const url = typeof node.url === 'string' ? node.url : ''
118
+ return url.startsWith('#') ? reference(el, safeDecodeURIComponent(url.slice(1)), url.slice(1)) : reference(el, '', url)
119
+ },
120
+ keyboard: wrap('kbd'),
121
+ subscript: wrap('sub'),
122
+ superscript: wrap('sup'),
123
+ underline: wrap('u'),
124
+ definitionList: wrap('dl'),
125
+ definitionTerm: wrap('dt'),
126
+ definitionDescription: wrap('dd'),
127
+ details: (state, node) => element('details', classed(node, { open: node.open === true }), state.all(node)),
128
+ summary: wrap('summary'),
129
+ div: (state, node) => element('div', classed(node), state.all(node)),
130
+ span: (state, node) => element('span', isButton(node) ? { className: ['pm-button'] } : classed(node), state.all(node)),
131
+ ...layoutHandlers(),
132
+ ...tabHandlers(),
133
+ presmiumRole(state, node) {
134
+ const children = state.all(node)
135
+ return children.length
136
+ ? element('span', { className: ['pm-role'], dataRole: String(node.name ?? '') }, children)
137
+ : viewerNode({ type: 'pmRole', mark: roleMark(node) })
138
+ },
139
+ }
140
+ return custom as Handlers
141
+ }
142
+
143
+ function textOf(node: ElementContent | Root): string {
144
+ if (node.type === 'text') return node.value
145
+ return 'children' in node ? node.children.map(c => textOf(c as ElementContent)).join('') : ''
146
+ }
147
+
148
+ export function markdownToHast(mdast: MdastNode, prefix: string, links: MarkdownLinks = {}): Root {
149
+ const html = toHast(mdast as never, { allowDangerousHtml: true, handlers: handlers(links), unknownHandler: unknown as never }) as Nodes
150
+ const tree = raw(html, { passThrough: VIEWER_NODES }) as Root
151
+ let item = 0
152
+ for (const child of tree.children) {
153
+ if (child.type !== 'element') continue
154
+ if (/^h[1-6]$/.test(child.tagName) && child.properties.id === undefined) child.properties.id = `${prefix}-${urlSlug(textOf(child))}`
155
+ if (child.tagName !== 'ul' && child.tagName !== 'ol') continue
156
+ for (const li of child.children) {
157
+ if (li.type === 'element' && li.tagName === 'li') li.properties.dataPmItem = item++
158
+ }
159
+ }
160
+ return tree
161
+ }
@@ -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
+ }