@lincyaw/viewer 1.0.0-rc.13 → 1.0.0-rc.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincyaw/viewer",
3
- "version": "1.0.0-rc.13",
3
+ "version": "1.0.0-rc.14",
4
4
  "description": "Presmium's IR runtime: renders the IR in the browser, with runtime views and live updates",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -56,8 +56,8 @@
56
56
  "react-dom": "18.3.1",
57
57
  "shiki": "4.5.0",
58
58
  "style-to-js": "2.0.2",
59
- "@lincyaw/host": "1.0.0-rc.13",
60
- "@lincyaw/kernel": "1.0.0-rc.13"
59
+ "@lincyaw/host": "1.0.0-rc.14",
60
+ "@lincyaw/kernel": "1.0.0-rc.14"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/hast": "3.0.5",
@@ -6,6 +6,7 @@ import type { ComponentNode, IRBlock, IRSection } from '@lincyaw/kernel'
6
6
  import { FINAL_STEP } from '../domain/index.ts'
7
7
  import { CODE_OWNED, CodeBlockView } from './code/code-block.ts'
8
8
  import { contentMarks } from './content-marks.ts'
9
+ import { loadedParts } from './deferred.ts'
9
10
  import { markdownToHast } from './markdown/handlers.ts'
10
11
  import { PmMermaid } from './markdown/mermaid.ts'
11
12
  import { hastToVNodes, MarkdownView } from './markdown/render.ts'
@@ -18,7 +19,7 @@ import { SECTION_CONTEXT } from './section-context.ts'
18
19
  export interface BlockProps { block: IRBlock, section: IRSection, step: number, expanded?: boolean, localPreview?: boolean }
19
20
  const COMPONENT_NODE = createContext<{ node: ComponentNode, section: string } | undefined>(undefined)
20
21
  function fallbackOf(node: ComponentNode, section: string) { return node.fallback ? hastToVNodes(markdownToHast(node.fallback, section).children, {}) : [] }
21
- export function PmComponentFallback() { const current = useContext(COMPONENT_NODE); return current ? h('div', { className: 'pm-component-fallback' }, fallbackOf(current.node, current.section)) : null }
22
+ export function PmComponentFallback() { const current = useContext(COMPONENT_NODE); useStore(loadedParts); return current ? h('div', { className: 'pm-component-fallback' }, fallbackOf(current.node, current.section)) : null }
22
23
  function ComponentView(props: BlockProps) {
23
24
  const node = props.block.node as ComponentNode
24
25
  const printed = useStaticRender()
@@ -44,6 +45,8 @@ function content(props: BlockProps): ReactNode {
44
45
  function BlockViewContent(props: BlockProps) {
45
46
  const viewer = useContext(VIEWER); const el = useRef<HTMLDivElement>(null)
46
47
  useStore(registryRevision)
48
+ // A part of the viewer the block needed and did not have yet (`deferred.ts`) has arrived.
49
+ useStore(loadedParts)
47
50
  useEffect(() => el.current ? trackClicks(el.current, props.expanded ? FINAL_STEP : props.step) : undefined, [props.step, props.expanded, props.block])
48
51
  const { block } = props
49
52
  const visible = props.expanded || blockVisible(block, props.step)
@@ -1,9 +1,9 @@
1
1
  import { useAppearance, usePrefersReducedMotion } from '@lincyaw/host'
2
- import { ShikiMagicMove } from '@shikijs/magic-move/react'
3
2
  import '@shikijs/magic-move/style.css'
4
3
  import { useEffect, useState } from 'react'
4
+ import type { ComponentProps, ComponentType } from 'react'
5
+ import type { ShikiMagicMove } from '@shikijs/magic-move/react'
5
6
  import type { Element, ElementContent, Root } from 'hast'
6
- import { toHtml } from 'hast-util-to-html'
7
7
  import { codeColumns } from '../../domain/index.ts'
8
8
  import { highlighterFor, SHIKI_THEMES } from './highlight.ts'
9
9
  import { h } from '../element.ts'
@@ -13,10 +13,14 @@ function lineElements(tree: Root): Element[] { const pre = tree.children.find((n
13
13
  export function CodeView({ lang = '', value, highlight }: { lang?: string, value: string, highlight?: number[] }) {
14
14
  const shiki = useHighlighter(lang); const code = value.replace(/\n$/, ''); const lines = new Set(highlight ?? [])
15
15
  let html: string | undefined
16
- if (shiki) { const tree = shiki.highlighter.codeToHast(code, { lang: shiki.lang, themes: SHIKI_THEMES, defaultColor: false }); lineElements(tree).forEach((line, i) => line.properties.class = lines.has(i + 1) ? 'line highlighted' : 'line'); html = toHtml(tree) }
16
+ if (shiki) { const tree = shiki.highlighter.codeToHast(code, { lang: shiki.lang, themes: SHIKI_THEMES, defaultColor: false }); lineElements(tree).forEach((line, i) => line.properties.class = lines.has(i + 1) ? 'line highlighted' : 'line'); html = shiki.toHtml(tree) }
17
17
  const attrs = { className: `pm-code${lines.size ? ' pm-code--highlight' : ''}`, style: { '--pm-code-columns': codeColumns(code) }, 'data-lang': lang || undefined }
18
18
  if (html) return h('div', { ...attrs, innerHTML: html })
19
19
  const rows = code.split('\n').flatMap((text, i) => [h('span', { key: i, className: `line${lines.has(i + 1) ? ' highlighted' : ''}` }, text), '\n'])
20
20
  return h('div', attrs, h('pre', { className: 'shiki' }, h('code', {}, rows.slice(0, -1))))
21
21
  }
22
- export function VariantView({ lang = '', value }: { lang?: string, value: string }) { const loaded = useHighlighter(lang); const reduced = usePrefersReducedMotion(); const { dark } = useAppearance(); if (!loaded || reduced) return h(CodeView, { lang, value }); const code = value.replace(/\n$/, ''); return h('div', { className: 'pm-code pm-code--magic-move', style: { '--pm-code-columns': codeColumns(code) }, 'data-lang': lang || undefined }, h(ShikiMagicMove, { highlighter: loaded.highlighter, lang: loaded.lang, theme: dark ? SHIKI_THEMES.dark : SHIKI_THEMES.light, code })) }
22
+ type MagicMove = ComponentType<ComponentProps<typeof ShikiMagicMove>>
23
+ let magicMove: Promise<MagicMove> | undefined
24
+ /** The animation between variants, loaded once a page shows one (until then, and on the server, the listing alone). */
25
+ function useMagicMove(wanted: boolean) { const [move, setMove] = useState<MagicMove>(); useEffect(() => { if (!wanted || move) return; let active = true; magicMove ??= import('@shikijs/magic-move/react').then(m => m.ShikiMagicMove as MagicMove); void magicMove.then(value => { if (active) setMove(() => value) }).catch(() => {}); return () => { active = false } }, [wanted]); return move }
26
+ export function VariantView({ lang = '', value }: { lang?: string, value: string }) { const loaded = useHighlighter(lang); const reduced = usePrefersReducedMotion(); const { dark } = useAppearance(); const ShikiMagicMove = useMagicMove(!reduced); if (!loaded || reduced || !ShikiMagicMove) return h(CodeView, { lang, value }); const code = value.replace(/\n$/, ''); return h('div', { className: 'pm-code pm-code--magic-move', style: { '--pm-code-columns': codeColumns(code) }, 'data-lang': lang || undefined }, h(ShikiMagicMove, { highlighter: loaded.highlighter, lang: loaded.lang, theme: dark ? SHIKI_THEMES.dark : SHIKI_THEMES.light, code })) }
@@ -1,5 +1,6 @@
1
1
  import { languageId } from '@lincyaw/kernel'
2
2
  import type { HighlighterCore } from 'shiki/core'
3
+ import type { toHtml } from 'hast-util-to-html'
3
4
 
4
5
  type Loader = () => Promise<unknown>
5
6
 
@@ -43,17 +44,22 @@ const ALIASES: Record<string, string> = {
43
44
 
44
45
  export const SHIKI_THEMES = { light: 'github-light', dark: 'github-dark' } as const
45
46
 
46
- let highlighter: Promise<HighlighterCore> | undefined
47
+ /** Shiki, with what writes its trees as HTML. */
48
+ interface Loaded { highlighter: HighlighterCore, toHtml: typeof toHtml }
49
+
50
+ let highlighter: Promise<Loaded> | undefined
47
51
  const loading = new Map<string, Promise<void>>()
48
52
 
49
- function load(): Promise<HighlighterCore> {
53
+ function load(): Promise<Loaded> {
50
54
  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({
55
+ highlighter ??= Promise.all([import('shiki/core'), import('shiki/engine/javascript'), import('hast-util-to-html')]).then(async ([core, engine, html]) => ({
56
+ highlighter: await core.createHighlighterCore({
53
57
  themes: [import('shiki/themes/github-light.mjs'), import('shiki/themes/github-dark.mjs')],
54
58
  langs: [],
55
59
  engine: engine.createJavaScriptRegexEngine({ forgiving: true }),
56
- }))
60
+ }),
61
+ toHtml: html.toHtml,
62
+ }))
57
63
  return highlighter
58
64
  }
59
65
 
@@ -63,11 +69,11 @@ function grammarOf(lang: string): string | undefined {
63
69
  return name in GRAMMARS ? name : undefined
64
70
  }
65
71
 
66
- export async function highlighterFor(lang: string): Promise<{ highlighter: HighlighterCore, lang: string } | undefined> {
72
+ export async function highlighterFor(lang: string): Promise<Loaded & { lang: string } | undefined> {
67
73
  const name = grammarOf(lang)
68
74
  if (!name) return undefined
69
- const core = await load()
70
- if (!loading.has(name)) loading.set(name, core.loadLanguage(GRAMMARS[name]!() as never))
75
+ const loaded = await load()
76
+ if (!loading.has(name)) loading.set(name, loaded.highlighter.loadLanguage(GRAMMARS[name]!() as never))
71
77
  await loading.get(name)
72
- return { highlighter: core, lang: name }
78
+ return { ...loaded, lang: name }
73
79
  }
@@ -0,0 +1,19 @@
1
+ import { htmlAsText } from '@lincyaw/kernel'
2
+ import type { MdastNode } 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
+
8
+ /** What the outputs of code runs are read with, loaded once a page shows one (`deferred.ts`). */
9
+ export interface OutputParsers {
10
+ /** Terminal text with its ANSI colours, as HTML. */
11
+ ansi(text: string): string
12
+ /** A Markdown output as MDAST, its raw HTML as text. */
13
+ markdown(text: string): MdastNode
14
+ }
15
+
16
+ export const OUTPUT_PARSERS: OutputParsers = {
17
+ ansi: text => new AnsiUp().ansi_to_html(text),
18
+ markdown: text => htmlAsText(fromMarkdown(text, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }) as unknown as MdastNode),
19
+ }
@@ -1,14 +1,11 @@
1
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'
2
+ import { escapeHtml, isProseRun } from '@lincyaw/kernel'
3
+ import type { RunOutput, RunResult } from '@lincyaw/kernel'
8
4
  import type { ReactElement, ReactNode } from 'react'
9
5
  import { useEffect, useState } from 'react'
10
6
  import { h } from '../element.ts'
11
7
  import { outputNotes } from '../../domain/index.ts'
8
+ import { codeOutputParsers } from '../deferred.ts'
12
9
  import { markdownToHast } from '../markdown/handlers.ts'
13
10
  import { PmMath } from '../markdown/math.ts'
14
11
  import { hastToVNodes } from '../markdown/render.ts'
@@ -20,13 +17,14 @@ function text(value: unknown): string {
20
17
  return Array.isArray(value) ? value.join('') : typeof value === 'string' ? value : JSON.stringify(value, null, 2)
21
18
  }
22
19
 
20
+ // Until the parsers are loaded (`deferred.ts`), terminal text shows without its colours and Markdown as written.
23
21
  function ansi(value: string): string {
24
- return new AnsiUp().ansi_to_html(value)
22
+ return codeOutputParsers()?.ansi(value) ?? escapeHtml(value)
25
23
  }
26
24
 
27
25
  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, {})
26
+ const parsers = codeOutputParsers()
27
+ return parsers ? hastToVNodes(markdownToHast(parsers.markdown(markdown), prefix).children, {}) : [h('pre', { key: 0, className: 'pm-output__text' }, markdown)]
30
28
  }
31
29
 
32
30
  let purify: Promise<(html: string) => string> | undefined
@@ -0,0 +1,65 @@
1
+ import { createStore } from '@lincyaw/host'
2
+ import { docBlocks } from '@lincyaw/kernel'
3
+ import type { IRDocument, MdastNode } from '@lincyaw/kernel'
4
+ import type { raw } from 'hast-util-raw'
5
+ import type { OutputParsers } from './code/output-parsers.ts'
6
+
7
+ /**
8
+ * The parts of the viewer that only some documents use, loaded apart from the app: the HTML parser for HTML written in
9
+ * Markdown, and the parsers of code outputs (ANSI colours, Markdown). A page loads the parts its document uses before it
10
+ * first renders (`loadDocumentParts`), so that the server's render and the browser's first one agree; a part a later
11
+ * change of the document needs loads then, and the blocks render again once it is there.
12
+ */
13
+
14
+ type RawHtml = typeof raw
15
+
16
+ /** Counts the parts loaded, so that a block shown without one renders again once it is there. */
17
+ export const loadedParts = createStore(0)
18
+ let rawHtml: RawHtml | undefined
19
+ let outputParsers: OutputParsers | undefined
20
+ const loading = new Map<string, Promise<void>>()
21
+
22
+ function load(name: string, start: () => Promise<void>): Promise<void> {
23
+ let known = loading.get(name)
24
+ if (!known) {
25
+ known = start().then(
26
+ () => loadedParts.set(loadedParts.getSnapshot() + 1),
27
+ (error: unknown) => {
28
+ loading.delete(name)
29
+ console.error(`[presmium] the viewer's ${name} did not load`, error)
30
+ },
31
+ )
32
+ loading.set(name, known)
33
+ }
34
+ return known
35
+ }
36
+
37
+ const loadRawHtml = () => load('HTML parser', async () => { rawHtml = (await import('hast-util-raw')).raw })
38
+ const loadOutputParsers = () => load('output parsers', async () => { outputParsers = (await import('./code/output-parsers.ts')).OUTPUT_PARSERS })
39
+
40
+ /** The HTML parser if it is loaded; else it starts loading and the caller shows the HTML as missing meanwhile. */
41
+ export function rawHtmlParser(): RawHtml | undefined {
42
+ if (!rawHtml) void loadRawHtml()
43
+ return rawHtml
44
+ }
45
+
46
+ /** The parsers of code outputs if they are loaded; else they start loading and the caller shows plain text meanwhile. */
47
+ export function codeOutputParsers(): OutputParsers | undefined {
48
+ if (!outputParsers) void loadOutputParsers()
49
+ return outputParsers
50
+ }
51
+
52
+ function hasHtml(node: MdastNode | undefined): boolean {
53
+ return node !== undefined && (node.type === 'html' || (node.children?.some(hasHtml) ?? false))
54
+ }
55
+
56
+ /** Loads the parts `doc` uses (`import.meta.env.SSR` included: the server renders the document with them too). */
57
+ export async function loadDocumentParts(doc: IRDocument): Promise<void> {
58
+ const parts: Promise<void>[] = []
59
+ for (const { block } of docBlocks(doc)) {
60
+ const node = block.node
61
+ if (!rawHtml && ((node.type === 'markdown' && hasHtml(node.mdast)) || (node.type === 'component' && hasHtml(node.fallback)))) parts.push(loadRawHtml())
62
+ if (!outputParsers && node.type === 'code' && node.outputs?.length) parts.push(loadOutputParsers())
63
+ }
64
+ await Promise.all(parts)
65
+ }
@@ -22,6 +22,7 @@ Object.entries(DOCUMENT_LAYOUT_COMPONENTS).forEach(([name, component]) => regist
22
22
  export function ViewerProvider({ viewer, children }: { viewer: Viewer, children?: ReactNode }) { return createElement(VIEWER.Provider, { value: viewer }, children) }
23
23
 
24
24
  export { BlockView, BlockPreview, PmComponentFallback } from './block.ts'
25
+ export { loadDocumentParts } from './deferred.ts'
25
26
  export { syncViewerWithOperations } from './operations.ts'
26
27
  export { findView, findComponent, listViews, registerComponent, registryRevision } from './registry.ts'
27
28
  export type { NodeRenderer, ViewDefinition, ViewerAction, ViewerLayer } from './registry.ts'
@@ -1,9 +1,9 @@
1
1
  import { isButton, roleMark, safeDecodeURIComponent, urlSlug } from '@lincyaw/kernel'
2
2
  import type { MdastNode } from '@lincyaw/kernel'
3
3
  import type { Element, ElementContent, Nodes, Root } from 'hast'
4
- import { raw } from 'hast-util-raw'
5
4
  import type { Handler, Handlers, State } from 'mdast-util-to-hast'
6
5
  import { defaultHandlers, toHast } from 'mdast-util-to-hast'
6
+ import { rawHtmlParser } from '../deferred.ts'
7
7
  import { viewerMessages } from '../viewer-messages.ts'
8
8
  import { footnoteId } from '../viewer-state.ts'
9
9
  import type { Viewer } from '../viewer-state.ts'
@@ -145,9 +145,35 @@ function textOf(node: ElementContent | Root): string {
145
145
  return 'children' in node ? node.children.map(c => textOf(c as ElementContent)).join('') : ''
146
146
  }
147
147
 
148
+ function hasRaw(node: Nodes): boolean {
149
+ return node.type === 'raw' || ('children' in node && node.children.some(child => hasRaw(child as Nodes)))
150
+ }
151
+
152
+ /** Joins the neighbouring texts of a tree, as parsing it as HTML does (`hast-util-raw`), so that it renders as one text. */
153
+ function joinTexts(node: Nodes): void {
154
+ if (!('children' in node)) return
155
+ const children: Nodes[] = []
156
+ for (const child of node.children as Nodes[]) {
157
+ const last = children.at(-1)
158
+ if (child.type === 'text' && last?.type === 'text') last.value += child.value
159
+ else children.push(child)
160
+ joinTexts(child)
161
+ }
162
+ node.children = children as never
163
+ }
164
+
165
+ /** The trees made while the HTML parser was loading, without their HTML; they are made again once it is there. */
166
+ const partial = new WeakSet<Root>()
167
+ export const isPartialTree = (tree: Root): boolean => partial.has(tree)
168
+
148
169
  export function markdownToHast(mdast: MdastNode, prefix: string, links: MarkdownLinks = {}): Root {
149
170
  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
171
+ // HTML written in the Markdown is parsed with the rest (`hast-util-raw`); without any, the tree needs no HTML parser.
172
+ const written = hasRaw(html)
173
+ const raw = written ? rawHtmlParser() : undefined
174
+ if (!raw) joinTexts(html)
175
+ const tree = (raw ? raw(html, { passThrough: VIEWER_NODES }) : html) as Root
176
+ if (written && !raw) partial.add(tree)
151
177
  let item = 0
152
178
  for (const child of tree.children) {
153
179
  if (child.type !== 'element') continue
@@ -2,7 +2,7 @@ import type { IRDocument, MdastNode } from '@lincyaw/kernel'
2
2
  import type { Root } from 'hast'
3
3
  import type { Viewer } from '../viewer-state.ts'
4
4
  import { footnoteSections } from '../../domain/index.ts'
5
- import { markdownToHast } from './handlers.ts'
5
+ import { isPartialTree, markdownToHast } from './handlers.ts'
6
6
  import type { MarkdownLinks } from './handlers.ts'
7
7
 
8
8
  interface CachedTree {
@@ -58,7 +58,7 @@ export function cachedTree(viewer: Viewer, doc: IRDocument, node: MdastNode, pre
58
58
  const tree = markdownToHast(node, prefix, links)
59
59
  // Only immutable HAST survives component unmounts; no VNodes or mounted resources are retained.
60
60
  const bytes = JSON.stringify(tree).length * 2 // UTF-16 JSON size is a soft heap estimate.
61
- if (bytes > TREE_BYTES) return tree
61
+ if (bytes > TREE_BYTES || isPartialTree(tree)) return tree
62
62
  const entry: CachedTree = { node, key, tree, bytes }
63
63
  const variants = cache.nodes.get(node) ?? new Map<string, CachedTree>()
64
64
  variants.set(key, entry)
@@ -1,5 +1,6 @@
1
1
  import { FocusScope } from '@radix-ui/react-focus-scope'
2
- import { PmApprovals, PmCaption, PmCaptionProvider, PmIcon, TIMED_CAPTIONS, useService, PLAYBACK_CONTROL, presmiumService } from '@lincyaw/host'
2
+ import { PAGE_READINESS, PmApprovals, PmCaption, PmCaptionProvider, PmIcon, TIMED_CAPTIONS, useService, PLAYBACK_CONTROL, presmiumService } from '@lincyaw/host'
3
+ import { notifyEmbedder } from '@lincyaw/kernel'
3
4
  import { useEffect, useRef, useState } from 'react'
4
5
  import { useStore } from '@lincyaw/host'
5
6
  import { h } from '../element.ts'
@@ -39,6 +40,17 @@ export function PresentView() {
39
40
  const [fullscreen, setFullscreen] = useState(false)
40
41
  const [captions, setCaptions] = useState(true)
41
42
  const timedCaptions = useStore(useService(TIMED_CAPTIONS)?.active, false)
43
+ // The page that embeds the presentation learns when the current slide is drawn: no component still loading,
44
+ // checked after the slide's components have mounted and taken their holds.
45
+ const loading = useStore(useService(PAGE_READINESS)?.pending, 0)
46
+ useEffect(() => {
47
+ if (loading > 0) {
48
+ notifyEmbedder({ drawn: false, section: section?.id, step })
49
+ return
50
+ }
51
+ const frame = requestAnimationFrame(() => notifyEmbedder({ drawn: true, section: section?.id, step }))
52
+ return () => cancelAnimationFrame(frame)
53
+ }, [loading, section?.id, step])
42
54
  const overview = useStore(overviewOpen)
43
55
  const doc = useStore(viewer.doc)
44
56
  useStore(viewer.options.confirmedRevision)