返回 slidev
operations.ts
根目录 / packages / slidev / node / mcp / operations.ts
1 import type { LoadedSlidevData } from '@slidev/parser/fs'
2 import type { SlideInfo, SlidePatch, SourceSlideInfo } from '@slidev/types'
3 import * as parser from '@slidev/parser/fs'
4 import YAML from 'yaml'
5 import { updateFrontmatterPatch } from '../utils'
6
7 /**
8 * Resolve a rendered slide (1-based, as displayed in the presentation) or
9 * throw a descriptive error.
10 */
11 export function resolveSlide(data: LoadedSlidevData, no: number): SlideInfo {
12 const slide = data.slides[no - 1]
13 if (!slide)
14 throw new Error(`Slide ${no} does not exist. The deck has ${data.slides.length} slides (1-${data.slides.length}).`)
15 return slide
16 }
17
18 function getMarkdown(data: LoadedSlidevData, source: SourceSlideInfo) {
19 const md = data.markdownFiles[source.filepath]
20 if (!md)
21 throw new Error(`Markdown file not loaded: ${source.filepath}`)
22 return md
23 }
24
25 function assertNotEntryHeadmatter(data: LoadedSlidevData, source: SourceSlideInfo, action: string) {
26 if (source.filepath === data.entry.filepath && data.entry.slides.indexOf(source) === 0) {
27 throw new Error(
28 `Cannot ${action} the first slide of the entry file: its frontmatter is the deck headmatter (global configuration). `
29 + `Edit its content with the update tool instead, or operate on the following slides.`,
30 )
31 }
32 }
33
34 export interface SlidePatchResult {
35 slide: SlideInfo
36 fileContent: string
37 }
38
39 /**
40 * Apply a `SlidePatch` to the slide source and save the markdown file.
41 *
42 * Note this only mutates the *source* slide (`slide.source`), not the
43 * rendered `SlideInfo`, so a running dev server will pick up the change from
44 * disk like an external edit and push HMR updates to connected clients.
45 */
46 export async function applySlidePatch(
47 data: LoadedSlidevData,
48 no: number,
49 patch: SlidePatch,
50 ): Promise<SlidePatchResult> {
51 const slide = resolveSlide(data, no)
52 const source = slide.source
53
54 if (patch.content != null)
55 source.content = patch.content
56 if (patch.frontmatterRaw != null) {
57 if (patch.frontmatterRaw.trim() === '') {
58 source.frontmatterDoc = source.frontmatterStyle = undefined
59 }
60 else {
61 const parsed = YAML.parseDocument(patch.frontmatterRaw)
62 if (parsed.errors.length)
63 throw new Error(`Invalid YAML frontmatter: ${parsed.errors.map(e => e.message).join('; ')}`)
64 source.frontmatterDoc = parsed
65 }
66 }
67 if (patch.note != null)
68 source.note = patch.note
69 if (patch.frontmatter)
70 updateFrontmatterPatch(source, patch.frontmatter)
71
72 parser.prettifySlide(source)
73 const fileContent = await parser.save(getMarkdown(data, source))
74 return { slide, fileContent }
75 }
76
77 export interface InsertSlideOptions {
78 /**
79 * Rendered slide number (1-based) after which the new slide is inserted.
80 * The new slide is inserted into the same markdown file as this slide.
81 */
82 after: number
83 content: string
84 frontmatter?: Record<string, any>
85 note?: string
86 }
87
88 export interface InsertSlideResult {
89 source: SourceSlideInfo
90 filepath: string
91 fileContent: string
92 }
93
94 /**
95 * Insert a new slide after an existing one, in the same markdown file.
96 */
97 export async function insertSlide(data: LoadedSlidevData, options: InsertSlideOptions): Promise<InsertSlideResult> {
98 const anchor = resolveSlide(data, options.after)
99 const md = getMarkdown(data, anchor.source)
100
101 const frontmatter = options.frontmatter ?? {}
102 const hasFrontmatter = Object.keys(frontmatter).length > 0
103 const doc = hasFrontmatter ? new YAML.Document(frontmatter) : undefined
104
105 const source: SourceSlideInfo = {
106 filepath: anchor.source.filepath,
107 index: 0, // recomputed on next load
108 start: 0,
109 contentStart: 0,
110 end: 0,
111 raw: '',
112 revision: '',
113 content: options.content,
114 contentRaw: options.content,
115 frontmatter,
116 frontmatterDoc: doc,
117 frontmatterStyle: doc ? 'frontmatter' : undefined,
118 frontmatterRaw: doc?.toString(),
119 note: options.note?.trim() || undefined,
120 }
121 parser.prettifySlide(source)
122
123 const anchorIdx = md.slides.indexOf(anchor.source)
124 md.slides.splice(anchorIdx + 1, 0, source)
125 const fileContent = await parser.save(md)
126 return { source, filepath: md.filepath, fileContent }
127 }
128
129 export interface RemoveSlideResult {
130 removed: SlideInfo
131 filepath: string
132 fileContent: string
133 }
134
135 /**
136 * Remove a slide from its source markdown file.
137 */
138 export async function removeSlide(data: LoadedSlidevData, no: number): Promise<RemoveSlideResult> {
139 const slide = resolveSlide(data, no)
140 assertNotEntryHeadmatter(data, slide.source, 'remove')
141 const md = getMarkdown(data, slide.source)
142 const idx = md.slides.indexOf(slide.source)
143 if (idx < 0)
144 throw new Error(`Slide ${no} is out of sync with its source file. Try again.`)
145 md.slides.splice(idx, 1)
146 const fileContent = await parser.save(md)
147 return { removed: slide, filepath: md.filepath, fileContent }
148 }
149
150 export interface MoveSlideOptions {
151 /** Rendered slide number (1-based) of the slide to move */
152 from: number
153 /** Move the slide right before this rendered slide number */
154 before?: number
155 /** Move the slide right after this rendered slide number */
156 after?: number
157 }
158
159 export interface MoveSlideResult {
160 moved: SlideInfo
161 anchor: SlideInfo
162 filepath: string
163 fileContent: string
164 }
165
166 /**
167 * Move a slide before or after another slide within the same markdown file.
168 */
169 export async function moveSlide(data: LoadedSlidevData, options: MoveSlideOptions): Promise<MoveSlideResult> {
170 const { from, before, after } = options
171 if ((before == null) === (after == null))
172 throw new Error('Specify exactly one of `before` or `after`.')
173
174 const anchorNo = (before ?? after)!
175 if (anchorNo === from)
176 throw new Error('The `before`/`after` anchor must be a different slide than `from`.')
177
178 const slide = resolveSlide(data, from)
179 const anchor = resolveSlide(data, anchorNo)
180
181 if (slide.source.filepath !== anchor.source.filepath) {
182 throw new Error(
183 `Cannot move a slide across markdown files: slide ${from} is in "${slide.source.filepath}" `
184 + `but slide ${anchorNo} is in "${anchor.source.filepath}" (imported with \`src:\`). `
185 + `Move it within its own file, or edit the \`src:\` imports in the entry file manually.`,
186 )
187 }
188
189 assertNotEntryHeadmatter(data, slide.source, 'move')
190 if (before != null)
191 assertNotEntryHeadmatter(data, anchor.source, 'insert a slide before')
192
193 const md = getMarkdown(data, slide.source)
194 const fromIdx = md.slides.indexOf(slide.source)
195 if (fromIdx < 0)
196 throw new Error(`Slide ${from} is out of sync with its source file. Try again.`)
197 md.slides.splice(fromIdx, 1)
198 const anchorIdx = md.slides.indexOf(anchor.source)
199 if (anchorIdx < 0)
200 throw new Error(`Slide ${anchorNo} is out of sync with its source file. Try again.`)
201 md.slides.splice(before != null ? anchorIdx : anchorIdx + 1, 0, slide.source)
202 const fileContent = await parser.save(md)
203 return { moved: slide, anchor, filepath: md.filepath, fileContent }
204 }
205
205 lines TYPESCRIPT