@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
package/src/code.ts
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { currentLocale, OPERATIONS, usePrefersReducedMotion, useService, useStartReport } from '@lincyaw/host'
|
|
2
|
+
import type { Operations } from '@lincyaw/host'
|
|
3
|
+
import { blockState, EXECUTOR, errorMessage, failedRun } from '@lincyaw/kernel'
|
|
4
|
+
import type { CodeNode, IRBlock, RecordedRun, RunRequest, RunResult } from '@lincyaw/kernel'
|
|
5
|
+
import { ShikiMagicMove } from '@shikijs/magic-move/vue'
|
|
6
|
+
import '@shikijs/magic-move/style.css'
|
|
7
|
+
import type { Element, ElementContent, Root } from 'hast'
|
|
8
|
+
import { toHtml } from 'hast-util-to-html'
|
|
9
|
+
import type { PropType, VNode, VNodeChild } from 'vue'
|
|
10
|
+
import { computed, defineComponent, h, inject, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
|
|
11
|
+
import { IN_COMPONENT, useSectionContext } from './context.ts'
|
|
12
|
+
import { useDark } from './dark.ts'
|
|
13
|
+
import { codeColumns, originParts } from './domain/index.ts'
|
|
14
|
+
import type { OriginPart } from './domain/index.ts'
|
|
15
|
+
import { highlighterFor, THEMES } from './highlight.ts'
|
|
16
|
+
import { messages } from './i18n.ts'
|
|
17
|
+
import { proseOnly, runOutput } from './outputs.ts'
|
|
18
|
+
|
|
19
|
+
type Loaded = Awaited<ReturnType<typeof highlighterFor>>
|
|
20
|
+
|
|
21
|
+
function useHighlighter(lang: () => string) {
|
|
22
|
+
const shiki = shallowRef<Loaded>()
|
|
23
|
+
const load = async () => {
|
|
24
|
+
const wanted = lang()
|
|
25
|
+
const loaded = await highlighterFor(wanted).catch(() => undefined)
|
|
26
|
+
if (wanted === lang()) shiki.value = loaded
|
|
27
|
+
}
|
|
28
|
+
onMounted(load)
|
|
29
|
+
watch(lang, load)
|
|
30
|
+
return shiki
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function lineElements(tree: Root): Element[] {
|
|
34
|
+
const pre = tree.children.find((node): node is Element => node.type === 'element')
|
|
35
|
+
const code = pre?.children.find((node): node is Element => node.type === 'element' && node.tagName === 'code')
|
|
36
|
+
return (code?.children ?? []).filter((node: ElementContent): node is Element => node.type === 'element')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const CodeView = defineComponent({
|
|
40
|
+
name: 'PmCodeView',
|
|
41
|
+
props: {
|
|
42
|
+
lang: { type: String, default: '' },
|
|
43
|
+
value: { type: String, required: true },
|
|
44
|
+
highlight: { type: Array as PropType<number[]>, default: undefined },
|
|
45
|
+
},
|
|
46
|
+
setup(props) {
|
|
47
|
+
const shiki = useHighlighter(() => props.lang)
|
|
48
|
+
const code = computed(() => props.value.replace(/\n$/, ''))
|
|
49
|
+
const lines = computed(() => new Set(props.highlight ?? []))
|
|
50
|
+
const tree = computed(() => shiki.value && shiki.value.highlighter.codeToHast(code.value, { lang: shiki.value.lang, themes: THEMES, defaultColor: false }))
|
|
51
|
+
const html = computed(() => {
|
|
52
|
+
if (!tree.value) return undefined
|
|
53
|
+
const marked = lines.value
|
|
54
|
+
lineElements(tree.value).forEach((line, i) => {
|
|
55
|
+
line.properties.class = marked.has(i + 1) ? 'line highlighted' : 'line'
|
|
56
|
+
})
|
|
57
|
+
return toHtml(tree.value)
|
|
58
|
+
})
|
|
59
|
+
const columns = computed(() => codeColumns(code.value))
|
|
60
|
+
return () => {
|
|
61
|
+
const attrs = {
|
|
62
|
+
'class': ['pm-code', { 'pm-code--highlight': lines.value.size > 0 }],
|
|
63
|
+
'style': { '--pm-code-columns': columns.value },
|
|
64
|
+
'data-lang': props.lang || undefined,
|
|
65
|
+
}
|
|
66
|
+
if (html.value) return h('div', { ...attrs, key: 'shiki', innerHTML: html.value })
|
|
67
|
+
const rows = code.value.split('\n').flatMap((text, i) => [h('span', { class: ['line', { highlighted: lines.value.has(i + 1) }] }, text), '\n'])
|
|
68
|
+
return h('div', { ...attrs, key: 'plain' }, [h('pre', { class: 'shiki' }, [h('code', rows.slice(0, -1))])])
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const VariantView = defineComponent({
|
|
74
|
+
name: 'PmCodeVariant',
|
|
75
|
+
props: {
|
|
76
|
+
lang: { type: String, default: '' },
|
|
77
|
+
value: { type: String, required: true },
|
|
78
|
+
},
|
|
79
|
+
setup(props) {
|
|
80
|
+
const shiki = useHighlighter(() => props.lang)
|
|
81
|
+
const reduced = usePrefersReducedMotion()
|
|
82
|
+
const dark = useDark()
|
|
83
|
+
const code = computed(() => props.value.replace(/\n$/, ''))
|
|
84
|
+
return () => {
|
|
85
|
+
const loaded = shiki.value
|
|
86
|
+
if (!loaded || reduced.value) return h(CodeView, { lang: props.lang, value: props.value })
|
|
87
|
+
return h('div', { 'class': 'pm-code pm-code--magic-move', 'style': { '--pm-code-columns': codeColumns(code.value) }, 'data-lang': props.lang || undefined }, [
|
|
88
|
+
h(ShikiMagicMove, { highlighter: loaded.highlighter, lang: loaded.lang, theme: dark.value ? THEMES.dark : THEMES.light, code: code.value, options: { duration: 500 } }),
|
|
89
|
+
])
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
function link(className: string, href: string | undefined, text: string, attrs: Record<string, unknown> = {}): VNode {
|
|
95
|
+
return href
|
|
96
|
+
? h('a', { ...attrs, class: className, href, target: '_blank', rel: 'noopener' }, text)
|
|
97
|
+
: h('span', { ...attrs, class: className }, text)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function originPart(part: OriginPart, k: number): VNode {
|
|
101
|
+
return link(`pm-code-origin__${part.kind}`, part.href, part.text, {
|
|
102
|
+
'key': `${part.kind}:${k}`,
|
|
103
|
+
'title': part.title,
|
|
104
|
+
'data-active': part.active ? '' : undefined,
|
|
105
|
+
'data-status': part.status,
|
|
106
|
+
'data-drift': part.drift,
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function originBar(node: CodeNode, shown: number | undefined): VNodeChild {
|
|
111
|
+
const bar = originParts(node, shown, currentLocale())
|
|
112
|
+
return bar ? h('div', { 'class': 'pm-code-origin', 'data-anchor': bar.anchor }, bar.parts.map(originPart)) : null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const replays = new Set<(data: Record<string, unknown>) => void>()
|
|
116
|
+
const followed = new WeakSet<Operations>()
|
|
117
|
+
|
|
118
|
+
function followRuns(operations: Operations): void {
|
|
119
|
+
if (followed.has(operations)) return
|
|
120
|
+
followed.add(operations)
|
|
121
|
+
operations.onReplay('run', { apply: data => replays.forEach(replay => replay(data)) })
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const CodeBlockView = defineComponent({
|
|
125
|
+
name: 'PmCodeBlock',
|
|
126
|
+
props: {
|
|
127
|
+
block: { type: Object as PropType<IRBlock>, required: true },
|
|
128
|
+
step: { type: Number, required: true },
|
|
129
|
+
expanded: Boolean,
|
|
130
|
+
},
|
|
131
|
+
setup(props) {
|
|
132
|
+
const owned = inject(IN_COMPONENT, false)
|
|
133
|
+
const executor = useService(EXECUTOR)
|
|
134
|
+
const operations = useService(OPERATIONS)
|
|
135
|
+
const section = useSectionContext()
|
|
136
|
+
const report = useStartReport()
|
|
137
|
+
const live = shallowRef<RunResult>()
|
|
138
|
+
const fallback = ref<string>()
|
|
139
|
+
const running = ref(false)
|
|
140
|
+
const mounted = ref(false)
|
|
141
|
+
let current: AbortController | undefined
|
|
142
|
+
const stop = () => {
|
|
143
|
+
current?.abort()
|
|
144
|
+
current = undefined
|
|
145
|
+
running.value = false
|
|
146
|
+
}
|
|
147
|
+
onMounted(() => {
|
|
148
|
+
mounted.value = true
|
|
149
|
+
})
|
|
150
|
+
onBeforeUnmount(stop)
|
|
151
|
+
const show = (result: RunResult | undefined) => {
|
|
152
|
+
const node = props.block.node as CodeNode
|
|
153
|
+
const recorded = result && !result.ok && result.exitCode === null && node.outputs?.length
|
|
154
|
+
fallback.value = recorded ? result.stderr.trim() || messages().code.unavailable : undefined
|
|
155
|
+
live.value = recorded ? undefined : result
|
|
156
|
+
}
|
|
157
|
+
const run = async () => {
|
|
158
|
+
const node = props.block.node as CodeNode
|
|
159
|
+
const router = executor.value
|
|
160
|
+
if (!node.run || !router || running.value) return
|
|
161
|
+
stop()
|
|
162
|
+
const controller = new AbortController()
|
|
163
|
+
current = controller
|
|
164
|
+
running.value = true
|
|
165
|
+
const { lang, args, stdin, timeoutMs, executor: named, session } = node.run
|
|
166
|
+
const request: RunRequest = { lang, code: node.value, args, stdin, timeoutMs, executor: named, session }
|
|
167
|
+
const started = Date.now()
|
|
168
|
+
let result: RunResult
|
|
169
|
+
try {
|
|
170
|
+
result = await router.run(request, controller.signal)
|
|
171
|
+
} catch (error) {
|
|
172
|
+
result = failedRun(router.id, messages().code.executorFailed(router.id, errorMessage(error)), Date.now() - started)
|
|
173
|
+
}
|
|
174
|
+
if (controller.signal.aborted) return
|
|
175
|
+
show(result)
|
|
176
|
+
current = undefined
|
|
177
|
+
running.value = false
|
|
178
|
+
const recording = node.outputs?.[0] as RecordedRun | undefined
|
|
179
|
+
operations.value?.report({ type: 'run', data: { section: section?.section.value.id, block: props.block.id, result: fallback.value && recording ? `freeze:${recording.cacheKey}` : 'live' } })
|
|
180
|
+
}
|
|
181
|
+
const reset = async () => {
|
|
182
|
+
const node = props.block.node as CodeNode
|
|
183
|
+
if (!node.run?.session) return
|
|
184
|
+
stop()
|
|
185
|
+
await executor.value?.endSession?.(node.run.session)
|
|
186
|
+
show(undefined)
|
|
187
|
+
}
|
|
188
|
+
const replay = (data: Record<string, unknown>) => {
|
|
189
|
+
if (data.block !== props.block.id || (data.section !== undefined && data.section !== section?.section.value.id)) return
|
|
190
|
+
stop()
|
|
191
|
+
show(undefined)
|
|
192
|
+
}
|
|
193
|
+
watch(operations, (ops) => {
|
|
194
|
+
if (ops) followRuns(ops)
|
|
195
|
+
}, { immediate: true })
|
|
196
|
+
onMounted(() => replays.add(replay))
|
|
197
|
+
onBeforeUnmount(() => replays.delete(replay))
|
|
198
|
+
watch(() => (props.block.node as CodeNode).value, () => {
|
|
199
|
+
stop()
|
|
200
|
+
show(undefined)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
return () => {
|
|
204
|
+
const node = props.block.node as CodeNode
|
|
205
|
+
const state = blockState(props.block, props.step)
|
|
206
|
+
const variants = node.variants ?? []
|
|
207
|
+
const shown = variants.length && !props.expanded ? Math.min(state, variants.length - 1) : undefined
|
|
208
|
+
const listings = variants.length === 0
|
|
209
|
+
? [h(CodeView, { lang: node.lang, value: node.value, highlight: props.expanded ? undefined : node.highlight?.[state] })]
|
|
210
|
+
: shown === undefined
|
|
211
|
+
? variants.map(v => h('div', { key: v.label, class: 'pm-code-block__variant' }, [h('p', { class: 'pm-code-block__label' }, v.label), h(CodeView, { lang: node.lang, value: v.value })]))
|
|
212
|
+
: [h(VariantView, { key: 'variant', lang: node.lang, value: variants[shown]!.value })]
|
|
213
|
+
const echo = node.echo !== false
|
|
214
|
+
const runnable = !owned && echo && node.run && node.run.when !== 'compile'
|
|
215
|
+
const t = messages().code
|
|
216
|
+
const available = mounted.value && executor.value
|
|
217
|
+
const controls = runnable
|
|
218
|
+
? h('div', { class: 'pm-code-block__run' }, [
|
|
219
|
+
h('button', { type: 'button', disabled: running.value || !available, onClick: run }, running.value ? t.running : live.value ? t.rerun : t.run),
|
|
220
|
+
node.run!.session ? h('button', { 'type': 'button', 'class': 'pm-code-block__reset', 'disabled': !available, 'data-session': node.run!.session, 'onClick': reset }, t.resetSession) : null,
|
|
221
|
+
mounted.value && !executor.value && report.value ? h('span', { class: 'pm-code-block__hint' }, t.unavailable) : null,
|
|
222
|
+
])
|
|
223
|
+
: null
|
|
224
|
+
const runs = owned ? [] : live.value ? [live.value] : node.outputs ?? []
|
|
225
|
+
const prose = !echo && !live.value && runs.length > 0 && runs.every(proseOnly)
|
|
226
|
+
const prefix = section?.section.value.id ?? props.block.id
|
|
227
|
+
const outputs = runs.length
|
|
228
|
+
? h('div', { 'class': ['pm-outputs', { 'pm-outputs--prose': prose }], 'data-source': live.value ? 'live' : 'recorded' }, runs.map((r, i) => runOutput(r, (r as RecordedRun).cacheKey || i, prefix, { prose, note: fallback.value })))
|
|
229
|
+
: null
|
|
230
|
+
return h('div', { 'class': ['pm-code-block', { 'pm-code-block--output-only': !echo }], 'data-state': props.expanded ? undefined : state, 'data-run': runnable ? node.run!.when : undefined, 'data-session': node.run?.session }, echo
|
|
231
|
+
? [originBar(node, shown), ...listings, controls, outputs]
|
|
232
|
+
: [outputs])
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
})
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { IRSection } from '@lincyaw/kernel'
|
|
2
|
+
import type { InjectionKey, Ref } from 'vue'
|
|
3
|
+
import { inject } from 'vue'
|
|
4
|
+
|
|
5
|
+
export interface SectionContext {
|
|
6
|
+
section: Readonly<Ref<IRSection>>
|
|
7
|
+
step: Readonly<Ref<number>>
|
|
8
|
+
view: Readonly<Ref<string>>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const SECTION_CONTEXT: InjectionKey<SectionContext> = Symbol('presmium.viewer.section')
|
|
12
|
+
export const IN_COMPONENT: InjectionKey<boolean> = Symbol('presmium.viewer.inComponent')
|
|
13
|
+
|
|
14
|
+
export function useSectionContext(): SectionContext | undefined {
|
|
15
|
+
return inject(SECTION_CONTEXT, undefined)
|
|
16
|
+
}
|
package/src/dark.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { stepStateAt } from '@lincyaw/kernel/contracts'
|
|
2
|
+
|
|
3
|
+
export interface ClickSpec {
|
|
4
|
+
kind: 'click' | 'after'
|
|
5
|
+
at?: unknown
|
|
6
|
+
hide?: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ClickRange {
|
|
10
|
+
from: number
|
|
11
|
+
to?: number
|
|
12
|
+
hide: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isRelative(at: unknown): at is string {
|
|
16
|
+
return typeof at === 'string' && /^[+-]\d+$/.test(at.trim())
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resolveClicks(specs: readonly ClickSpec[]): ClickRange[] {
|
|
20
|
+
let last = 0
|
|
21
|
+
const position = (at: unknown, base: number): number => {
|
|
22
|
+
if (isRelative(at)) return base + Number(at.trim())
|
|
23
|
+
const value = Number(at)
|
|
24
|
+
return Number.isFinite(value) ? value : base + 1
|
|
25
|
+
}
|
|
26
|
+
return specs.map((spec) => {
|
|
27
|
+
const at = spec.kind === 'after' ? '+0' : spec.at ?? '+1'
|
|
28
|
+
const [enter, leave] = Array.isArray(at) ? at : [at]
|
|
29
|
+
const from = position(enter, last)
|
|
30
|
+
if (isRelative(enter) || !Number.isFinite(Number(enter))) last = from
|
|
31
|
+
const to = leave === undefined ? undefined : position(leave, from)
|
|
32
|
+
return { from, to, hide: spec.hide ?? false }
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function clickVisible(range: ClickRange, step: number): boolean {
|
|
37
|
+
return stepStateAt(range, step).active !== range.hide
|
|
38
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { stepTrail, trailStep } from '@lincyaw/kernel'
|
|
2
|
+
import type { IRSection, StepKeys } from '@lincyaw/kernel'
|
|
3
|
+
|
|
4
|
+
export { stepKeys, stepTrail, trailStep } from '@lincyaw/kernel'
|
|
5
|
+
export type { StepKeys } from '@lincyaw/kernel'
|
|
6
|
+
|
|
7
|
+
export interface FollowAnchor {
|
|
8
|
+
section?: string
|
|
9
|
+
trail?: string[]
|
|
10
|
+
back?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const LEARNERS = 'student'
|
|
14
|
+
|
|
15
|
+
export function backSection(sections: readonly Pick<IRSection, 'id' | 'audience'>[], id: string): string | undefined {
|
|
16
|
+
const at = sections.findIndex(section => section.id === id)
|
|
17
|
+
const audience = sections[at]?.audience
|
|
18
|
+
if (!audience?.length) return undefined
|
|
19
|
+
const learners = !audience.includes(LEARNERS)
|
|
20
|
+
return sections.slice(0, at).findLast(section => !section.audience?.length || (learners && section.audience.includes(LEARNERS)))?.id
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function followedPlace(position: FollowAnchor & { step: number }, keysOf: (id: string) => StepKeys | undefined): { section: string, step: number } | undefined {
|
|
24
|
+
const { section, trail, back, step } = position
|
|
25
|
+
const keys = section === undefined ? undefined : keysOf(section)
|
|
26
|
+
if (keys) {
|
|
27
|
+
const other = trail !== undefined && stepTrail(keys, step).join('\n') !== trail.join('\n')
|
|
28
|
+
return { section: section!, step: other ? trailStep(keys, trail) : step }
|
|
29
|
+
}
|
|
30
|
+
const before = back === undefined ? undefined : keysOf(back)
|
|
31
|
+
return before && { section: back!, step: before.length - 1 }
|
|
32
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export { clickVisible, resolveClicks } from './clicks.ts'
|
|
2
|
+
export type { ClickRange, ClickSpec } from './clicks.ts'
|
|
3
|
+
export { backSection, followedPlace, stepKeys, stepTrail, trailStep } from './follow.ts'
|
|
4
|
+
export type { FollowAnchor, StepKeys } from './follow.ts'
|
|
5
|
+
export { applyUpdate, diffDocuments, sectionDiagnostics } from './live.ts'
|
|
6
|
+
export type { LiveUpdate } from './live.ts'
|
|
7
|
+
export { VIEWER_MESSAGES } from './messages.ts'
|
|
8
|
+
export type { Messages } from './messages.ts'
|
|
9
|
+
export { ORIGIN_MESSAGES, originParts, outputNotes } from './origin.ts'
|
|
10
|
+
export type { OriginBar, OriginMessages, OriginPart } from './origin.ts'
|
|
11
|
+
export {
|
|
12
|
+
clampPosition,
|
|
13
|
+
FINAL_STEP,
|
|
14
|
+
itemVisible,
|
|
15
|
+
keepPosition,
|
|
16
|
+
nextPosition,
|
|
17
|
+
prevPosition,
|
|
18
|
+
stepAtLine,
|
|
19
|
+
} from './steps.ts'
|
|
20
|
+
export type { Position } from './steps.ts'
|
|
21
|
+
export { codeColumns, footnoteSections } from './text.ts'
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Finding, IRDocument, IRSection } from '@lincyaw/kernel'
|
|
2
|
+
|
|
3
|
+
export interface LiveUpdate {
|
|
4
|
+
order: string[]
|
|
5
|
+
sections: IRSection[]
|
|
6
|
+
diagnostics: Finding[]
|
|
7
|
+
meta?: IRDocument['meta']
|
|
8
|
+
config?: IRDocument['config']
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function key(value: unknown): string {
|
|
12
|
+
return JSON.stringify(value)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function diffDocuments(prev: IRDocument, next: IRDocument): LiveUpdate | undefined {
|
|
16
|
+
const old = new Map(prev.sections.map(s => [s.id, key(s)]))
|
|
17
|
+
const sections = next.sections.filter(s => old.get(s.id) !== key(s))
|
|
18
|
+
const order = next.sections.map(s => s.id)
|
|
19
|
+
const update: LiveUpdate = { order, sections, diagnostics: next.diagnostics }
|
|
20
|
+
if (key(prev.meta) !== key(next.meta)) update.meta = next.meta
|
|
21
|
+
if (key(prev.config) !== key(next.config)) update.config = next.config
|
|
22
|
+
const reordered = key(order) !== key(prev.sections.map(s => s.id))
|
|
23
|
+
const changed = sections.length > 0 || reordered || update.meta || update.config || key(prev.diagnostics) !== key(next.diagnostics)
|
|
24
|
+
return changed ? update : undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function applyUpdate(doc: IRDocument, update: LiveUpdate): IRDocument {
|
|
28
|
+
const known = new Map(doc.sections.map(s => [s.id, s]))
|
|
29
|
+
for (const section of update.sections) known.set(section.id, section)
|
|
30
|
+
return {
|
|
31
|
+
...doc,
|
|
32
|
+
meta: update.meta ?? doc.meta,
|
|
33
|
+
config: update.config ?? doc.config,
|
|
34
|
+
sections: update.order.flatMap(id => known.get(id) ?? []),
|
|
35
|
+
diagnostics: update.diagnostics,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function sectionDiagnostics(section: IRSection, diagnostics: readonly Finding[]): Finding[] {
|
|
40
|
+
const ranges = [section.source, ...section.blocks.map(b => b.source)]
|
|
41
|
+
return diagnostics.filter(({ source }) => source?.line !== undefined && ranges.some(range =>
|
|
42
|
+
range.file === source.file && source.line! >= range.line && source.line! <= (range.endLine ?? range.line)))
|
|
43
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { Locale } from '@lincyaw/kernel/contracts'
|
|
2
|
+
|
|
3
|
+
const zhStep = (step: number, steps: number) => `第 ${step + 1} 步,共 ${steps + 1} 步`
|
|
4
|
+
|
|
5
|
+
const zh = {
|
|
6
|
+
views: { document: '文档', scrolly: '滚动叙事', present: '放映', presenter: '演讲者' },
|
|
7
|
+
present: {
|
|
8
|
+
region: '放映',
|
|
9
|
+
counter: (index: number, total: number, step: number, steps: number) => `${index} / ${total}${steps > 0 ? ` · ${zhStep(step, steps)}` : ''}`,
|
|
10
|
+
prev: '上一步',
|
|
11
|
+
next: '下一步',
|
|
12
|
+
overview: '总览',
|
|
13
|
+
presenter: '演讲者窗口',
|
|
14
|
+
fullscreen: '全屏',
|
|
15
|
+
exit: '退出放映',
|
|
16
|
+
},
|
|
17
|
+
presenter: {
|
|
18
|
+
step: (step: number, steps: number) => (steps > 0 ? zhStep(step, steps) : ''),
|
|
19
|
+
timer: '计时',
|
|
20
|
+
restart: '重新计时',
|
|
21
|
+
notes: '讲解',
|
|
22
|
+
noNotes: '本节没有讲解',
|
|
23
|
+
upcoming: '下一步',
|
|
24
|
+
end: '已到结尾',
|
|
25
|
+
nextSection: (title: string) => `下一节:${title}`,
|
|
26
|
+
nextStep: (step: number) => `第 ${step + 1} 步`,
|
|
27
|
+
},
|
|
28
|
+
section: {
|
|
29
|
+
anchor: (title: string) => `链接到“${title}”`,
|
|
30
|
+
openSource: '在编辑器中打开源码',
|
|
31
|
+
sourceTitle: (where: string) => `${where}(Alt+单击任一块,打开该块的源码)`,
|
|
32
|
+
},
|
|
33
|
+
code: {
|
|
34
|
+
executorFailed: (id: string, message: string) => `执行器 ${id} 出错:${message}`,
|
|
35
|
+
run: '运行',
|
|
36
|
+
rerun: '重跑',
|
|
37
|
+
running: '运行中…',
|
|
38
|
+
unavailable: '执行服务不可用',
|
|
39
|
+
resetSession: '重置会话',
|
|
40
|
+
},
|
|
41
|
+
markdown: {
|
|
42
|
+
admonitions: {
|
|
43
|
+
note: '注意',
|
|
44
|
+
tip: '提示',
|
|
45
|
+
hint: '提示',
|
|
46
|
+
important: '重要',
|
|
47
|
+
warning: '警告',
|
|
48
|
+
caution: '小心',
|
|
49
|
+
attention: '注意',
|
|
50
|
+
danger: '危险',
|
|
51
|
+
error: '错误',
|
|
52
|
+
seealso: '另请参阅',
|
|
53
|
+
} as Record<string, string>,
|
|
54
|
+
footnote: (label: string) => `脚注 ${label}`,
|
|
55
|
+
},
|
|
56
|
+
missingComponent: (name: string) => `未注册的组件 ${name}`,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type Messages = typeof zh
|
|
60
|
+
|
|
61
|
+
const en: Messages = {
|
|
62
|
+
views: { document: 'Document', scrolly: 'Scrollytelling', present: 'Present', presenter: 'Presenter' },
|
|
63
|
+
present: {
|
|
64
|
+
region: 'Presentation',
|
|
65
|
+
counter: (index, total, step, steps) => `${index} / ${total}${steps > 0 ? ` · step ${step + 1} of ${steps + 1}` : ''}`,
|
|
66
|
+
prev: 'Previous',
|
|
67
|
+
next: 'Next',
|
|
68
|
+
overview: 'Overview',
|
|
69
|
+
presenter: 'Presenter window',
|
|
70
|
+
fullscreen: 'Full screen',
|
|
71
|
+
exit: 'Exit presentation',
|
|
72
|
+
},
|
|
73
|
+
presenter: {
|
|
74
|
+
step: (step, steps) => (steps > 0 ? `Step ${step + 1} of ${steps + 1}` : ''),
|
|
75
|
+
timer: 'Timer',
|
|
76
|
+
restart: 'Restart timer',
|
|
77
|
+
notes: 'Narration',
|
|
78
|
+
noNotes: 'No narration in this section',
|
|
79
|
+
upcoming: 'Next',
|
|
80
|
+
end: 'End of the document',
|
|
81
|
+
nextSection: title => `Next section: ${title}`,
|
|
82
|
+
nextStep: step => `Step ${step + 1}`,
|
|
83
|
+
},
|
|
84
|
+
section: {
|
|
85
|
+
anchor: title => `Link to "${title}"`,
|
|
86
|
+
openSource: 'Open the source in the editor',
|
|
87
|
+
sourceTitle: where => `${where} (Alt+click a block to open its source)`,
|
|
88
|
+
},
|
|
89
|
+
code: {
|
|
90
|
+
executorFailed: (id, message) => `Executor ${id} failed: ${message}`,
|
|
91
|
+
run: 'Run',
|
|
92
|
+
rerun: 'Run again',
|
|
93
|
+
running: 'Running…',
|
|
94
|
+
unavailable: 'Execution service unavailable',
|
|
95
|
+
resetSession: 'Reset session',
|
|
96
|
+
},
|
|
97
|
+
markdown: {
|
|
98
|
+
admonitions: {
|
|
99
|
+
note: 'Note',
|
|
100
|
+
tip: 'Tip',
|
|
101
|
+
hint: 'Hint',
|
|
102
|
+
important: 'Important',
|
|
103
|
+
warning: 'Warning',
|
|
104
|
+
caution: 'Caution',
|
|
105
|
+
attention: 'Attention',
|
|
106
|
+
danger: 'Danger',
|
|
107
|
+
error: 'Error',
|
|
108
|
+
seealso: 'See also',
|
|
109
|
+
},
|
|
110
|
+
footnote: label => `Footnote ${label}`,
|
|
111
|
+
},
|
|
112
|
+
missingComponent: name => `Unregistered component ${name}`,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const VIEWER_MESSAGES: Record<Locale, Messages> = { 'zh-CN': zh, 'en': en }
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { CodeNode, ResolutionStatus, RunResult } from '@lincyaw/kernel'
|
|
2
|
+
import type { Locale } from '@lincyaw/kernel/contracts'
|
|
3
|
+
|
|
4
|
+
const zh = {
|
|
5
|
+
workingTree: '工作区',
|
|
6
|
+
status: { moved: '已移动', fuzzy: '待确认', lost: '已失效' } as Partial<Record<ResolutionStatus, string>>,
|
|
7
|
+
statusTitle: {
|
|
8
|
+
moved: '引用的代码在文件中移动了位置,显示的是移动后的代码',
|
|
9
|
+
fuzzy: '引用的代码有改动,找到的位置有待确认;运行 presmium check 查看',
|
|
10
|
+
lost: '找不到引用的代码;运行 presmium check 查看',
|
|
11
|
+
} as Partial<Record<ResolutionStatus, string>>,
|
|
12
|
+
drifted: '已漂移',
|
|
13
|
+
driftedTitle: '工作区中的代码已改变,运行 presmium check 查看差异',
|
|
14
|
+
synced: '已锁定',
|
|
15
|
+
syncedTitle: '锁定的代码与工作区一致',
|
|
16
|
+
openFile: (file: string) => `在仓库中打开 ${file}`,
|
|
17
|
+
timedOut: '超时',
|
|
18
|
+
exitCode: (code: number) => `退出码 ${code}`,
|
|
19
|
+
recorded: '预录输出',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type OriginMessages = typeof zh
|
|
23
|
+
|
|
24
|
+
const en: OriginMessages = {
|
|
25
|
+
workingTree: 'working tree',
|
|
26
|
+
status: { moved: 'Moved', fuzzy: 'Unconfirmed', lost: 'Lost' },
|
|
27
|
+
statusTitle: {
|
|
28
|
+
moved: 'The referenced code moved in its file; this is the code where it is now',
|
|
29
|
+
fuzzy: 'The referenced code changed and the place found is uncertain; run presmium check to see it',
|
|
30
|
+
lost: 'The referenced code is no longer found; run presmium check to see why',
|
|
31
|
+
},
|
|
32
|
+
drifted: 'Drifted',
|
|
33
|
+
driftedTitle: 'The code changed in the working tree; run presmium check to see the difference',
|
|
34
|
+
synced: 'Pinned',
|
|
35
|
+
syncedTitle: 'The pinned code matches the working tree',
|
|
36
|
+
openFile: file => `Open ${file} in the repository`,
|
|
37
|
+
timedOut: 'Timed out',
|
|
38
|
+
exitCode: code => `Exit code ${code}`,
|
|
39
|
+
recorded: 'Recorded output',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const ORIGIN_MESSAGES: Record<Locale, OriginMessages> = { 'zh-CN': zh, 'en': en }
|
|
43
|
+
|
|
44
|
+
export interface OriginPart {
|
|
45
|
+
kind: 'file' | 'variant' | 'commit' | 'badge'
|
|
46
|
+
text: string
|
|
47
|
+
href?: string
|
|
48
|
+
title?: string
|
|
49
|
+
active?: boolean
|
|
50
|
+
status?: ResolutionStatus
|
|
51
|
+
drift?: 'synced' | 'drifted'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface OriginBar {
|
|
55
|
+
anchor?: string
|
|
56
|
+
parts: OriginPart[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function lines(start: number, end = start): string {
|
|
60
|
+
return end > start ? `${start}-${end}` : `${start}`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function originParts(node: CodeNode, shown: number | undefined, locale: Locale): OriginBar | undefined {
|
|
64
|
+
const t = ORIGIN_MESSAGES[locale]
|
|
65
|
+
const { origin } = node
|
|
66
|
+
if (!origin) return node.title ? { parts: [{ kind: 'file', text: node.title }] } : undefined
|
|
67
|
+
const variants = node.variants ?? []
|
|
68
|
+
const version = shown === undefined ? undefined : variants[shown]
|
|
69
|
+
const href = version ? version.permalink : origin.permalink
|
|
70
|
+
const [start, end] = version?.startLine !== undefined ? [version.startLine, version.endLine] : [origin.startLine, origin.endLine]
|
|
71
|
+
const parts: OriginPart[] = [{ kind: 'file', text: `${node.title ?? origin.file}:${lines(start, end)}`, href, title: href ? t.openFile(origin.file) : origin.anchor }]
|
|
72
|
+
if (variants.length) {
|
|
73
|
+
variants.forEach((variant, k) => parts.push({ kind: 'variant', text: variant.label, href: variant.permalink, active: k === shown }))
|
|
74
|
+
} else {
|
|
75
|
+
parts.push({ kind: 'commit', text: origin.pin ? `@ ${origin.pin.slice(0, 7)}` : t.workingTree, title: origin.pin })
|
|
76
|
+
}
|
|
77
|
+
const status = t.status[origin.status]
|
|
78
|
+
if (status) parts.push({ kind: 'badge', text: status, title: t.statusTitle[origin.status], status: origin.status })
|
|
79
|
+
else if (origin.pin && !variants.length && origin.drift) {
|
|
80
|
+
const drifted = origin.drift === 'drifted'
|
|
81
|
+
parts.push({ kind: 'badge', text: drifted ? t.drifted : t.synced, title: drifted ? t.driftedTitle : t.syncedTitle, drift: origin.drift })
|
|
82
|
+
}
|
|
83
|
+
return { anchor: origin.anchor, parts }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function outputNotes(run: RunResult, locale: Locale): string[] {
|
|
87
|
+
const t = ORIGIN_MESSAGES[locale]
|
|
88
|
+
return [
|
|
89
|
+
run.timedOut ? t.timedOut : '',
|
|
90
|
+
run.exitCode !== null && run.exitCode !== 0 ? t.exitCode(run.exitCode) : '',
|
|
91
|
+
run.source === 'recorded' ? t.recorded : '',
|
|
92
|
+
].filter(Boolean)
|
|
93
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { IRBlock, IRDocument } from '@lincyaw/kernel'
|
|
2
|
+
|
|
3
|
+
export const FINAL_STEP = Number.POSITIVE_INFINITY
|
|
4
|
+
|
|
5
|
+
export interface Position {
|
|
6
|
+
section: string
|
|
7
|
+
step: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function itemVisible(block: IRBlock, item: number, step: number): boolean {
|
|
11
|
+
return step >= block.at + item
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function stepAtLine(markers: readonly { step: number, top: number }[], line: number): number {
|
|
15
|
+
return markers.reduce((step, marker) => (marker.top <= line ? Math.max(step, marker.step) : step), 0)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function clampPosition(doc: IRDocument, position: Position): Position {
|
|
19
|
+
const section = doc.sections.find(s => s.id === position.section) ?? doc.sections[0]
|
|
20
|
+
if (!section) return position
|
|
21
|
+
const step = Number.isFinite(position.step) ? Math.min(Math.max(Math.round(position.step), 0), section.steps) : section.steps
|
|
22
|
+
return { section: section.id, step }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function keepPosition(prev: IRDocument, next: IRDocument, position: Position): Position {
|
|
26
|
+
if (next.sections.some(s => s.id === position.section) || next.sections.length === 0) return clampPosition(next, position)
|
|
27
|
+
const at = prev.sections.findIndex(s => s.id === position.section)
|
|
28
|
+
const section = next.sections[Math.min(Math.max(at, 0), next.sections.length - 1)]!
|
|
29
|
+
return clampPosition(next, { section: section.id, step: position.step })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function nextPosition(doc: IRDocument, position: Position): Position | undefined {
|
|
33
|
+
const i = doc.sections.findIndex(s => s.id === position.section)
|
|
34
|
+
const section = doc.sections[i]
|
|
35
|
+
if (section && position.step < section.steps) return { section: section.id, step: position.step + 1 }
|
|
36
|
+
const next = doc.sections[i + 1]
|
|
37
|
+
return next ? { section: next.id, step: 0 } : undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function prevPosition(doc: IRDocument, position: Position): Position | undefined {
|
|
41
|
+
const i = doc.sections.findIndex(s => s.id === position.section)
|
|
42
|
+
if (i >= 0 && position.step > 0) return { section: position.section, step: position.step - 1 }
|
|
43
|
+
const prev = doc.sections[i - 1]
|
|
44
|
+
return prev ? { section: prev.id, step: prev.steps } : undefined
|
|
45
|
+
}
|