@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.
- package/LICENSE +202 -0
- package/README.md +29 -2
- package/package.json +53 -3
- package/src/block.ts +122 -0
- package/src/cards.ts +130 -0
- package/src/clicks.ts +124 -0
- package/src/code.ts +235 -0
- package/src/context.ts +16 -0
- package/src/dark.ts +5 -0
- package/src/domain/clicks.ts +38 -0
- package/src/domain/follow.ts +32 -0
- package/src/domain/index.ts +21 -0
- package/src/domain/live.ts +43 -0
- package/src/domain/messages.ts +115 -0
- package/src/domain/origin.ts +93 -0
- package/src/domain/steps.ts +45 -0
- package/src/domain/text.ts +27 -0
- package/src/env.d.ts +3 -0
- package/src/globals.ts +66 -0
- package/src/highlight.ts +73 -0
- package/src/i18n.ts +7 -0
- package/src/index.ts +55 -0
- package/src/markdown.ts +255 -0
- package/src/operations.ts +28 -0
- package/src/outputs.ts +97 -0
- package/src/plugins.ts +34 -0
- package/src/present.ts +327 -0
- package/src/registry.ts +81 -0
- package/src/rich.ts +155 -0
- package/src/section.ts +209 -0
- package/src/style.css +833 -0
- package/src/viewer.ts +116 -0
- package/src/views.ts +101 -0
|
@@ -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
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
|
+
}
|
package/src/highlight.ts
ADDED
|
@@ -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
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'
|
package/src/markdown.ts
ADDED
|
@@ -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
|
+
}
|