@cyber-dash-tech/revela 0.4.6 → 0.5.1
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/README.md +82 -2
- package/README.zh-CN.md +85 -2
- package/lib/agents/research-prompt.ts +37 -10
- package/lib/commands/help.ts +3 -0
- package/lib/commands/init.ts +68 -0
- package/lib/commands/remember.ts +46 -0
- package/lib/commands/review.ts +68 -0
- package/lib/decks-memory.ts +509 -0
- package/lib/decks-state.ts +452 -0
- package/lib/pdf/export.ts +80 -1
- package/package.json +1 -1
- package/plugin.ts +220 -14
- package/skill/SKILL.md +123 -153
- package/tools/decks.ts +158 -0
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"
|
|
2
|
+
import { basename, dirname, join } from "path"
|
|
3
|
+
|
|
4
|
+
export const DECKS_STATE_FILE = "DECKS.json"
|
|
5
|
+
|
|
6
|
+
export type DeckProductionStatus = "planning" | "blocked" | "ready" | "written"
|
|
7
|
+
export type SlideProductionStatus = "planned" | "ready" | "written" | "qa_passed" | "qa_failed"
|
|
8
|
+
export type WriteReadinessStatus = "blocked" | "ready" | "written"
|
|
9
|
+
|
|
10
|
+
export interface DecksState {
|
|
11
|
+
version: 1
|
|
12
|
+
activeDeck?: string
|
|
13
|
+
workspace: {
|
|
14
|
+
brief?: string
|
|
15
|
+
sourceMaterials: SourceMaterial[]
|
|
16
|
+
preferences: {
|
|
17
|
+
user: string[]
|
|
18
|
+
workflow: string[]
|
|
19
|
+
}
|
|
20
|
+
deckMemory: DeckMemoryEntry[]
|
|
21
|
+
openQuestions: string[]
|
|
22
|
+
}
|
|
23
|
+
decks: Record<string, DeckSpec>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SourceMaterial {
|
|
27
|
+
path: string
|
|
28
|
+
type?: string
|
|
29
|
+
summary?: string
|
|
30
|
+
bestUsedFor?: string
|
|
31
|
+
lastChecked?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DeckMemoryEntry {
|
|
35
|
+
slug: string
|
|
36
|
+
topic?: string
|
|
37
|
+
keyDecisions?: string[]
|
|
38
|
+
outputPath?: string
|
|
39
|
+
date?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface DeckSpec {
|
|
43
|
+
slug: string
|
|
44
|
+
status: DeckProductionStatus
|
|
45
|
+
goal: string
|
|
46
|
+
audience?: string
|
|
47
|
+
language?: string
|
|
48
|
+
slideCount?: number
|
|
49
|
+
outputPath: string
|
|
50
|
+
theme: {
|
|
51
|
+
design?: string
|
|
52
|
+
domain?: string
|
|
53
|
+
}
|
|
54
|
+
requiredInputs: RequiredInputs
|
|
55
|
+
researchPlan: ResearchAxis[]
|
|
56
|
+
slides: SlideSpec[]
|
|
57
|
+
assets: DeckAsset[]
|
|
58
|
+
writeReadiness: {
|
|
59
|
+
status: WriteReadinessStatus
|
|
60
|
+
blockers: string[]
|
|
61
|
+
lastReviewedAt?: string
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface RequiredInputs {
|
|
66
|
+
topicClarified: boolean
|
|
67
|
+
audienceClarified: boolean
|
|
68
|
+
slideCountDecided: boolean
|
|
69
|
+
languageDecided: boolean
|
|
70
|
+
visualStyleSelected: boolean
|
|
71
|
+
sourceMaterialsIdentified: boolean
|
|
72
|
+
researchNeedAssessed: boolean
|
|
73
|
+
researchFindingsRead: boolean
|
|
74
|
+
slidePlanConfirmed: boolean
|
|
75
|
+
designLayoutsFetched: boolean
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ResearchAxis {
|
|
79
|
+
axis: string
|
|
80
|
+
needed: boolean
|
|
81
|
+
status: "pending" | "in_progress" | "done" | "read" | "skipped"
|
|
82
|
+
findingsFile?: string
|
|
83
|
+
notes?: string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface SlideSpec {
|
|
87
|
+
index: number
|
|
88
|
+
title: string
|
|
89
|
+
purpose?: string
|
|
90
|
+
layout: string
|
|
91
|
+
qa?: boolean
|
|
92
|
+
components: string[]
|
|
93
|
+
content: {
|
|
94
|
+
headline?: string
|
|
95
|
+
body?: string[]
|
|
96
|
+
bullets?: string[]
|
|
97
|
+
speakerNotes?: string
|
|
98
|
+
data?: unknown
|
|
99
|
+
}
|
|
100
|
+
evidence: EvidenceRef[]
|
|
101
|
+
visuals?: VisualBrief[]
|
|
102
|
+
status: SlideProductionStatus
|
|
103
|
+
notes?: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface EvidenceRef {
|
|
107
|
+
source: string
|
|
108
|
+
quote?: string
|
|
109
|
+
page?: string
|
|
110
|
+
url?: string
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface VisualBrief {
|
|
114
|
+
id?: string
|
|
115
|
+
purpose?: string
|
|
116
|
+
brief: string
|
|
117
|
+
assetPath?: string
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface DeckAsset {
|
|
121
|
+
id: string
|
|
122
|
+
type: "image" | "chart" | "file"
|
|
123
|
+
path?: string
|
|
124
|
+
purpose?: string
|
|
125
|
+
notes?: string
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface DeckStateReadinessResult {
|
|
129
|
+
ready: boolean
|
|
130
|
+
slug: string
|
|
131
|
+
status?: WriteReadinessStatus
|
|
132
|
+
blocker: string
|
|
133
|
+
blockers: string[]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function decksStatePath(workspaceRoot: string): string {
|
|
137
|
+
return join(workspaceRoot, DECKS_STATE_FILE)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function hasDecksState(workspaceRoot: string): boolean {
|
|
141
|
+
return existsSync(decksStatePath(workspaceRoot))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function createEmptyDecksState(): DecksState {
|
|
145
|
+
return {
|
|
146
|
+
version: 1,
|
|
147
|
+
workspace: {
|
|
148
|
+
sourceMaterials: [],
|
|
149
|
+
preferences: { user: [], workflow: [] },
|
|
150
|
+
deckMemory: [],
|
|
151
|
+
openQuestions: [],
|
|
152
|
+
},
|
|
153
|
+
decks: {},
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function defaultRequiredInputs(overrides?: Partial<RequiredInputs>): RequiredInputs {
|
|
158
|
+
return {
|
|
159
|
+
topicClarified: false,
|
|
160
|
+
audienceClarified: false,
|
|
161
|
+
slideCountDecided: false,
|
|
162
|
+
languageDecided: false,
|
|
163
|
+
visualStyleSelected: false,
|
|
164
|
+
sourceMaterialsIdentified: false,
|
|
165
|
+
researchNeedAssessed: false,
|
|
166
|
+
researchFindingsRead: false,
|
|
167
|
+
slidePlanConfirmed: false,
|
|
168
|
+
designLayoutsFetched: false,
|
|
169
|
+
...overrides,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function createDeckSpec(input: Partial<DeckSpec> & { slug: string }): DeckSpec {
|
|
174
|
+
const slug = normalizeSlug(input.slug)
|
|
175
|
+
return {
|
|
176
|
+
slug,
|
|
177
|
+
status: input.status ?? "planning",
|
|
178
|
+
goal: input.goal ?? "",
|
|
179
|
+
audience: input.audience,
|
|
180
|
+
language: input.language,
|
|
181
|
+
slideCount: input.slideCount,
|
|
182
|
+
outputPath: normalizeDeckPath(input.outputPath || `decks/${slug}.html`),
|
|
183
|
+
theme: input.theme ?? {},
|
|
184
|
+
requiredInputs: defaultRequiredInputs(input.requiredInputs),
|
|
185
|
+
researchPlan: input.researchPlan ?? [],
|
|
186
|
+
slides: normalizeSlides(input.slides ?? []),
|
|
187
|
+
assets: input.assets ?? [],
|
|
188
|
+
writeReadiness: input.writeReadiness ?? { status: "blocked", blockers: [] },
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function readDecksState(workspaceRoot: string): DecksState {
|
|
193
|
+
const parsed = JSON.parse(readFileSync(decksStatePath(workspaceRoot), "utf-8")) as DecksState
|
|
194
|
+
return normalizeDecksState(parsed)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function writeDecksState(workspaceRoot: string, state: DecksState): void {
|
|
198
|
+
const filePath = decksStatePath(workspaceRoot)
|
|
199
|
+
mkdirSync(dirname(filePath), { recursive: true })
|
|
200
|
+
writeFileSync(filePath, JSON.stringify(normalizeDecksState(state), null, 2) + "\n", "utf-8")
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function readOrCreateDecksState(workspaceRoot: string): DecksState {
|
|
204
|
+
if (hasDecksState(workspaceRoot)) return readDecksState(workspaceRoot)
|
|
205
|
+
const state = createEmptyDecksState()
|
|
206
|
+
writeDecksState(workspaceRoot, state)
|
|
207
|
+
return state
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function upsertDeck(state: DecksState, input: Partial<DeckSpec> & { slug: string }): DecksState {
|
|
211
|
+
const normalized = normalizeDecksState(state)
|
|
212
|
+
const slug = normalizeSlug(input.slug)
|
|
213
|
+
const existing = normalized.decks[slug]
|
|
214
|
+
const next = createDeckSpec({ ...existing, ...input, slug })
|
|
215
|
+
normalized.decks[slug] = next
|
|
216
|
+
normalized.activeDeck = slug
|
|
217
|
+
return normalized
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function upsertSlides(state: DecksState, slug: string, slides: SlideSpec[]): DecksState {
|
|
221
|
+
const normalized = normalizeDecksState(state)
|
|
222
|
+
const key = normalizeSlug(slug)
|
|
223
|
+
const deck = normalized.decks[key] ?? createDeckSpec({ slug: key })
|
|
224
|
+
const byIndex = new Map(deck.slides.map((slide) => [slide.index, slide]))
|
|
225
|
+
for (const slide of normalizeSlides(slides)) byIndex.set(slide.index, slide)
|
|
226
|
+
deck.slides = [...byIndex.values()].sort((a, b) => a.index - b.index)
|
|
227
|
+
normalized.decks[key] = deck
|
|
228
|
+
normalized.activeDeck = key
|
|
229
|
+
return normalized
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function reviewDeckState(state: DecksState, slug?: string): { state: DecksState; result: DeckStateReadinessResult } {
|
|
233
|
+
const normalized = normalizeDecksState(state)
|
|
234
|
+
const key = normalizeSlug(slug || normalized.activeDeck || "")
|
|
235
|
+
const deck = key ? normalized.decks[key] : undefined
|
|
236
|
+
if (!deck) {
|
|
237
|
+
const missing = key || "active deck"
|
|
238
|
+
return {
|
|
239
|
+
state: normalized,
|
|
240
|
+
result: {
|
|
241
|
+
ready: false,
|
|
242
|
+
slug: missing,
|
|
243
|
+
blocker: `Deck ${missing} does not exist in ${DECKS_STATE_FILE}.`,
|
|
244
|
+
blockers: [`Deck ${missing} does not exist in ${DECKS_STATE_FILE}.`],
|
|
245
|
+
},
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const blockers = computeDeckBlockers(deck)
|
|
250
|
+
deck.writeReadiness = {
|
|
251
|
+
status: blockers.length === 0 ? "ready" : "blocked",
|
|
252
|
+
blockers,
|
|
253
|
+
lastReviewedAt: new Date().toISOString(),
|
|
254
|
+
}
|
|
255
|
+
deck.status = blockers.length === 0 ? "ready" : "blocked"
|
|
256
|
+
normalized.decks[deck.slug] = deck
|
|
257
|
+
normalized.activeDeck = deck.slug
|
|
258
|
+
return {
|
|
259
|
+
state: normalized,
|
|
260
|
+
result: {
|
|
261
|
+
ready: blockers.length === 0,
|
|
262
|
+
slug: deck.slug,
|
|
263
|
+
status: deck.writeReadiness.status,
|
|
264
|
+
blocker: blockers.join("; "),
|
|
265
|
+
blockers,
|
|
266
|
+
},
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function checkDeckStateWriteReadiness(workspaceRoot: string, filePath: string): DeckStateReadinessResult | undefined {
|
|
271
|
+
if (!hasDecksState(workspaceRoot)) return undefined
|
|
272
|
+
return evaluateDeckStateWriteReadiness(readDecksState(workspaceRoot), filePath)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function evaluateDeckStateWriteReadiness(state: DecksState, filePath: string): DeckStateReadinessResult {
|
|
276
|
+
const targetPath = normalizeDeckPath(filePath)
|
|
277
|
+
const targetSlug = deckSlugFromPath(targetPath)
|
|
278
|
+
const normalized = normalizeDecksState(state)
|
|
279
|
+
const deck = findDeckForTarget(normalized, targetPath, targetSlug)
|
|
280
|
+
if (!deck) {
|
|
281
|
+
return {
|
|
282
|
+
ready: false,
|
|
283
|
+
slug: targetSlug,
|
|
284
|
+
blocker: `No deck in ${DECKS_STATE_FILE} matches ${targetPath}. Use revela-decks upsertDeck/upsertSlides, then review before writing.`,
|
|
285
|
+
blockers: [`No deck in ${DECKS_STATE_FILE} matches ${targetPath}.`],
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const blockers = computeDeckBlockers(deck)
|
|
290
|
+
if (normalizeDeckPath(deck.outputPath) !== targetPath) {
|
|
291
|
+
blockers.unshift(`Deck outputPath is ${deck.outputPath || "missing"}, not ${targetPath}`)
|
|
292
|
+
}
|
|
293
|
+
if (deck.writeReadiness.status !== "ready") {
|
|
294
|
+
blockers.unshift(`Deck writeReadiness is ${deck.writeReadiness.status || "missing"}, not ready`)
|
|
295
|
+
}
|
|
296
|
+
if (deck.writeReadiness.blockers.length > 0) {
|
|
297
|
+
blockers.unshift(`Deck still has readiness blockers: ${deck.writeReadiness.blockers.join("; ")}`)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
ready: blockers.length === 0,
|
|
302
|
+
slug: deck.slug,
|
|
303
|
+
status: deck.writeReadiness.status,
|
|
304
|
+
blocker: blockers.join("; "),
|
|
305
|
+
blockers,
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function isDecksStatePath(filePath: string): boolean {
|
|
310
|
+
return normalizePath(filePath).split("/").pop() === DECKS_STATE_FILE
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function extractDecksStateTargetsFromPatch(patchText: string): string[] {
|
|
314
|
+
const targets = new Set<string>()
|
|
315
|
+
for (const line of patchText.replace(/\r\n/g, "\n").split("\n")) {
|
|
316
|
+
const match = /^\*\*\*\s+(?:Add File|Update File|Delete File|Move to):\s*(.+?)\s*$/.exec(line)
|
|
317
|
+
if (!match) continue
|
|
318
|
+
const target = cleanMarkdownText(match[1])
|
|
319
|
+
if (isDecksStatePath(target)) targets.add(target)
|
|
320
|
+
}
|
|
321
|
+
return [...targets]
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function buildDecksStatePromptLayer(workspaceRoot: string, maxChars = 14000): string {
|
|
325
|
+
if (!hasDecksState(workspaceRoot)) return ""
|
|
326
|
+
const state = readDecksState(workspaceRoot)
|
|
327
|
+
const active = state.activeDeck ? state.decks[state.activeDeck] : undefined
|
|
328
|
+
const compact = {
|
|
329
|
+
sourceOfTruth: DECKS_STATE_FILE,
|
|
330
|
+
activeDeck: state.activeDeck,
|
|
331
|
+
workspace: state.workspace,
|
|
332
|
+
deck: active,
|
|
333
|
+
}
|
|
334
|
+
let text = JSON.stringify(compact, null, 2)
|
|
335
|
+
if (text.length > maxChars) text = text.slice(0, maxChars).trimEnd() + "\n[DECKS.json state truncated for prompt size.]"
|
|
336
|
+
return `---\n\n# Revela Workspace State From ${DECKS_STATE_FILE}\n\n\`\`\`json\n${text}\n\`\`\`\n\nRules for this state layer:\n- Treat ${DECKS_STATE_FILE} as the source of truth for deck specs, slide plans, and write readiness.\n- Do not edit ${DECKS_STATE_FILE} directly; use the revela-decks tool.\n- Before writing decks/*.html, the matching deck must have writeReadiness.status=ready and a complete slide spec.`
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function normalizeDecksState(input: DecksState): DecksState {
|
|
340
|
+
const state: DecksState = {
|
|
341
|
+
version: 1,
|
|
342
|
+
activeDeck: input.activeDeck ? normalizeSlug(input.activeDeck) : undefined,
|
|
343
|
+
workspace: {
|
|
344
|
+
brief: input.workspace?.brief,
|
|
345
|
+
sourceMaterials: input.workspace?.sourceMaterials ?? [],
|
|
346
|
+
preferences: {
|
|
347
|
+
user: input.workspace?.preferences?.user ?? [],
|
|
348
|
+
workflow: input.workspace?.preferences?.workflow ?? [],
|
|
349
|
+
},
|
|
350
|
+
deckMemory: input.workspace?.deckMemory ?? [],
|
|
351
|
+
openQuestions: input.workspace?.openQuestions ?? [],
|
|
352
|
+
},
|
|
353
|
+
decks: {},
|
|
354
|
+
}
|
|
355
|
+
for (const [slug, deck] of Object.entries(input.decks ?? {})) {
|
|
356
|
+
const normalizedSlug = normalizeSlug(deck.slug || slug)
|
|
357
|
+
state.decks[normalizedSlug] = createDeckSpec({ ...deck, slug: normalizedSlug })
|
|
358
|
+
}
|
|
359
|
+
if (state.activeDeck && !state.decks[state.activeDeck]) state.activeDeck = undefined
|
|
360
|
+
return state
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function computeDeckBlockers(deck: DeckSpec): string[] {
|
|
364
|
+
const blockers: string[] = []
|
|
365
|
+
if (!deck.goal.trim()) blockers.push("Deck goal is missing")
|
|
366
|
+
if (!isDeckHtmlPath(deck.outputPath)) blockers.push(`outputPath must be decks/*.html, got ${deck.outputPath || "missing"}`)
|
|
367
|
+
|
|
368
|
+
for (const [key, value] of Object.entries(deck.requiredInputs) as Array<[keyof RequiredInputs, boolean]>) {
|
|
369
|
+
if (value !== true) blockers.push(`requiredInputs.${key} is not true`)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (typeof deck.slideCount === "number" && deck.slideCount > 0 && deck.slides.length !== deck.slideCount) {
|
|
373
|
+
blockers.push(`slides length ${deck.slides.length} does not match slideCount ${deck.slideCount}`)
|
|
374
|
+
}
|
|
375
|
+
if (deck.slides.length === 0) blockers.push("slides are missing")
|
|
376
|
+
for (const slide of deck.slides) {
|
|
377
|
+
if (!slide.title.trim()) blockers.push(`Slide ${slide.index} title is missing`)
|
|
378
|
+
if (!slide.layout.trim()) blockers.push(`Slide ${slide.index} layout is missing`)
|
|
379
|
+
if (slide.components.length === 0) blockers.push(`Slide ${slide.index} components are missing`)
|
|
380
|
+
if (!hasSlideContent(slide)) blockers.push(`Slide ${slide.index} content is missing`)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
for (const axis of deck.researchPlan) {
|
|
384
|
+
if (axis.needed && axis.status !== "done" && axis.status !== "read" && axis.status !== "skipped") {
|
|
385
|
+
blockers.push(`Research axis ${axis.axis || "unnamed"} is needed but ${axis.status}`)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return blockers
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function normalizeSlides(slides: SlideSpec[]): SlideSpec[] {
|
|
392
|
+
return slides
|
|
393
|
+
.map((slide) => ({
|
|
394
|
+
...slide,
|
|
395
|
+
title: slide.title ?? "",
|
|
396
|
+
layout: slide.layout ?? "",
|
|
397
|
+
components: slide.components ?? [],
|
|
398
|
+
content: slide.content ?? {},
|
|
399
|
+
evidence: slide.evidence ?? [],
|
|
400
|
+
status: slide.status ?? "planned",
|
|
401
|
+
}))
|
|
402
|
+
.sort((a, b) => a.index - b.index)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function findDeckForTarget(state: DecksState, targetPath: string, targetSlug: string): DeckSpec | undefined {
|
|
406
|
+
return Object.values(state.decks).find((deck) => normalizeDeckPath(deck.outputPath) === targetPath) ?? state.decks[normalizeSlug(targetSlug)]
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function hasSlideContent(slide: SlideSpec): boolean {
|
|
410
|
+
const content = slide.content ?? {}
|
|
411
|
+
return Boolean(
|
|
412
|
+
content.headline?.trim() ||
|
|
413
|
+
(content.body && content.body.length > 0) ||
|
|
414
|
+
(content.bullets && content.bullets.length > 0) ||
|
|
415
|
+
content.data !== undefined,
|
|
416
|
+
)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function isDeckHtmlPath(filePath: string): boolean {
|
|
420
|
+
return normalizePath(filePath).match(/(^|\/)decks\/[^/]+\.html$/) !== null
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function deckSlugFromPath(filePath: string): string {
|
|
424
|
+
return normalizeSlug(basename(normalizePath(filePath), ".html"))
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function normalizeDeckPath(filePath: string): string {
|
|
428
|
+
const normalized = normalizePath(cleanMarkdownText(filePath)).replace(/^\.\//, "")
|
|
429
|
+
const match = /(?:^|\/)(decks\/[^/]+\.html)$/.exec(normalized)
|
|
430
|
+
return match?.[1] ?? normalized
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function normalizePath(filePath: string): string {
|
|
434
|
+
return filePath.replace(/\\/g, "/")
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function normalizeSlug(value: string): string {
|
|
438
|
+
return cleanMarkdownText(value)
|
|
439
|
+
.toLowerCase()
|
|
440
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
441
|
+
.replace(/^-+|-+$/g, "")
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function cleanMarkdownText(value: string): string {
|
|
445
|
+
let text = String(value ?? "").trim()
|
|
446
|
+
const link = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(text)
|
|
447
|
+
if (link) text = link[1] || link[2]
|
|
448
|
+
return text
|
|
449
|
+
.replace(/^`+|`+$/g, "")
|
|
450
|
+
.replace(/^\*+|\*+$/g, "")
|
|
451
|
+
.trim()
|
|
452
|
+
}
|
package/lib/pdf/export.ts
CHANGED
|
@@ -57,6 +57,18 @@ const MIME_TO_EXT: Record<string, string> = {
|
|
|
57
57
|
"image/avif": ".avif",
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
const IMAGE_EXTS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".avif", ".bmp"])
|
|
61
|
+
const EXT_TO_MIME: Record<string, string> = {
|
|
62
|
+
".jpg": "image/jpeg",
|
|
63
|
+
".jpeg": "image/jpeg",
|
|
64
|
+
".png": "image/png",
|
|
65
|
+
".gif": "image/gif",
|
|
66
|
+
".webp": "image/webp",
|
|
67
|
+
".svg": "image/svg+xml",
|
|
68
|
+
".avif": "image/avif",
|
|
69
|
+
".bmp": "image/bmp",
|
|
70
|
+
}
|
|
71
|
+
|
|
60
72
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
61
73
|
|
|
62
74
|
function findChromePath(): string {
|
|
@@ -149,6 +161,72 @@ async function localizeExternalImages(
|
|
|
149
161
|
return patched
|
|
150
162
|
}
|
|
151
163
|
|
|
164
|
+
function isLocalImageRef(ref: string): boolean {
|
|
165
|
+
const pathPart = ref.split(/[?#]/)[0]
|
|
166
|
+
return IMAGE_EXTS.has(extname(pathPart).toLowerCase())
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function extractImageAssetRefsForPdf(htmlContent: string): string[] {
|
|
170
|
+
const assetRefPattern = /\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))|url\(\s*(?:"([^"]*)"|'([^']*)'|([^\s)]+))\s*\)/g
|
|
171
|
+
const refs = new Set<string>()
|
|
172
|
+
let match: RegExpExecArray | null
|
|
173
|
+
|
|
174
|
+
while ((match = assetRefPattern.exec(htmlContent)) !== null) {
|
|
175
|
+
const ref = match.slice(1).find((value): value is string => value !== undefined)
|
|
176
|
+
if (ref) refs.add(ref.trim())
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return Array.from(refs)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function toDataUrlFromRef(ref: string, baseDir: string): Promise<string | null> {
|
|
183
|
+
if (!ref || ref.startsWith("data:") || ref.startsWith("blob:") || ref.startsWith("#")) {
|
|
184
|
+
return null
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
if (ref.startsWith("http://") || ref.startsWith("https://") || ref.startsWith("//") || ref.startsWith("file://")) {
|
|
189
|
+
return null
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let filePath: string | null = null
|
|
193
|
+
if (isLocalImageRef(ref)) {
|
|
194
|
+
filePath = resolve(baseDir, decodeURI(ref.split(/[?#]/)[0]))
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!filePath || !existsSync(filePath)) return null
|
|
198
|
+
const ext = extname(filePath).toLowerCase()
|
|
199
|
+
const mime = EXT_TO_MIME[ext]
|
|
200
|
+
if (!mime) return null
|
|
201
|
+
const buf = readFileSync(filePath)
|
|
202
|
+
return `data:${mime};base64,${buf.toString("base64")}`
|
|
203
|
+
} catch {
|
|
204
|
+
return null
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function inlineImageAssetsForPdf(htmlContent: string, htmlFilePath: string): Promise<string> {
|
|
209
|
+
const baseDir = dirname(resolve(htmlFilePath))
|
|
210
|
+
const refs = extractImageAssetRefsForPdf(htmlContent)
|
|
211
|
+
|
|
212
|
+
if (refs.length === 0) return htmlContent
|
|
213
|
+
|
|
214
|
+
const replacements = new Map<string, string>()
|
|
215
|
+
await Promise.allSettled(
|
|
216
|
+
refs.map(async (ref) => {
|
|
217
|
+
const dataUrl = await toDataUrlFromRef(ref, baseDir)
|
|
218
|
+
if (dataUrl) replacements.set(ref, dataUrl)
|
|
219
|
+
})
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
let patched = htmlContent
|
|
223
|
+
for (const [original, replacement] of replacements) {
|
|
224
|
+
const escaped = original.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
225
|
+
patched = patched.replace(new RegExp(escaped, "g"), replacement)
|
|
226
|
+
}
|
|
227
|
+
return patched
|
|
228
|
+
}
|
|
229
|
+
|
|
152
230
|
// ── Main export ──────────────────────────────────────────────────────────────
|
|
153
231
|
|
|
154
232
|
export interface ExportResult {
|
|
@@ -185,7 +263,8 @@ export async function exportToPdf(htmlFilePath: string): Promise<ExportResult> {
|
|
|
185
263
|
let tmpHtmlPath: string
|
|
186
264
|
try {
|
|
187
265
|
const originalHtml = readFileSync(abs, "utf-8")
|
|
188
|
-
const
|
|
266
|
+
const localizedHtml = await localizeExternalImages(originalHtml, tmpDir)
|
|
267
|
+
const patchedHtml = await inlineImageAssetsForPdf(localizedHtml, abs)
|
|
189
268
|
tmpHtmlPath = join(tmpDir, "index.html")
|
|
190
269
|
writeFileSync(tmpHtmlPath, patchedHtml, "utf-8")
|
|
191
270
|
} catch (err) {
|