@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/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,5 @@
1
+ import { useTheme } from '@lincyaw/host'
2
+
3
+ export function useDark() {
4
+ return useTheme().dark
5
+ }
@@ -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
+ }