@cyber-dash-tech/revela 0.4.6 → 0.5.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/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/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
|
+
}
|