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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,27 @@
1
+ import { docBlocks } from '@lincyaw/kernel'
2
+ import type { IRDocument, MdastNode } from '@lincyaw/kernel'
3
+
4
+ const WIDE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]|[\u{1F300}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u
5
+
6
+ export function codeColumns(code: string): number {
7
+ let widest = 0
8
+ for (const line of code.split('\n')) {
9
+ let width = 0
10
+ for (const char of line) width += char === '\t' ? 4 : WIDE.test(char) ? 2 : 1
11
+ widest = Math.max(widest, width)
12
+ }
13
+ return widest
14
+ }
15
+
16
+ function definitions(node: MdastNode, found: (identifier: string) => void): void {
17
+ if (node.type === 'footnoteDefinition' && node.identifier !== undefined) found(String(node.identifier))
18
+ node.children?.forEach(child => definitions(child, found))
19
+ }
20
+
21
+ export function footnoteSections(doc: IRDocument): Map<string, string> {
22
+ const sections = new Map<string, string>()
23
+ for (const { section, block: { node } } of docBlocks(doc)) {
24
+ if (node.type === 'markdown') definitions(node.mdast, identifier => sections.set(identifier, section.id))
25
+ }
26
+ return sections
27
+ }
package/src/env.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ interface ImportMetaEnv {
2
+ SSR: boolean
3
+ }
package/src/globals.ts ADDED
@@ -0,0 +1,66 @@
1
+ import type { IRSection } from '@lincyaw/kernel'
2
+ import type { App, Component, ComponentOptions } from 'vue'
3
+ import { computed, getCurrentInstance, inject, reactive } from 'vue'
4
+ import { SECTION_CONTEXT } from './context.ts'
5
+ import type { SectionContext } from './context.ts'
6
+ import { VIEWER } from './viewer.ts'
7
+ import type { Viewer } from './viewer.ts'
8
+
9
+ function section() {
10
+ return getCurrentInstance() ? inject(SECTION_CONTEXT, undefined) : undefined
11
+ }
12
+
13
+ export function installSlideGlobals(app: App, viewer: Viewer): void {
14
+ const props = app.config.globalProperties as Record<string, unknown>
15
+ const index = computed(() => viewer.section(viewer.position.value.section)?.index ?? 1)
16
+ const go = (n: number) => {
17
+ const target = viewer.doc.value.sections[n - 1]
18
+ if (target) viewer.show({ section: target.id, step: 0 })
19
+ }
20
+ const nav = reactive({
21
+ currentPage: index,
22
+ currentSlideNo: index,
23
+ clicks: computed(() => viewer.position.value.step),
24
+ total: computed(() => viewer.doc.value.sections.length),
25
+ go,
26
+ next: () => viewer.next(),
27
+ prev: () => viewer.prev(),
28
+ nextSlide: () => go(index.value + 1),
29
+ prevSlide: () => go(index.value - 1),
30
+ })
31
+ const configs = computed(() => ({ ...viewer.doc.value.meta, presmium: viewer.doc.value.config.presmium }))
32
+ const define = (name: string, get: () => unknown) => {
33
+ const existing = Object.getOwnPropertyDescriptor(props, name)
34
+ if (existing && !existing.configurable) return
35
+ Object.defineProperty(props, name, { configurable: true, enumerable: true, get })
36
+ }
37
+ define('$page', () => section()?.section.value.index ?? index.value)
38
+ define('$clicks', () => section()?.step.value ?? viewer.position.value.step)
39
+ define('$nav', () => nav)
40
+ define('$slidev', () => ({ configs: configs.value, themeConfigs: {}, nav }))
41
+ }
42
+
43
+ export function sectionFrontmatter(section: IRSection, renderer: string): Record<string, unknown> {
44
+ return { title: section.title, ...section.hints[renderer] }
45
+ }
46
+
47
+ export function withSlideGlobals(component: Component): Component {
48
+ if (typeof component === 'function') return component
49
+ const options = component as ComponentOptions
50
+ const inject = Array.isArray(options.inject) ? Object.fromEntries(options.inject.map(key => [key, key])) : options.inject
51
+ return {
52
+ ...options,
53
+ inject: {
54
+ ...inject,
55
+ pmSection: { from: SECTION_CONTEXT, default: undefined },
56
+ pmViewer: { from: VIEWER, default: undefined },
57
+ },
58
+ computed: {
59
+ ...options.computed,
60
+ $frontmatter(): Record<string, unknown> | undefined {
61
+ const { pmSection, pmViewer } = this as unknown as { pmSection?: SectionContext, pmViewer?: Viewer }
62
+ return pmSection && sectionFrontmatter(pmSection.section.value, pmViewer?.options.renderer ?? 'web')
63
+ },
64
+ },
65
+ } as ComponentOptions
66
+ }
@@ -0,0 +1,73 @@
1
+ import { languageId } from '@lincyaw/kernel'
2
+ import type { HighlighterCore } from 'shiki/core'
3
+
4
+ type Loader = () => Promise<unknown>
5
+
6
+ const GRAMMARS: Record<string, Loader> = import.meta.env.SSR ? {} : {
7
+ python: () => import('shiki/langs/python.mjs'),
8
+ javascript: () => import('shiki/langs/javascript.mjs'),
9
+ typescript: () => import('shiki/langs/typescript.mjs'),
10
+ jsx: () => import('shiki/langs/jsx.mjs'),
11
+ tsx: () => import('shiki/langs/tsx.mjs'),
12
+ json: () => import('shiki/langs/json.mjs'),
13
+ shellscript: () => import('shiki/langs/shellscript.mjs'),
14
+ yaml: () => import('shiki/langs/yaml.mjs'),
15
+ toml: () => import('shiki/langs/toml.mjs'),
16
+ html: () => import('shiki/langs/html.mjs'),
17
+ css: () => import('shiki/langs/css.mjs'),
18
+ vue: () => import('shiki/langs/vue.mjs'),
19
+ markdown: () => import('shiki/langs/markdown.mjs'),
20
+ sql: () => import('shiki/langs/sql.mjs'),
21
+ diff: () => import('shiki/langs/diff.mjs'),
22
+ c: () => import('shiki/langs/c.mjs'),
23
+ cpp: () => import('shiki/langs/cpp.mjs'),
24
+ rust: () => import('shiki/langs/rust.mjs'),
25
+ go: () => import('shiki/langs/go.mjs'),
26
+ java: () => import('shiki/langs/java.mjs'),
27
+ wgsl: () => import('shiki/langs/wgsl.mjs'),
28
+ ruby: () => import('shiki/langs/ruby.mjs'),
29
+ }
30
+
31
+ const ALIASES: Record<string, string> = {
32
+ mts: 'typescript',
33
+ sh: 'shellscript',
34
+ bash: 'shellscript',
35
+ shell: 'shellscript',
36
+ zsh: 'shellscript',
37
+ yml: 'yaml',
38
+ md: 'markdown',
39
+ 'c++': 'cpp',
40
+ rs: 'rust',
41
+ rb: 'ruby',
42
+ }
43
+
44
+ export const THEMES = { light: 'github-light', dark: 'github-dark' } as const
45
+
46
+ let highlighter: Promise<HighlighterCore> | undefined
47
+ const loading = new Map<string, Promise<void>>()
48
+
49
+ function load(): Promise<HighlighterCore> {
50
+ if (import.meta.env.SSR) return new Promise(() => {})
51
+ highlighter ??= Promise.all([import('shiki/core'), import('shiki/engine/javascript')]).then(([core, engine]) =>
52
+ core.createHighlighterCore({
53
+ themes: [import('shiki/themes/github-light.mjs'), import('shiki/themes/github-dark.mjs')],
54
+ langs: [],
55
+ engine: engine.createJavaScriptRegexEngine({ forgiving: true }),
56
+ }))
57
+ return highlighter
58
+ }
59
+
60
+ export function grammarOf(lang: string): string | undefined {
61
+ const id = languageId(lang)
62
+ const name = ALIASES[id] ?? id
63
+ return name in GRAMMARS ? name : undefined
64
+ }
65
+
66
+ export async function highlighterFor(lang: string): Promise<{ highlighter: HighlighterCore, lang: string } | undefined> {
67
+ const name = grammarOf(lang)
68
+ if (!name) return undefined
69
+ const core = await load()
70
+ if (!loading.has(name)) loading.set(name, core.loadLanguage(GRAMMARS[name]!() as never))
71
+ await loading.get(name)
72
+ return { highlighter: core, lang: name }
73
+ }
package/src/i18n.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { currentLocale } from '@lincyaw/host'
2
+ import { VIEWER_MESSAGES } from './domain/index.ts'
3
+ import type { Messages } from './domain/index.ts'
4
+
5
+ export function messages(): Messages {
6
+ return VIEWER_MESSAGES[currentLocale()]
7
+ }
package/src/index.ts ADDED
@@ -0,0 +1,55 @@
1
+ import type { App } from 'vue'
2
+ import { installClicks } from './clicks.ts'
3
+ import { installSlideGlobals } from './globals.ts'
4
+ import { messages } from './i18n.ts'
5
+ import { BUILTIN_ACTIONS, PresenterView, PresentView } from './present.ts'
6
+ import { registerAction, registerView } from './registry.ts'
7
+ import { provideViewer } from './viewer.ts'
8
+ import type { Viewer } from './viewer.ts'
9
+ import { DocumentView, ScrollyView } from './views.ts'
10
+ import './style.css'
11
+
12
+ registerView({ id: 'document', get label() { return messages().views.document }, kind: 'page', component: DocumentView })
13
+ registerView({ id: 'scrolly', get label() { return messages().views.scrolly }, kind: 'page', component: ScrollyView })
14
+ registerView({ id: 'present', get label() { return messages().views.present }, kind: 'overlay', component: PresentView })
15
+ registerView({ id: 'presenter', get label() { return messages().views.presenter }, kind: 'overlay', component: PresenterView, hidden: true })
16
+ BUILTIN_ACTIONS.forEach(registerAction)
17
+
18
+ export function installViewer(app: App, viewer: Viewer): void {
19
+ provideViewer(app, viewer)
20
+ installClicks(app)
21
+ installSlideGlobals(app, viewer)
22
+ }
23
+
24
+ export { BlockView, PmComponentFallback } from './block.ts'
25
+ export { CodeView } from './code.ts'
26
+ export { SECTION_CONTEXT, useSectionContext } from './context.ts'
27
+ export type { SectionContext } from './context.ts'
28
+ export * from './domain/index.ts'
29
+ export { sectionFrontmatter, withSlideGlobals } from './globals.ts'
30
+ export { messages } from './i18n.ts'
31
+ export { MarkdownView } from './markdown.ts'
32
+ export { followOperations } from './operations.ts'
33
+ export { installViewerPlugin } from './plugins.ts'
34
+ export type { ViewerPlugin, ViewerPluginContext } from './plugins.ts'
35
+ export { BUILTIN_ACTIONS, PresenterView, PresentView } from './present.ts'
36
+ export {
37
+ getComponent,
38
+ getNodeRenderer,
39
+ getView,
40
+ listActions,
41
+ listLayers,
42
+ listViews,
43
+ registerAction,
44
+ registerComponent,
45
+ registerLayer,
46
+ registerNodeRenderer,
47
+ registerView,
48
+ } from './registry.ts'
49
+ export type { NodeRenderer, ViewDefinition, ViewerAction, ViewerLayer } from './registry.ts'
50
+ export { PmMath, PmMermaid, PmRoleMark } from './rich.ts'
51
+ export { SectionView, STEP_LINE } from './section.ts'
52
+ export type { SectionLayout } from './section.ts'
53
+ export { createViewer, useViewer, VIEWER } from './viewer.ts'
54
+ export type { Viewer, ViewerOptions } from './viewer.ts'
55
+ export { DocumentView, OverlayHost, ScrollyView, ViewHost } from './views.ts'
@@ -0,0 +1,255 @@
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'
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 { 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'
17
+
18
+ export interface MarkdownLinks {
19
+ footnoteHref?(identifier: string): string
20
+ section?(id: string): { href: string, title: string } | undefined
21
+ }
22
+
23
+ export function viewerLinks(viewer: Viewer | undefined): MarkdownLinks {
24
+ if (!viewer) return {}
25
+ return {
26
+ footnoteHref: viewer.footnoteHref,
27
+ section(id) {
28
+ const section = viewer.section(id)
29
+ return section && { href: viewer.options.sectionHref?.(id) ?? `#${id}`, title: section.title }
30
+ },
31
+ }
32
+ }
33
+
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 =
43
+ | { type: 'pmMath', tex: string, display: boolean, html?: string }
44
+ | { type: 'pmMermaid', code: string }
45
+ | { type: 'pmCode', lang: string, code: string }
46
+ | { type: 'pmRole', mark: string }
47
+
48
+ const VIEWER_NODES: ViewerNode['type'][] = ['pmMath', 'pmMermaid', 'pmCode', 'pmRole']
49
+
50
+ function viewerNode(node: ViewerNode): ElementContent {
51
+ return node as unknown as ElementContent
52
+ }
53
+
54
+ function asViewerNode(node: { type: string }): ViewerNode | undefined {
55
+ return (VIEWER_NODES as string[]).includes(node.type) ? node as ViewerNode : undefined
56
+ }
57
+
58
+ const BLOCK_ELEMENTS = new Set([
59
+ 'address', 'article', 'aside', 'blockquote', 'details', 'div', 'dl', 'dt', 'dd', 'fieldset', 'figcaption', 'figure', 'footer',
60
+ 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hr', 'iframe', 'li', 'main', 'nav', 'ol', 'p', 'pre', 'section', 'table', 'ul',
61
+ ])
62
+
63
+ function isBlock(node: ElementContent): boolean {
64
+ const own = asViewerNode(node)
65
+ if (own) return own.type === 'pmMermaid' || own.type === 'pmCode' || (own.type === 'pmMath' && own.display)
66
+ return node.type === 'element' && BLOCK_ELEMENTS.has(node.tagName)
67
+ }
68
+
69
+ export function decoded(value: string): string {
70
+ try {
71
+ return decodeURIComponent(value)
72
+ } catch {
73
+ return value
74
+ }
75
+ }
76
+
77
+ function unknown(state: State, node: MdastNode): ElementContent | undefined {
78
+ if (!node.children) return typeof node.value === 'string' ? text(node.value) : undefined
79
+ const children = state.all(node as never)
80
+ return element(children.some(isBlock) ? 'div' : 'span', {}, children)
81
+ }
82
+
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
+ function handlers(links: MarkdownLinks): Handlers {
93
+ const footnoteHref = links.footnoteHref ?? ((identifier: string) => `#${footnoteId(identifier)}`)
94
+ const wrap = (tagName: string, className?: string): Handler => (state, node) =>
95
+ element(tagName, className ? { className: [className] } : {}, state.all(node))
96
+ const value = (node: { value?: unknown }) => (typeof node.value === 'string' ? node.value : '')
97
+ const identifier = (node: { identifier?: unknown, label?: unknown }) => String(node.identifier ?? node.label)
98
+ 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))
100
+ const math = (node: MdastNode, display: boolean) => viewerNode({ type: 'pmMath', tex: value(node), display, html: typeof node.html === 'string' ? node.html : undefined })
101
+ const reference = (el: Element, id: string, fallback: string): Element => {
102
+ const target = id ? links.section?.(id) : undefined
103
+ if (target) el.properties.href = target.href
104
+ if (el.children.length === 0) el.children = [text(target?.title ?? fallback)]
105
+ return el
106
+ }
107
+ const custom: Record<string, Handler> = {
108
+ math: (_, node) => math(node, true),
109
+ inlineMath: (_, node) => math(node, false),
110
+ mermaid: (_, node) => viewerNode({ type: 'pmMermaid', code: value(node) }),
111
+ code: (_, node) => viewerNode({ type: 'pmCode', lang: typeof node.lang === 'string' ? node.lang : '', code: value(node) }),
112
+ iframe: (_, node) => element('iframe', {
113
+ src: typeof node.src === 'string' ? node.src : '',
114
+ width: node.width == null || node.width === '' ? undefined : String(node.width),
115
+ height: node.height == null || node.height === '' ? undefined : String(node.height),
116
+ loading: 'lazy',
117
+ }),
118
+ admonition(state, node) {
119
+ const names = messages().markdown.admonitions
120
+ const extra = classes(node)
121
+ const kind = typeof node.kind === 'string' ? node.kind : extra.find(c => c in names) ?? 'note'
122
+ const children = state.all(node)
123
+ const titled = (node.children as MdastNode[] | undefined)?.some(c => c.type === 'admonitionTitle')
124
+ const title = titled ? [] : [element('p', { className: ['pm-admonition__title'] }, [text(names[kind] ?? kind)])]
125
+ return element('aside', { className: [...new Set(['pm-admonition', `pm-admonition--${kind}`, ...extra])] }, [...title, ...children])
126
+ },
127
+ admonitionTitle: wrap('p', 'pm-admonition__title'),
128
+ footnoteReference: (_, node) => element('sup', { className: ['pm-footnote-ref'] }, [
129
+ element('a', { href: footnoteHref(identifier(node)), dataLabel: mark(node), ariaLabel: messages().markdown.footnote(mark(node)) }, []),
130
+ ]),
131
+ footnoteDefinition: (state, node) => element('div', { id: footnoteId(identifier(node)), className: ['pm-footnote'] }, [
132
+ element('sup', { dataLabel: mark(node) }, []),
133
+ ...state.all(node),
134
+ ]),
135
+ container: (state, node) => element('figure', { id: typeof node.identifier === 'string' ? node.identifier : undefined, className: ['pm-figure'] }, state.all(node)),
136
+ caption: wrap('figcaption'),
137
+ legend: wrap('div', 'pm-legend'),
138
+ abbreviation: (state, node) => element('abbr', typeof node.title === 'string' ? { title: node.title } : {}, state.all(node)),
139
+ crossReference: (state, node) => reference(element('a', { href: `#${identifier(node)}` }, state.all(node)), identifier(node), label(node)),
140
+ link(state, node) {
141
+ const el = defaultHandlers.link(state, node as never)
142
+ if (isButton(node)) el.properties.className = ['pm-button']
143
+ const url = typeof node.url === 'string' ? node.url : ''
144
+ return url.startsWith('#') ? reference(el, decoded(url.slice(1)), url.slice(1)) : reference(el, '', url)
145
+ },
146
+ keyboard: wrap('kbd'),
147
+ subscript: wrap('sub'),
148
+ superscript: wrap('sup'),
149
+ underline: wrap('u'),
150
+ definitionList: wrap('dl'),
151
+ definitionTerm: wrap('dt'),
152
+ definitionDescription: wrap('dd'),
153
+ details: (state, node) => element('details', classed(node, { open: node.open === true }), state.all(node)),
154
+ summary: wrap('summary'),
155
+ div: (state, node) => element('div', classed(node), state.all(node)),
156
+ span: (state, node) => element('span', isButton(node) ? { className: ['pm-button'] } : classed(node), state.all(node)),
157
+ ...cardHandlers(),
158
+ presmiumRole(state, node) {
159
+ const children = state.all(node)
160
+ return children.length
161
+ ? element('span', { className: ['pm-role'], dataRole: String(node.name ?? '') }, children)
162
+ : viewerNode({ type: 'pmRole', mark: roleMark(node) })
163
+ },
164
+ }
165
+ return custom as Handlers
166
+ }
167
+
168
+ function slug(text: string): string {
169
+ return text.normalize('NFKC').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '')
170
+ }
171
+
172
+ function textOf(node: ElementContent | Root): string {
173
+ if (node.type === 'text') return node.value
174
+ return 'children' in node ? node.children.map(c => textOf(c as ElementContent)).join('') : ''
175
+ }
176
+
177
+ export function markdownToHast(mdast: MdastNode, prefix: string, links: MarkdownLinks = {}): Root {
178
+ const html = toHast(mdast as never, { allowDangerousHtml: true, handlers: handlers(links), unknownHandler: unknown as never }) as Nodes
179
+ const tree = raw(html, { passThrough: VIEWER_NODES }) as Root
180
+ let item = 0
181
+ for (const child of tree.children) {
182
+ if (child.type !== 'element') continue
183
+ if (/^h[1-6]$/.test(child.tagName) && child.properties.id === undefined) child.properties.id = `${prefix}-${slug(textOf(child))}`
184
+ if (child.tagName !== 'ul' && child.tagName !== 'ol') continue
185
+ for (const li of child.children) {
186
+ if (li.type === 'element' && li.tagName === 'li') li.properties.dataPmItem = item++
187
+ }
188
+ }
189
+ return tree
190
+ }
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,28 @@
1
+ import type { Operations } from '@lincyaw/host'
2
+ import { watch } from 'vue'
3
+ import { getView } from './registry.ts'
4
+ import type { Viewer } from './viewer.ts'
5
+
6
+ export function followOperations(viewer: Viewer, operations: Operations): () => void {
7
+ const stop = watch(
8
+ () => ({ section: viewer.position.value.section, step: viewer.position.value.step, view: viewer.view.value }),
9
+ (now, before) => {
10
+ if (before && now.section === before.section && now.step === before.step && now.view === before.view) return
11
+ operations.report({ type: 'go', data: { ...now } })
12
+ },
13
+ { flush: 'sync' },
14
+ )
15
+ const off = operations.onReplay('go', {
16
+ apply(data) {
17
+ const view = typeof data.view === 'string' ? getView(data.view) : undefined
18
+ if (view && !view.hidden) viewer.setView(view.id)
19
+ if (typeof data.section !== 'string') return
20
+ const step = Number(data.step)
21
+ viewer.show({ section: data.section, step: Number.isFinite(step) && step >= 0 ? step : 0 })
22
+ },
23
+ })
24
+ return () => {
25
+ stop()
26
+ off()
27
+ }
28
+ }
package/src/outputs.ts ADDED
@@ -0,0 +1,97 @@
1
+ import { currentLocale } from '@lincyaw/host'
2
+ import type { MdastNode, RunOutput, RunResult } from '@lincyaw/kernel'
3
+ import { AnsiUp } from 'ansi_up'
4
+ import { fromMarkdown } from 'mdast-util-from-markdown'
5
+ import { gfmFromMarkdown } from 'mdast-util-gfm'
6
+ import { gfm } from 'micromark-extension-gfm'
7
+ import type { VNode, VNodeChild } from 'vue'
8
+ import { defineComponent, h, onMounted, ref, watch } from 'vue'
9
+ import { outputNotes } from './domain/index.ts'
10
+ import { hastToVNodes, markdownToHast } from './markdown.ts'
11
+ import { getNodeRenderer } from './registry.ts'
12
+ import { PmMath } from './rich.ts'
13
+
14
+ const ORDER = ['text/html', 'text/markdown', 'text/latex', 'image/svg+xml', 'image/png', 'image/jpeg', 'image/gif', 'application/json', 'text/plain']
15
+
16
+ function text(value: unknown): string {
17
+ return Array.isArray(value) ? value.join('') : typeof value === 'string' ? value : JSON.stringify(value, null, 2)
18
+ }
19
+
20
+ function ansi(value: string): string {
21
+ return new AnsiUp().ansi_to_html(value)
22
+ }
23
+
24
+ function literalHtml(node: MdastNode): MdastNode {
25
+ if (node.type === 'html') return { type: 'text', value: String(node.value ?? '') }
26
+ return node.children ? { ...node, children: node.children.map(literalHtml) } : node
27
+ }
28
+
29
+ export function markdownOutput(markdown: string, prefix: string): VNodeChild[] {
30
+ const mdast = literalHtml(fromMarkdown(markdown, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }) as unknown as MdastNode)
31
+ return hastToVNodes(markdownToHast(mdast, prefix).children, {})
32
+ }
33
+
34
+ let purify: Promise<(html: string) => string> | undefined
35
+
36
+ const HtmlOutput = defineComponent({
37
+ name: 'PmHtmlOutput',
38
+ props: { html: { type: String, required: true }, plain: { type: String, default: '' } },
39
+ setup(props) {
40
+ const clean = ref<string>()
41
+ const render = async () => {
42
+ purify ??= import('dompurify').then(m => (html: string) => m.default.sanitize(html))
43
+ clean.value = (await purify)(props.html)
44
+ }
45
+ onMounted(render)
46
+ watch(() => props.html, render)
47
+ return () => clean.value === undefined
48
+ ? h('pre', { class: 'pm-output__text' }, props.plain)
49
+ : h('div', { class: 'pm-output__html', innerHTML: clean.value })
50
+ },
51
+ })
52
+
53
+ function display(data: Record<string, unknown>, metadata: Record<string, unknown>, prefix: string, key: number): VNodeChild {
54
+ for (const type of Object.keys(data)) {
55
+ const renderer = getNodeRenderer(`mime:${type}`)
56
+ if (renderer) return h(renderer, { key, type, data: data[type], metadata })
57
+ }
58
+ const type = ORDER.find(t => data[t] !== undefined)
59
+ if (!type) return null
60
+ const value = data[type]
61
+ const attrs = { 'key': key, 'class': 'pm-output__display', 'data-mime': type }
62
+ if (type === 'text/html') return h('div', attrs, [h(HtmlOutput, { html: text(value), plain: text(data['text/plain'] ?? '') })])
63
+ if (type === 'text/markdown') return h('div', { ...attrs, class: ['pm-output__display', 'pm-output__markdown'] }, markdownOutput(text(value), prefix))
64
+ if (type === 'text/latex') return h('div', attrs, [h(PmMath, { tex: text(value).trim().replace(/^\$\$?|\$\$?$/g, ''), display: true })])
65
+ if (type === 'image/svg+xml') return h('div', attrs, [h('img', { src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(text(value))}`, alt: '' })])
66
+ if (type.startsWith('image/')) return h('div', attrs, [h('img', { src: `data:${type};base64,${text(value).replace(/\s/g, '')}`, alt: '' })])
67
+ if (type === 'application/json') return h('pre', attrs, JSON.stringify(value, null, 2))
68
+ return h('pre', { ...attrs, innerHTML: ansi(text(value)) })
69
+ }
70
+
71
+ function richOutput(output: RunOutput, prefix: string, key: number): VNodeChild {
72
+ switch (output.output_type) {
73
+ case 'stream': return h('pre', { key, class: `pm-output__${output.name}`, innerHTML: ansi(text(output.text)) })
74
+ case 'error': return h('pre', { key, class: 'pm-output__error', innerHTML: ansi(text(output.traceback.join('\n')) || `${output.ename}: ${output.evalue}`) })
75
+ default: return display(output.data, output.metadata ?? {}, prefix, key)
76
+ }
77
+ }
78
+
79
+ export function proseOnly(run: RunResult): boolean {
80
+ const outputs = run.outputs ?? []
81
+ return outputs.length > 0 && outputs.every(o => (o.output_type === 'display_data' || o.output_type === 'execute_result') && o.data['text/markdown'] !== undefined && !Object.keys(o.data).some(type => getNodeRenderer(`mime:${type}`)))
82
+ }
83
+
84
+ export function runOutput(run: RunResult & { cacheKey?: string }, key: string | number, prefix: string, options: { prose?: boolean, note?: string } = {}): VNode {
85
+ const notes = [...outputNotes(run, currentLocale()), ...options.note ? [options.note] : []]
86
+ const content = run.outputs
87
+ ? run.outputs.map((output, i) => richOutput(output, prefix, i))
88
+ : [
89
+ run.stdout ? h('pre', { class: 'pm-output__stdout' }, run.stdout) : null,
90
+ run.stderr ? h('pre', { class: 'pm-output__stderr' }, run.stderr) : null,
91
+ ]
92
+ if (options.prose) return h('div', { 'key': key, 'class': 'pm-output pm-output--prose', 'data-executor': run.executorId }, content)
93
+ return h('div', { 'key': key, 'class': ['pm-output', { 'pm-output--failed': !run.ok }], 'data-executor': run.executorId }, [
94
+ ...content,
95
+ notes.length ? h('p', { class: 'pm-output__note' }, notes.join(' · ')) : null,
96
+ ])
97
+ }
package/src/plugins.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { errorMessage } from '@lincyaw/kernel'
2
+ import { BlockView } from './block.ts'
3
+ import { registerAction, registerComponent, registerLayer, registerNodeRenderer, registerView } from './registry.ts'
4
+ import { SectionView } from './section.ts'
5
+ import { useViewer } from './viewer.ts'
6
+
7
+ export interface ViewerPluginContext {
8
+ registerView: typeof registerView
9
+ registerNodeRenderer: typeof registerNodeRenderer
10
+ registerComponent: typeof registerComponent
11
+ registerAction: typeof registerAction
12
+ registerLayer: typeof registerLayer
13
+ useViewer: typeof useViewer
14
+ SectionView: typeof SectionView
15
+ BlockView: typeof BlockView
16
+ }
17
+
18
+ export type ViewerPlugin = (context: ViewerPluginContext) => void
19
+
20
+ const context: ViewerPluginContext = { registerView, registerNodeRenderer, registerComponent, registerAction, registerLayer, useViewer, SectionView, BlockView }
21
+
22
+ export function installViewerPlugin(plugin: unknown, name = 'viewer plugin'): string | undefined {
23
+ if (typeof plugin !== 'function') {
24
+ console.error(`[presmium] ${name}: the default export is not a function`)
25
+ return 'the default export is not a function'
26
+ }
27
+ try {
28
+ (plugin as ViewerPlugin)(context)
29
+ return undefined
30
+ } catch (error) {
31
+ console.error(`[presmium] ${name} failed`, error)
32
+ return errorMessage(error)
33
+ }
34
+ }