@cyber-dash-tech/revela 0.17.23 → 0.18.2
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 +24 -25
- package/README.zh-CN.md +25 -26
- package/bin/revela.ts +2 -4
- package/lib/commands/help.ts +13 -13
- package/lib/commands/init.ts +24 -0
- package/lib/commands/png.ts +29 -0
- package/lib/commands/refine.ts +1 -1
- package/lib/commands/research.ts +24 -0
- package/lib/commands/review.ts +92 -14
- package/lib/decks-state.ts +7 -7
- package/lib/design/designs.ts +44 -0
- package/lib/narrative-state/deck-plan-artifact.ts +849 -19
- package/lib/narrative-state/render-plan.ts +13 -14
- package/lib/pdf/export.ts +84 -24
- package/lib/refine/server.ts +4 -3
- package/lib/runtime/index.ts +52 -7
- package/lib/runtime/review.ts +4 -104
- package/lib/workspace-state/render-targets.ts +2 -2
- package/lib/workspace-state/rendered-artifacts.ts +1 -1
- package/lib/workspace-state/types.ts +1 -1
- package/package.json +1 -1
- package/plugin.ts +31 -42
- package/plugins/revela/.codex-plugin/plugin.json +2 -2
- package/plugins/revela/.mcp.json +1 -1
- package/plugins/revela/mcp/revela-server.ts +118 -58
- package/plugins/revela/skills/revela-design/SKILL.md +9 -1
- package/plugins/revela/skills/revela-domain/SKILL.md +1 -1
- package/plugins/revela/skills/revela-export/SKILL.md +4 -5
- package/plugins/revela/skills/revela-init/SKILL.md +19 -34
- package/plugins/revela/skills/revela-make-deck/SKILL.md +15 -34
- package/plugins/revela/skills/revela-research/SKILL.md +17 -26
- package/plugins/revela/skills/revela-review-deck/SKILL.md +11 -29
- package/skill/SKILL.md +22 -19
- package/plugins/revela/skills/revela-story/SKILL.md +0 -24
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs"
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "fs"
|
|
2
2
|
import { dirname, join, relative } from "path"
|
|
3
3
|
import { createHash } from "crypto"
|
|
4
4
|
import type { DeckSpec, SlideSpec } from "../decks-state"
|
|
@@ -9,10 +9,11 @@ import type { VaultRelation, WorkspaceGraphNodeType } from "../narrative-vault/t
|
|
|
9
9
|
import type { DeckPlanChapter, DeckPlanQualityCheck, RenderPlanContract, RenderPlanSlideMetadata } from "./render-plan"
|
|
10
10
|
|
|
11
11
|
export const DECK_PLAN_DIR = "deck-plan"
|
|
12
|
+
export const DECK_PLAN_MARKDOWN_PATH = "deck-plan.md"
|
|
12
13
|
export const DECK_PLAN_INDEX_PATH = "deck-plan/index.md"
|
|
13
14
|
export const DECK_PLAN_SLIDES_DIR = "deck-plan/slides"
|
|
14
15
|
export const LEGACY_DECK_PLAN_ARTIFACT_PATH = "decks/deck-plan.md"
|
|
15
|
-
export const DECK_PLAN_ARTIFACT_PATH =
|
|
16
|
+
export const DECK_PLAN_ARTIFACT_PATH = DECK_PLAN_MARKDOWN_PATH
|
|
16
17
|
|
|
17
18
|
export interface DeckPlanArtifactInput {
|
|
18
19
|
deck: DeckSpec
|
|
@@ -65,6 +66,7 @@ export interface DeckPlanProjection {
|
|
|
65
66
|
frontmatter: Record<string, string | string[] | boolean>
|
|
66
67
|
sections: string[]
|
|
67
68
|
narrativeHash?: string
|
|
69
|
+
designName?: string
|
|
68
70
|
outputPath?: string
|
|
69
71
|
slides: DeckPlanSlideProjection[]
|
|
70
72
|
graphNodes: Array<{ id: string; type: WorkspaceGraphNodeType; file: string }>
|
|
@@ -81,12 +83,88 @@ export interface DeckPlanSlideProjection {
|
|
|
81
83
|
chapter: string
|
|
82
84
|
layout: string
|
|
83
85
|
components: string[]
|
|
86
|
+
componentPlan: DeckPlanSlideComponentPlan[]
|
|
84
87
|
structural: boolean
|
|
85
88
|
narrativeRole: string
|
|
86
89
|
markdown: string
|
|
87
90
|
frontmatter: Record<string, string | string[] | boolean>
|
|
88
91
|
sections: string[]
|
|
89
92
|
links: DeckPlanNarrativeLink[]
|
|
93
|
+
sourceLinks: DeckPlanSourceLinks
|
|
94
|
+
caveats: string[]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface DeckPlanSlideComponentPlan {
|
|
98
|
+
name: string
|
|
99
|
+
slot: string
|
|
100
|
+
position: string
|
|
101
|
+
purpose: string
|
|
102
|
+
content: string
|
|
103
|
+
claimIds: string[]
|
|
104
|
+
evidenceIds: string[]
|
|
105
|
+
sourceNotes: string[]
|
|
106
|
+
renderNotes: string[]
|
|
107
|
+
placementNote?: string
|
|
108
|
+
children?: DeckPlanSlideComponentPlan[]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface DeckPlanSlideUpsertComponentInput {
|
|
112
|
+
name: string
|
|
113
|
+
slot: string
|
|
114
|
+
position: string
|
|
115
|
+
purpose: string
|
|
116
|
+
content: string
|
|
117
|
+
claimIds?: string[]
|
|
118
|
+
evidenceIds?: string[]
|
|
119
|
+
sourceNotes?: string[]
|
|
120
|
+
renderNotes?: string[]
|
|
121
|
+
placementNote?: string
|
|
122
|
+
children?: DeckPlanSlideUpsertComponentInput[]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface DeckPlanSourceLinks {
|
|
126
|
+
materials: string[]
|
|
127
|
+
findings: string[]
|
|
128
|
+
assets: string[]
|
|
129
|
+
urls: string[]
|
|
130
|
+
caveats: string[]
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface DeckPlanSlideUpsertInput {
|
|
134
|
+
designName?: string
|
|
135
|
+
outputPath?: string
|
|
136
|
+
slideIndex: number
|
|
137
|
+
id?: string
|
|
138
|
+
title: string
|
|
139
|
+
chapter: string
|
|
140
|
+
narrativeRole: string
|
|
141
|
+
structural?: boolean
|
|
142
|
+
layout: string
|
|
143
|
+
components: DeckPlanSlideUpsertComponentInput[]
|
|
144
|
+
visualIntent: {
|
|
145
|
+
kind?: string
|
|
146
|
+
component?: string
|
|
147
|
+
rationale?: string
|
|
148
|
+
brief?: string
|
|
149
|
+
} | string
|
|
150
|
+
sourceLinks?: Partial<DeckPlanSourceLinks>
|
|
151
|
+
narrativeLinks?: {
|
|
152
|
+
claimIds?: string[]
|
|
153
|
+
evidenceIds?: string[]
|
|
154
|
+
riskIds?: string[]
|
|
155
|
+
objectionIds?: string[]
|
|
156
|
+
gapIds?: string[]
|
|
157
|
+
}
|
|
158
|
+
caveats?: string[]
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface DeckPlanSlideUpsertResult {
|
|
162
|
+
ok: boolean
|
|
163
|
+
path?: string
|
|
164
|
+
absolutePath?: string
|
|
165
|
+
updated?: boolean
|
|
166
|
+
slide?: DeckPlanSlideProjection
|
|
167
|
+
diagnostics: DeckPlanProjectionDiagnostic[]
|
|
90
168
|
}
|
|
91
169
|
|
|
92
170
|
export interface DeckPlanNarrativeLink {
|
|
@@ -104,15 +182,14 @@ export interface DeckPlanProjectionDiagnostic {
|
|
|
104
182
|
}
|
|
105
183
|
|
|
106
184
|
export const REQUIRED_DECK_PLAN_SECTIONS = [
|
|
185
|
+
"Goal",
|
|
186
|
+
"Audience",
|
|
187
|
+
"Design",
|
|
107
188
|
"Source Authority",
|
|
108
|
-
"Audience / Goal / Decision",
|
|
109
|
-
"Deck Parameters",
|
|
110
189
|
"Chapter Map",
|
|
111
|
-
"
|
|
112
|
-
"
|
|
113
|
-
"
|
|
114
|
-
"Chapter Writing Batches",
|
|
115
|
-
"HTML Identity Contract",
|
|
190
|
+
"Slides",
|
|
191
|
+
"Unresolved Inputs",
|
|
192
|
+
"HTML Contract",
|
|
116
193
|
]
|
|
117
194
|
|
|
118
195
|
export function writeDeckPlanArtifact(workspaceRoot: string, input: DeckPlanArtifactInput): { path: string; absolutePath: string } {
|
|
@@ -124,17 +201,17 @@ export function writeDeckPlanArtifact(workspaceRoot: string, input: DeckPlanArti
|
|
|
124
201
|
|
|
125
202
|
export function readDeckPlanArtifact(workspaceRoot: string, expected?: { narrativeHash?: string; knownNodeIds?: Set<string> }): DeckPlanReadResult {
|
|
126
203
|
const projection = readDeckPlanProjection(workspaceRoot, expected)
|
|
127
|
-
const absolutePath = projection?.absolutePath ?? join(workspaceRoot,
|
|
204
|
+
const absolutePath = projection?.absolutePath ?? join(workspaceRoot, DECK_PLAN_MARKDOWN_PATH)
|
|
128
205
|
if (!existsSync(absolutePath)) {
|
|
129
206
|
return {
|
|
130
207
|
ok: false,
|
|
131
|
-
path:
|
|
208
|
+
path: DECK_PLAN_MARKDOWN_PATH,
|
|
132
209
|
absolutePath,
|
|
133
210
|
approvalStatus: "missing",
|
|
134
211
|
sections: [],
|
|
135
212
|
missingSections: REQUIRED_DECK_PLAN_SECTIONS,
|
|
136
213
|
warnings: [],
|
|
137
|
-
reason: `Deck plan file is missing: ${
|
|
214
|
+
reason: `Deck plan file is missing: ${DECK_PLAN_MARKDOWN_PATH}. Write the LLM-authored deck plan before HTML generation.`,
|
|
138
215
|
}
|
|
139
216
|
}
|
|
140
217
|
const markdown = projection?.markdown ?? readFileSync(absolutePath, "utf-8")
|
|
@@ -160,7 +237,7 @@ export function readDeckPlanArtifact(workspaceRoot: string, expected?: { narrati
|
|
|
160
237
|
}
|
|
161
238
|
return {
|
|
162
239
|
ok: true,
|
|
163
|
-
path: projection?.path ??
|
|
240
|
+
path: projection?.path ?? DECK_PLAN_MARKDOWN_PATH,
|
|
164
241
|
absolutePath,
|
|
165
242
|
markdown,
|
|
166
243
|
planHash,
|
|
@@ -175,9 +252,10 @@ export function readDeckPlanArtifact(workspaceRoot: string, expected?: { narrati
|
|
|
175
252
|
|
|
176
253
|
export function readDeckPlanProjection(workspaceRoot: string, expected?: { narrativeHash?: string; knownNodeIds?: Set<string> }): DeckPlanProjection | undefined {
|
|
177
254
|
const root = join(workspaceRoot, DECK_PLAN_DIR)
|
|
255
|
+
const singlePath = join(workspaceRoot, DECK_PLAN_MARKDOWN_PATH)
|
|
178
256
|
const indexPath = join(workspaceRoot, DECK_PLAN_INDEX_PATH)
|
|
179
257
|
const legacyPath = join(workspaceRoot, LEGACY_DECK_PLAN_ARTIFACT_PATH)
|
|
180
|
-
const absolutePath = existsSync(indexPath) ? indexPath : existsSync(legacyPath) ? legacyPath : ""
|
|
258
|
+
const absolutePath = existsSync(singlePath) ? singlePath : existsSync(indexPath) ? indexPath : existsSync(legacyPath) ? legacyPath : ""
|
|
181
259
|
if (!absolutePath) return undefined
|
|
182
260
|
const markdown = readFileSync(absolutePath, "utf-8")
|
|
183
261
|
const parsed = parseVaultFrontmatter(markdown)
|
|
@@ -185,7 +263,8 @@ export function readDeckPlanProjection(workspaceRoot: string, expected?: { narra
|
|
|
185
263
|
const sections = parseMarkdownSections(markdown)
|
|
186
264
|
const path = relativePath(workspaceRoot, absolutePath)
|
|
187
265
|
const id = stringField(parsed.frontmatter, "id") || "deck-plan"
|
|
188
|
-
const
|
|
266
|
+
const isSingleFile = relativePath(workspaceRoot, absolutePath) === DECK_PLAN_MARKDOWN_PATH
|
|
267
|
+
const slides = isSingleFile ? readDeckPlanSlidesFromSingleFile(workspaceRoot, absolutePath, markdown, expected?.knownNodeIds) : existsSync(join(root, "slides")) ? readDeckPlanSlideFiles(workspaceRoot, expected?.knownNodeIds) : []
|
|
189
268
|
const diagnostics: DeckPlanProjectionDiagnostic[] = []
|
|
190
269
|
const narrativeHash = stringField(parsed.frontmatter, "narrativeHash") || narrativeHashFromMarkdown(markdown)
|
|
191
270
|
if (expected?.narrativeHash && narrativeHash && narrativeHash !== expected.narrativeHash) diagnostics.push({ severity: "warning", code: "stale_narrative_hash", message: "Deck plan narrativeHash does not match current narrative state.", file: path, nodeId: id })
|
|
@@ -212,7 +291,8 @@ export function readDeckPlanProjection(workspaceRoot: string, expected?: { narra
|
|
|
212
291
|
frontmatter: parsed.frontmatter,
|
|
213
292
|
sections,
|
|
214
293
|
narrativeHash,
|
|
215
|
-
|
|
294
|
+
designName: stringField(parsed.frontmatter, "designName") || stringField(parsed.frontmatter, "design"),
|
|
295
|
+
outputPath: stringField(parsed.frontmatter, "outputPath") || stringField(parsed.frontmatter, "output"),
|
|
216
296
|
slides,
|
|
217
297
|
graphNodes,
|
|
218
298
|
graphRelations,
|
|
@@ -247,6 +327,214 @@ export function deckPlanBodyHash(markdown: string): string {
|
|
|
247
327
|
return createHash("sha1").update(stripApprovalSection(markdown).trim()).digest("hex")
|
|
248
328
|
}
|
|
249
329
|
|
|
330
|
+
export function upsertDeckPlanSlideArtifact(
|
|
331
|
+
workspaceRoot: string,
|
|
332
|
+
input: DeckPlanSlideUpsertInput,
|
|
333
|
+
options: { narrativeHash?: string; knownNodeIds?: Set<string>; designLayouts: string[]; designComponents: string[]; layoutSlots?: Record<string, string[]>; componentNesting?: Record<string, { acceptsChildren: boolean; allowedChildren?: string[] }> },
|
|
334
|
+
): DeckPlanSlideUpsertResult {
|
|
335
|
+
const diagnostics = validateDeckPlanSlideUpsert(input, options)
|
|
336
|
+
if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) return { ok: false, diagnostics }
|
|
337
|
+
|
|
338
|
+
const existing = readDeckPlanProjection(workspaceRoot, { narrativeHash: options.narrativeHash, knownNodeIds: options.knownNodeIds })
|
|
339
|
+
const existingSlide = existing?.slides.find((slide) => slide.slideIndex === input.slideIndex)
|
|
340
|
+
const id = input.id?.trim() || existingSlide?.id || `slide-${slugify(input.title)}`
|
|
341
|
+
const nextSlide = projectionFromSlideInput(workspaceRoot, { ...input, id }, existingSlide)
|
|
342
|
+
const slides = [...(existing?.slides.filter((slide) => slide.slideIndex !== input.slideIndex) ?? []), nextSlide]
|
|
343
|
+
.sort((a, b) => (a.slideIndex ?? Number.MAX_SAFE_INTEGER) - (b.slideIndex ?? Number.MAX_SAFE_INTEGER))
|
|
344
|
+
const designName = input.designName || existing?.designName
|
|
345
|
+
const outputPath = input.outputPath || existing?.outputPath
|
|
346
|
+
writeDeckPlanSingleFile(workspaceRoot, {
|
|
347
|
+
title: "Deck Plan",
|
|
348
|
+
designName,
|
|
349
|
+
outputPath,
|
|
350
|
+
slides,
|
|
351
|
+
})
|
|
352
|
+
const projection = readDeckPlanProjection(workspaceRoot, { narrativeHash: options.narrativeHash, knownNodeIds: options.knownNodeIds })
|
|
353
|
+
const slide = projection?.slides.find((item) => item.slideIndex === input.slideIndex)
|
|
354
|
+
return { ok: true, path: DECK_PLAN_MARKDOWN_PATH, absolutePath: join(workspaceRoot, DECK_PLAN_MARKDOWN_PATH), updated: Boolean(existingSlide), slide, diagnostics: [...diagnostics, ...(projection?.diagnostics ?? [])] }
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function projectionFromSlideInput(workspaceRoot: string, input: DeckPlanSlideUpsertInput & { id: string }, existing?: DeckPlanSlideProjection): DeckPlanSlideProjection {
|
|
358
|
+
const sourceLinks = sourceLinksForInput(input)
|
|
359
|
+
const caveats = uniqueStrings([...(input.caveats ?? []), ...sourceLinks.caveats])
|
|
360
|
+
const componentPlan = input.components.map(componentInputToPlan)
|
|
361
|
+
const slide: DeckPlanSlideProjection = {
|
|
362
|
+
path: DECK_PLAN_MARKDOWN_PATH,
|
|
363
|
+
absolutePath: join(workspaceRoot, DECK_PLAN_MARKDOWN_PATH),
|
|
364
|
+
id: input.id,
|
|
365
|
+
slideIndex: input.slideIndex,
|
|
366
|
+
title: input.title,
|
|
367
|
+
chapter: input.chapter,
|
|
368
|
+
layout: input.layout,
|
|
369
|
+
components: uniqueStrings(componentPlan.flatMap(flattenComponentNames)),
|
|
370
|
+
componentPlan,
|
|
371
|
+
structural: input.structural ?? false,
|
|
372
|
+
narrativeRole: input.narrativeRole,
|
|
373
|
+
markdown: "",
|
|
374
|
+
frontmatter: existing?.frontmatter ?? {},
|
|
375
|
+
sections: [],
|
|
376
|
+
links: sourceLinksToNarrativeLinks(sourceLinks, input.narrativeLinks ? sourceLinksToNarrativeLinks(sourceLinksFromNarrativeLinks(input.narrativeLinks)) : []),
|
|
377
|
+
sourceLinks,
|
|
378
|
+
caveats,
|
|
379
|
+
}
|
|
380
|
+
slide.markdown = renderDeckPlanSlideBlock(slide, input.visualIntent)
|
|
381
|
+
return slide
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function componentInputToPlan(component: DeckPlanSlideUpsertComponentInput): DeckPlanSlideComponentPlan {
|
|
385
|
+
return normalizeComponentPlan({
|
|
386
|
+
name: component.name?.trim() || "",
|
|
387
|
+
slot: component.slot?.trim() || "",
|
|
388
|
+
position: component.position?.trim() || "",
|
|
389
|
+
purpose: component.purpose?.trim() || "",
|
|
390
|
+
content: component.content?.trim() || "",
|
|
391
|
+
claimIds: uniqueStrings(component.claimIds ?? []),
|
|
392
|
+
evidenceIds: uniqueStrings(component.evidenceIds ?? []),
|
|
393
|
+
sourceNotes: (component.sourceNotes ?? []).map((item) => item.trim()).filter(Boolean),
|
|
394
|
+
renderNotes: (component.renderNotes ?? []).map((item) => item.trim()).filter(Boolean),
|
|
395
|
+
placementNote: component.placementNote?.trim(),
|
|
396
|
+
children: component.children?.map(componentInputToPlan),
|
|
397
|
+
})
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function flattenComponentNames(component: DeckPlanSlideComponentPlan): string[] {
|
|
401
|
+
return [component.name, ...(component.children ?? []).flatMap(flattenComponentNames)].filter(Boolean)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function writeDeckPlanSingleFile(workspaceRoot: string, input: {
|
|
405
|
+
title: string
|
|
406
|
+
goal?: string
|
|
407
|
+
audience?: string
|
|
408
|
+
designName?: string
|
|
409
|
+
outputPath?: string
|
|
410
|
+
sourceAuthority?: string[]
|
|
411
|
+
unresolvedInputs?: string[]
|
|
412
|
+
slides: DeckPlanSlideProjection[]
|
|
413
|
+
}): { path: string; absolutePath: string } {
|
|
414
|
+
const absolutePath = join(workspaceRoot, DECK_PLAN_MARKDOWN_PATH)
|
|
415
|
+
const lines: string[] = []
|
|
416
|
+
lines.push("---")
|
|
417
|
+
lines.push("type: deck-plan")
|
|
418
|
+
lines.push("version: 0.18.1")
|
|
419
|
+
if (input.designName) lines.push(`designName: ${yamlScalar(input.designName)}`)
|
|
420
|
+
if (input.outputPath) lines.push(`outputPath: ${yamlScalar(input.outputPath)}`)
|
|
421
|
+
lines.push("---")
|
|
422
|
+
lines.push("")
|
|
423
|
+
lines.push(`# ${input.title || "Deck Plan"}`)
|
|
424
|
+
lines.push("")
|
|
425
|
+
lines.push("## Goal")
|
|
426
|
+
lines.push("")
|
|
427
|
+
lines.push(input.goal || "To be specified from user intent and source materials.")
|
|
428
|
+
lines.push("")
|
|
429
|
+
lines.push("## Audience")
|
|
430
|
+
lines.push("")
|
|
431
|
+
lines.push(input.audience || "To be specified.")
|
|
432
|
+
lines.push("")
|
|
433
|
+
lines.push("## Design")
|
|
434
|
+
lines.push("")
|
|
435
|
+
lines.push(`- Design: ${input.designName || "active design"}`)
|
|
436
|
+
if (input.outputPath) lines.push(`- Output path: ${input.outputPath}`)
|
|
437
|
+
lines.push("")
|
|
438
|
+
lines.push("## Source Authority")
|
|
439
|
+
lines.push("")
|
|
440
|
+
const sourceAuthority = input.sourceAuthority?.filter(Boolean) ?? ["Local materials, reviewed findings, workspace assets, explicit URLs, and user intent are the source context.", "Deck-plan is the render execution plan for HTML deck generation."]
|
|
441
|
+
for (const item of sourceAuthority) lines.push(`- ${item}`)
|
|
442
|
+
lines.push("")
|
|
443
|
+
lines.push("## Chapter Map")
|
|
444
|
+
lines.push("")
|
|
445
|
+
const chapterMap = new Map<string, number[]>()
|
|
446
|
+
for (const slide of input.slides) chapterMap.set(slide.chapter || "Unassigned", [...(chapterMap.get(slide.chapter || "Unassigned") ?? []), slide.slideIndex ?? 0].filter(Boolean))
|
|
447
|
+
for (const [chapter, indexes] of chapterMap) lines.push(`- ${chapter}: slides ${formatSlideRange(indexes)}`)
|
|
448
|
+
if (chapterMap.size === 0) lines.push("- No slides planned yet.")
|
|
449
|
+
lines.push("")
|
|
450
|
+
lines.push("## Slides")
|
|
451
|
+
lines.push("")
|
|
452
|
+
for (const slide of input.slides) {
|
|
453
|
+
lines.push(renderDeckPlanSlideBlock(slide))
|
|
454
|
+
lines.push("")
|
|
455
|
+
}
|
|
456
|
+
lines.push("## Unresolved Inputs")
|
|
457
|
+
lines.push("")
|
|
458
|
+
const unresolved = input.unresolvedInputs?.filter(Boolean) ?? []
|
|
459
|
+
if (unresolved.length === 0) lines.push("- None.")
|
|
460
|
+
else for (const item of unresolved) lines.push(`- ${item}`)
|
|
461
|
+
lines.push("")
|
|
462
|
+
lines.push("## HTML Contract")
|
|
463
|
+
lines.push("")
|
|
464
|
+
lines.push("- Render one `<section class=\"slide\" data-slide-index=\"N\">` per planned slide.")
|
|
465
|
+
lines.push("- Use positive 1-based slide indexes, unique indexes, DOM order, and one direct `.slide-canvas` child per slide.")
|
|
466
|
+
lines.push("")
|
|
467
|
+
writeFileSync(absolutePath, lines.join("\n"), "utf-8")
|
|
468
|
+
return { path: DECK_PLAN_MARKDOWN_PATH, absolutePath }
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function renderDeckPlanSlideBlock(slide: DeckPlanSlideProjection, visualIntent?: DeckPlanSlideUpsertInput["visualIntent"]): string {
|
|
472
|
+
const lines: string[] = []
|
|
473
|
+
lines.push(`### Slide ${slide.slideIndex ?? "?"} — ${slide.title}`)
|
|
474
|
+
lines.push("")
|
|
475
|
+
lines.push(`- Id: ${slide.id}`)
|
|
476
|
+
lines.push(`- Chapter: ${slide.chapter || "Unassigned"}`)
|
|
477
|
+
lines.push(`- Role: ${slide.narrativeRole || "Not specified"}`)
|
|
478
|
+
lines.push(`- Structural: ${slide.structural ? "true" : "false"}`)
|
|
479
|
+
lines.push(`- Layout: ${slide.layout || "unspecified"}`)
|
|
480
|
+
lines.push(`- Components: ${slide.components.join(", ") || "none"}`)
|
|
481
|
+
lines.push("")
|
|
482
|
+
lines.push("#### Visual Intent")
|
|
483
|
+
lines.push("")
|
|
484
|
+
if (visualIntent) lines.push(renderVisualIntent(visualIntent))
|
|
485
|
+
else lines.push("- Brief: Not specified.")
|
|
486
|
+
lines.push("")
|
|
487
|
+
lines.push("#### Component Plan")
|
|
488
|
+
lines.push("")
|
|
489
|
+
for (const component of slide.componentPlan) lines.push(renderComponentPlanMarkdown(component, 5))
|
|
490
|
+
lines.push("#### Source Links")
|
|
491
|
+
lines.push("")
|
|
492
|
+
lines.push(renderSourceLinksMarkdown(slide.sourceLinks))
|
|
493
|
+
lines.push("")
|
|
494
|
+
lines.push("#### Caveats")
|
|
495
|
+
lines.push("")
|
|
496
|
+
if (slide.caveats.length === 0) lines.push("- None.")
|
|
497
|
+
else for (const caveat of slide.caveats) lines.push(`- ${caveat}`)
|
|
498
|
+
lines.push("")
|
|
499
|
+
return lines.join("\n")
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function renderComponentPlanMarkdown(component: DeckPlanSlideComponentPlan, headingLevel: number): string {
|
|
503
|
+
const lines: string[] = []
|
|
504
|
+
lines.push(`${"#".repeat(headingLevel)} ${component.name}`)
|
|
505
|
+
lines.push("")
|
|
506
|
+
lines.push(`- Slot: ${component.slot}`)
|
|
507
|
+
lines.push(`- Position: ${component.position}`)
|
|
508
|
+
if (component.placementNote) lines.push(`- Placement note: ${component.placementNote}`)
|
|
509
|
+
lines.push(`- Purpose: ${component.purpose}`)
|
|
510
|
+
lines.push("- Content:")
|
|
511
|
+
lines.push(...indentMultiline(component.content))
|
|
512
|
+
lines.push(`- Claim ids: ${formatCsv(component.claimIds)}`)
|
|
513
|
+
lines.push(`- Evidence ids: ${formatCsv(component.evidenceIds)}`)
|
|
514
|
+
lines.push(`- Source notes: ${formatListValue(component.sourceNotes)}`)
|
|
515
|
+
lines.push(`- Render notes: ${formatListValue(component.renderNotes)}`)
|
|
516
|
+
lines.push("")
|
|
517
|
+
for (const child of component.children ?? []) lines.push(renderComponentPlanMarkdown(child, headingLevel + 1))
|
|
518
|
+
return lines.join("\n")
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function renderSourceLinksMarkdown(sourceLinks: DeckPlanSourceLinks): string {
|
|
522
|
+
const lines: string[] = []
|
|
523
|
+
for (const [label, values] of [
|
|
524
|
+
["Materials", sourceLinks.materials],
|
|
525
|
+
["Findings", sourceLinks.findings],
|
|
526
|
+
["Assets", sourceLinks.assets],
|
|
527
|
+
["URLs", sourceLinks.urls],
|
|
528
|
+
["Caveats", sourceLinks.caveats],
|
|
529
|
+
] as const) {
|
|
530
|
+
lines.push(`${label}:`)
|
|
531
|
+
if (values.length === 0) lines.push("- None.")
|
|
532
|
+
else for (const value of values) lines.push(value.includes("/") && !/^https?:\/\//i.test(value) ? `- [[${value}]]` : `- ${value}`)
|
|
533
|
+
lines.push("")
|
|
534
|
+
}
|
|
535
|
+
return lines.join("\n").trim()
|
|
536
|
+
}
|
|
537
|
+
|
|
250
538
|
function readDeckPlanSlideFiles(workspaceRoot: string, knownNodeIds?: Set<string>): DeckPlanSlideProjection[] {
|
|
251
539
|
const slidesDir = join(workspaceRoot, DECK_PLAN_SLIDES_DIR)
|
|
252
540
|
if (!existsSync(slidesDir) || !statSync(slidesDir).isDirectory()) return []
|
|
@@ -259,7 +547,10 @@ function readDeckPlanSlideFiles(workspaceRoot: string, knownNodeIds?: Set<string
|
|
|
259
547
|
const split = splitMarkdownSections(parsed.body)
|
|
260
548
|
const path = relativePath(workspaceRoot, absolutePath)
|
|
261
549
|
const id = stringField(parsed.frontmatter, "id") || fileId(entry)
|
|
262
|
-
const
|
|
550
|
+
const componentPlan = parseDeckPlanComponentPlan(split.sections["component-plan"] ?? "")
|
|
551
|
+
const sourceLinks = parseDeckPlanSourceLinks(split.sections["source-links"] ?? "")
|
|
552
|
+
const links = sourceLinksToNarrativeLinks(sourceLinks, parseDeckPlanNarrativeLinks(split.sections["narrative-links"] ?? parsed.body, knownNodeIds))
|
|
553
|
+
const caveats = parseBulletText(split.sections["caveats"] ?? "")
|
|
263
554
|
slides.push({
|
|
264
555
|
path,
|
|
265
556
|
absolutePath,
|
|
@@ -269,17 +560,87 @@ function readDeckPlanSlideFiles(workspaceRoot: string, knownNodeIds?: Set<string
|
|
|
269
560
|
chapter: stringField(parsed.frontmatter, "chapter"),
|
|
270
561
|
layout: stringField(parsed.frontmatter, "layout"),
|
|
271
562
|
components: arrayField(parsed.frontmatter, "components"),
|
|
563
|
+
componentPlan,
|
|
272
564
|
structural: booleanField(parsed.frontmatter, "structural", false),
|
|
273
565
|
narrativeRole: stringField(parsed.frontmatter, "narrativeRole"),
|
|
274
566
|
markdown,
|
|
275
567
|
frontmatter: parsed.frontmatter,
|
|
276
568
|
sections: parseMarkdownSections(markdown),
|
|
277
569
|
links,
|
|
570
|
+
sourceLinks,
|
|
571
|
+
caveats,
|
|
278
572
|
})
|
|
279
573
|
}
|
|
280
574
|
return slides.sort((a, b) => (a.slideIndex ?? Number.MAX_SAFE_INTEGER) - (b.slideIndex ?? Number.MAX_SAFE_INTEGER) || a.path.localeCompare(b.path))
|
|
281
575
|
}
|
|
282
576
|
|
|
577
|
+
function readDeckPlanSlidesFromSingleFile(workspaceRoot: string, absolutePath: string, markdown: string, knownNodeIds?: Set<string>): DeckPlanSlideProjection[] {
|
|
578
|
+
const path = relativePath(workspaceRoot, absolutePath)
|
|
579
|
+
const body = parseVaultFrontmatter(markdown).body
|
|
580
|
+
const matches = [...body.matchAll(/^[ \t]*###\s+Slide\s+(\d+)\s+(?:—|-)\s+(.+?)\s*$/gm)]
|
|
581
|
+
const slides: DeckPlanSlideProjection[] = []
|
|
582
|
+
for (let i = 0; i < matches.length; i++) {
|
|
583
|
+
const match = matches[i]
|
|
584
|
+
const start = match.index ?? 0
|
|
585
|
+
const nextSlide = i + 1 < matches.length ? matches[i + 1].index ?? body.length : body.length
|
|
586
|
+
const headingEnd = body.indexOf("\n", start)
|
|
587
|
+
const searchStart = headingEnd === -1 ? start + match[0].length : headingEnd + 1
|
|
588
|
+
const nextSection = body.slice(searchStart).search(/^[ \t]*##\s+(?!#)/m)
|
|
589
|
+
const end = Math.min(nextSlide, nextSection === -1 ? body.length : searchStart + nextSection)
|
|
590
|
+
const block = body.slice(start, end).trim()
|
|
591
|
+
const slideIndex = Number(match[1])
|
|
592
|
+
const title = match[2].trim()
|
|
593
|
+
const fields = parseSlideBlockFields(block)
|
|
594
|
+
const id = fields.id || `slide-${slugify(title)}`
|
|
595
|
+
const sourceLinks = normalizeSourceLinks(parseDeckPlanSourceLinks(singleFileSubsection(block, "Source Links")))
|
|
596
|
+
const narrativeLinks = parseDeckPlanNarrativeLinks(singleFileSubsection(block, "Narrative Links") || block, knownNodeIds)
|
|
597
|
+
const links = sourceLinksToNarrativeLinks(sourceLinks, narrativeLinks)
|
|
598
|
+
const caveats = uniqueStrings([...parseBulletText(singleFileSubsection(block, "Caveats")), ...sourceLinks.caveats])
|
|
599
|
+
const componentPlan = parseDeckPlanComponentPlan(singleFileSubsection(block, "Component Plan"))
|
|
600
|
+
slides.push({
|
|
601
|
+
path,
|
|
602
|
+
absolutePath,
|
|
603
|
+
id,
|
|
604
|
+
slideIndex,
|
|
605
|
+
title,
|
|
606
|
+
chapter: fields.chapter || "",
|
|
607
|
+
layout: fields.layout || "",
|
|
608
|
+
components: parseCsv(fields.components || componentPlan.map((component) => component.name).join(", ")),
|
|
609
|
+
componentPlan,
|
|
610
|
+
structural: fields.structural === "true" || fields.structural === "yes",
|
|
611
|
+
narrativeRole: fields.role || fields.narrativeRole || "",
|
|
612
|
+
markdown: block,
|
|
613
|
+
frontmatter: {},
|
|
614
|
+
sections: parseMarkdownSections(block),
|
|
615
|
+
links,
|
|
616
|
+
sourceLinks,
|
|
617
|
+
caveats,
|
|
618
|
+
})
|
|
619
|
+
}
|
|
620
|
+
return slides.sort((a, b) => (a.slideIndex ?? Number.MAX_SAFE_INTEGER) - (b.slideIndex ?? Number.MAX_SAFE_INTEGER))
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function parseSlideBlockFields(block: string): Record<string, string> {
|
|
624
|
+
const fields: Record<string, string> = {}
|
|
625
|
+
for (const rawLine of block.split(/\r?\n/)) {
|
|
626
|
+
const match = /^-\s+([A-Za-z][A-Za-z ]+):\s*(.*)$/.exec(rawLine.trim())
|
|
627
|
+
if (!match) continue
|
|
628
|
+
const key = match[1].trim().replace(/\s+/g, "")
|
|
629
|
+
fields[key[0].toLowerCase() + key.slice(1)] = cleanPlanValue(match[2])
|
|
630
|
+
}
|
|
631
|
+
return fields
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function singleFileSubsection(block: string, heading: string): string {
|
|
635
|
+
const re = new RegExp(`^[ \\t]*####\\s+${heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "mi")
|
|
636
|
+
const match = re.exec(block)
|
|
637
|
+
if (!match || match.index === undefined) return ""
|
|
638
|
+
const start = match.index + match[0].length
|
|
639
|
+
const rest = block.slice(start)
|
|
640
|
+
const next = rest.search(/^[ \t]*####\s+/m)
|
|
641
|
+
return (next === -1 ? rest : rest.slice(0, next)).trim()
|
|
642
|
+
}
|
|
643
|
+
|
|
283
644
|
function parseDeckPlanNarrativeLinks(section: string, knownNodeIds?: Set<string>): DeckPlanNarrativeLink[] {
|
|
284
645
|
const links: DeckPlanNarrativeLink[] = []
|
|
285
646
|
let group = ""
|
|
@@ -298,8 +659,97 @@ function parseDeckPlanNarrativeLinks(section: string, knownNodeIds?: Set<string>
|
|
|
298
659
|
return uniqueLinks(links)
|
|
299
660
|
}
|
|
300
661
|
|
|
662
|
+
function emptySourceLinks(): DeckPlanSourceLinks {
|
|
663
|
+
return { materials: [], findings: [], assets: [], urls: [], caveats: [] }
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function normalizeSourceLinks(input?: Partial<DeckPlanSourceLinks>): DeckPlanSourceLinks {
|
|
667
|
+
return {
|
|
668
|
+
materials: uniqueStrings(input?.materials ?? []),
|
|
669
|
+
findings: uniqueStrings(input?.findings ?? []),
|
|
670
|
+
assets: uniqueStrings(input?.assets ?? []),
|
|
671
|
+
urls: uniqueStrings(input?.urls ?? []),
|
|
672
|
+
caveats: uniqueStrings(input?.caveats ?? []),
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function sourceLinksFromNarrativeLinks(input?: DeckPlanSlideUpsertInput["narrativeLinks"]): DeckPlanSourceLinks {
|
|
677
|
+
const links = emptySourceLinks()
|
|
678
|
+
for (const id of input?.evidenceIds ?? []) {
|
|
679
|
+
if (/^https?:\/\//i.test(id)) links.urls.push(id)
|
|
680
|
+
else if (id.startsWith("assets/")) links.assets.push(id)
|
|
681
|
+
else if (id.startsWith("researches/")) links.findings.push(id)
|
|
682
|
+
else if (id.startsWith("materials/") || id.startsWith("sources/")) links.materials.push(id)
|
|
683
|
+
else links.findings.push(id)
|
|
684
|
+
}
|
|
685
|
+
for (const id of input?.claimIds ?? []) {
|
|
686
|
+
if (id.startsWith("researches/")) links.findings.push(id)
|
|
687
|
+
else if (id.startsWith("assets/")) links.assets.push(id)
|
|
688
|
+
else links.materials.push(id)
|
|
689
|
+
}
|
|
690
|
+
links.caveats.push(...(input?.riskIds ?? []), ...(input?.objectionIds ?? []), ...(input?.gapIds ?? []))
|
|
691
|
+
return normalizeSourceLinks(links)
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function sourceLinksForInput(input: DeckPlanSlideUpsertInput): DeckPlanSourceLinks {
|
|
695
|
+
return normalizeSourceLinks({
|
|
696
|
+
...sourceLinksFromNarrativeLinks(input.narrativeLinks),
|
|
697
|
+
...(input.sourceLinks ?? {}),
|
|
698
|
+
caveats: [...(sourceLinksFromNarrativeLinks(input.narrativeLinks).caveats ?? []), ...(input.sourceLinks?.caveats ?? []), ...(input.caveats ?? [])],
|
|
699
|
+
})
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function parseDeckPlanSourceLinks(section: string): DeckPlanSourceLinks {
|
|
703
|
+
const links = emptySourceLinks()
|
|
704
|
+
let group: keyof DeckPlanSourceLinks | undefined
|
|
705
|
+
for (const rawLine of section.replace(/\r\n/g, "\n").split("\n")) {
|
|
706
|
+
const heading = /^\s*([A-Za-z][A-Za-z\s/-]*):\s*$/.exec(rawLine)
|
|
707
|
+
if (heading) {
|
|
708
|
+
const normalized = heading[1].trim().toLowerCase()
|
|
709
|
+
if (normalized.includes("material")) group = "materials"
|
|
710
|
+
else if (normalized.includes("finding") || normalized.includes("research")) group = "findings"
|
|
711
|
+
else if (normalized.includes("asset") || normalized.includes("media")) group = "assets"
|
|
712
|
+
else if (normalized.includes("url") || normalized.includes("link")) group = "urls"
|
|
713
|
+
else if (normalized.includes("caveat") || normalized.includes("risk") || normalized.includes("gap")) group = "caveats"
|
|
714
|
+
else group = undefined
|
|
715
|
+
continue
|
|
716
|
+
}
|
|
717
|
+
const bullet = rawLine.replace(/^\s*[-*]\s+/, "").trim()
|
|
718
|
+
if (!bullet || bullet.toLowerCase() === "none.") continue
|
|
719
|
+
const wikilink = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/.exec(bullet)?.[1]?.trim()
|
|
720
|
+
const value = wikilink || bullet
|
|
721
|
+
if (group) links[group].push(value)
|
|
722
|
+
else if (/^https?:\/\//i.test(value)) links.urls.push(value)
|
|
723
|
+
else if (value.startsWith("assets/")) links.assets.push(value)
|
|
724
|
+
else if (value.startsWith("researches/")) links.findings.push(value)
|
|
725
|
+
else links.materials.push(value)
|
|
726
|
+
}
|
|
727
|
+
return normalizeSourceLinks(links)
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function sourceLinksToNarrativeLinks(sourceLinks: DeckPlanSourceLinks, compatibility: DeckPlanNarrativeLink[] = []): DeckPlanNarrativeLink[] {
|
|
731
|
+
const links: DeckPlanNarrativeLink[] = []
|
|
732
|
+
for (const id of sourceLinks.materials) links.push({ id, relation: "uses_evidence", group: "materials" })
|
|
733
|
+
for (const id of sourceLinks.findings) links.push({ id, relation: "uses_evidence", group: "findings" })
|
|
734
|
+
for (const id of sourceLinks.assets) links.push({ id, relation: "uses_evidence", group: "assets" })
|
|
735
|
+
for (const id of sourceLinks.urls) links.push({ id, relation: "uses_evidence", group: "urls" })
|
|
736
|
+
for (const id of sourceLinks.caveats) links.push({ id, relation: "mentions_gap", group: "caveats" })
|
|
737
|
+
return uniqueLinks([...links, ...compatibility])
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function parseBulletText(section: string): string[] {
|
|
741
|
+
return section
|
|
742
|
+
.replace(/\r\n/g, "\n")
|
|
743
|
+
.split("\n")
|
|
744
|
+
.map((line) => line.replace(/^\s*[-*]\s+/, "").trim())
|
|
745
|
+
.filter((line) => line && line.toLowerCase() !== "none.")
|
|
746
|
+
}
|
|
747
|
+
|
|
301
748
|
function relationForDeckPlanLink(group: string, id: string): DeckPlanNarrativeLink["relation"] {
|
|
302
749
|
const normalized = group.toLowerCase()
|
|
750
|
+
if (normalized.includes("source") || normalized.includes("material")) return "uses_evidence"
|
|
751
|
+
if (normalized.includes("finding") || normalized.includes("research")) return "uses_evidence"
|
|
752
|
+
if (normalized.includes("asset") || normalized.includes("media")) return "uses_evidence"
|
|
303
753
|
if (normalized.includes("evidence") || id.startsWith("evidence")) return "uses_evidence"
|
|
304
754
|
if (normalized.includes("risk") || id.startsWith("risk")) return "addresses_risk"
|
|
305
755
|
if (normalized.includes("objection") || id.startsWith("objection")) return "answers_objection"
|
|
@@ -308,6 +758,8 @@ function relationForDeckPlanLink(group: string, id: string): DeckPlanNarrativeLi
|
|
|
308
758
|
}
|
|
309
759
|
|
|
310
760
|
function inferredLinkGroup(id: string, knownNodeIds?: Set<string>): string {
|
|
761
|
+
if (id.startsWith("researches/")) return "findings"
|
|
762
|
+
if (id.startsWith("assets/")) return "assets"
|
|
311
763
|
if (id.startsWith("evidence")) return "evidence"
|
|
312
764
|
if (id.startsWith("risk")) return "risk"
|
|
313
765
|
if (id.startsWith("objection")) return "objection"
|
|
@@ -318,7 +770,7 @@ function inferredLinkGroup(id: string, knownNodeIds?: Set<string>): string {
|
|
|
318
770
|
|
|
319
771
|
function deckPlanIndexDiagnostics(slides: DeckPlanSlideProjection[]): DeckPlanProjectionDiagnostic[] {
|
|
320
772
|
const diagnostics: DeckPlanProjectionDiagnostic[] = []
|
|
321
|
-
if (slides.length === 0) diagnostics.push({ severity: "warning", code: "deck_plan_slides_missing", message: "deck-plan
|
|
773
|
+
if (slides.length === 0) diagnostics.push({ severity: "warning", code: "deck_plan_slides_missing", message: "deck-plan.md contains no slide blocks." })
|
|
322
774
|
const seen = new Map<number, DeckPlanSlideProjection>()
|
|
323
775
|
let previous = 0
|
|
324
776
|
for (const slide of slides) {
|
|
@@ -337,7 +789,15 @@ function deckPlanIndexDiagnostics(slides: DeckPlanSlideProjection[]): DeckPlanPr
|
|
|
337
789
|
|
|
338
790
|
function slideDiagnostics(slide: DeckPlanSlideProjection, knownNodeIds?: Set<string>): DeckPlanProjectionDiagnostic[] {
|
|
339
791
|
const diagnostics: DeckPlanProjectionDiagnostic[] = []
|
|
340
|
-
if (!slide.structural &&
|
|
792
|
+
if (!slide.structural && slide.links.length === 0 && slide.caveats.length === 0) diagnostics.push({ severity: "warning", code: "slide_source_link_missing", message: `Non-structural deck-plan slide ${slide.id} has no source, research, asset, or caveat link.`, file: slide.path, nodeId: slide.id })
|
|
793
|
+
if (!slide.layout) diagnostics.push({ severity: "warning", code: "slide_layout_missing", message: `Deck-plan slide ${slide.id} is missing a layout.`, file: slide.path, nodeId: slide.id })
|
|
794
|
+
if (slide.components.length === 0) diagnostics.push({ severity: "warning", code: "slide_components_missing", message: `Deck-plan slide ${slide.id} has no component names in frontmatter.`, file: slide.path, nodeId: slide.id })
|
|
795
|
+
if (slide.componentPlan.length === 0) diagnostics.push({ severity: "warning", code: "slide_component_plan_missing", message: `Deck-plan slide ${slide.id} is missing structured ## Component Plan entries.`, file: slide.path, nodeId: slide.id })
|
|
796
|
+
for (const component of slide.componentPlan) {
|
|
797
|
+
for (const key of ["name", "slot", "position", "purpose", "content"] as const) {
|
|
798
|
+
if (!component[key] || (Array.isArray(component[key]) && component[key].length === 0)) diagnostics.push({ severity: "warning", code: "slide_component_plan_incomplete", message: `Deck-plan slide ${slide.id} has incomplete component plan entry for ${component.name || "unnamed component"}: missing ${key}.`, file: slide.path, nodeId: slide.id })
|
|
799
|
+
}
|
|
800
|
+
}
|
|
341
801
|
if (knownNodeIds) {
|
|
342
802
|
for (const link of slide.links) {
|
|
343
803
|
if (!knownNodeIds.has(link.id)) diagnostics.push({ severity: "warning", code: "deck_plan_broken_link", message: `Deck-plan slide ${slide.id} links to unknown narrative node ${link.id}.`, file: slide.path, nodeId: slide.id })
|
|
@@ -346,6 +806,302 @@ function slideDiagnostics(slide: DeckPlanSlideProjection, knownNodeIds?: Set<str
|
|
|
346
806
|
return diagnostics
|
|
347
807
|
}
|
|
348
808
|
|
|
809
|
+
export function deckPlanDesignDiagnostics(projection: DeckPlanProjection | undefined, inventory: { layouts: string[]; components: string[]; layoutSlots?: Record<string, string[]>; componentNesting?: Record<string, { acceptsChildren: boolean; allowedChildren?: string[] }> }): DeckPlanProjectionDiagnostic[] {
|
|
810
|
+
if (!projection) return []
|
|
811
|
+
const layouts = new Set(inventory.layouts)
|
|
812
|
+
const components = new Set(inventory.components)
|
|
813
|
+
const diagnostics: DeckPlanProjectionDiagnostic[] = []
|
|
814
|
+
for (const slide of projection.slides) {
|
|
815
|
+
if (slide.layout && !layouts.has(slide.layout)) diagnostics.push({ severity: "warning", code: "slide_layout_unknown", message: `Deck-plan slide ${slide.id} uses layout '${slide.layout}' outside the active design inventory.`, file: slide.path, nodeId: slide.id })
|
|
816
|
+
for (const component of slide.componentPlan) diagnostics.push(...componentDesignDiagnostics(slide, component, inventory, components))
|
|
817
|
+
}
|
|
818
|
+
return diagnostics
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function componentDesignDiagnostics(slide: DeckPlanSlideProjection, component: DeckPlanSlideComponentPlan, inventory: { layoutSlots?: Record<string, string[]>; componentNesting?: Record<string, { acceptsChildren: boolean; allowedChildren?: string[] }> }, components: Set<string>): DeckPlanProjectionDiagnostic[] {
|
|
822
|
+
const diagnostics: DeckPlanProjectionDiagnostic[] = []
|
|
823
|
+
if (component.name && !components.has(component.name)) diagnostics.push({ severity: "warning", code: "slide_component_plan_unknown", message: `Deck-plan slide ${slide.id} component plan uses '${component.name}' outside the active design inventory.`, file: slide.path, nodeId: slide.id })
|
|
824
|
+
const allowedSlots = slide.layout ? inventory.layoutSlots?.[slide.layout] : undefined
|
|
825
|
+
if (component.slot && allowedSlots && allowedSlots.length > 0 && !allowedSlots.includes(component.slot)) diagnostics.push({ severity: "warning", code: "slide_component_slot_invalid", message: `Deck-plan slide ${slide.id} component '${component.name}' uses slot '${component.slot}' outside layout '${slide.layout}' slots: ${allowedSlots.join(", ")}.`, file: slide.path, nodeId: slide.id })
|
|
826
|
+
const nesting = inventory.componentNesting?.[component.name]
|
|
827
|
+
if ((component.children?.length ?? 0) > 0 && nesting && !nesting.acceptsChildren) diagnostics.push({ severity: "warning", code: "slide_component_children_invalid", message: `Deck-plan slide ${slide.id} component '${component.name}' does not accept children.`, file: slide.path, nodeId: slide.id })
|
|
828
|
+
for (const child of component.children ?? []) diagnostics.push(...componentDesignDiagnostics(slide, child, inventory, components))
|
|
829
|
+
return diagnostics
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function validateDeckPlanSlideUpsert(input: DeckPlanSlideUpsertInput, options: { designLayouts: string[]; designComponents: string[]; layoutSlots?: Record<string, string[]>; componentNesting?: Record<string, { acceptsChildren: boolean; allowedChildren?: string[] }> }): DeckPlanProjectionDiagnostic[] {
|
|
833
|
+
const diagnostics: DeckPlanProjectionDiagnostic[] = []
|
|
834
|
+
const nodeId = input.id?.trim() || `slide-${input.slideIndex}`
|
|
835
|
+
if (!Number.isInteger(input.slideIndex) || input.slideIndex < 1) diagnostics.push(errorDiagnostic("slide_index_invalid", "slideIndex must be a positive 1-based integer.", nodeId))
|
|
836
|
+
for (const [key, value] of [["title", input.title], ["chapter", input.chapter], ["narrativeRole", input.narrativeRole], ["layout", input.layout]] as const) {
|
|
837
|
+
if (!String(value || "").trim()) diagnostics.push(errorDiagnostic(`slide_${key}_missing`, `${key} is required.`, nodeId))
|
|
838
|
+
}
|
|
839
|
+
if (!options.designLayouts.includes(input.layout)) diagnostics.push(errorDiagnostic("slide_layout_unknown", `Layout '${input.layout}' is not in the selected design inventory.`, nodeId))
|
|
840
|
+
if (!Array.isArray(input.components) || input.components.length === 0) diagnostics.push(errorDiagnostic("slide_components_missing", "At least one component plan entry is required.", nodeId))
|
|
841
|
+
const componentNames = new Set<string>()
|
|
842
|
+
const positions = new Set<string>()
|
|
843
|
+
const allowedSlots = options.layoutSlots?.[input.layout]
|
|
844
|
+
for (const component of input.components ?? []) {
|
|
845
|
+
validateComponentInput(component, { nodeId, componentNames, positions, options, allowedSlots, parentName: undefined, topLevel: true, diagnostics })
|
|
846
|
+
}
|
|
847
|
+
const visual = normalizeVisualIntent(input.visualIntent)
|
|
848
|
+
if (visual.component && !componentNames.has(visual.component)) diagnostics.push(errorDiagnostic("slide_visual_component_missing", `visualIntent.component '${visual.component}' is not present in component plan.`, nodeId))
|
|
849
|
+
const sourceLinks = sourceLinksForInput(input)
|
|
850
|
+
if (!input.structural && linksCount(sourceLinks) === 0) diagnostics.push({ severity: "warning", code: "slide_source_link_missing", message: "Non-structural slides should include at least one material, finding, asset, URL, or caveat source link.", nodeId })
|
|
851
|
+
return diagnostics
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function validateComponentInput(component: DeckPlanSlideUpsertComponentInput, context: {
|
|
855
|
+
nodeId: string
|
|
856
|
+
componentNames: Set<string>
|
|
857
|
+
positions: Set<string>
|
|
858
|
+
options: { designComponents: string[]; componentNesting?: Record<string, { acceptsChildren: boolean; allowedChildren?: string[] }> }
|
|
859
|
+
allowedSlots?: string[]
|
|
860
|
+
parentName?: string
|
|
861
|
+
topLevel: boolean
|
|
862
|
+
diagnostics: DeckPlanProjectionDiagnostic[]
|
|
863
|
+
}): void {
|
|
864
|
+
const name = component.name?.trim()
|
|
865
|
+
if (name) context.componentNames.add(name)
|
|
866
|
+
if (!name) context.diagnostics.push(errorDiagnostic("slide_component_name_missing", "Every component requires name.", context.nodeId))
|
|
867
|
+
else if (!context.options.designComponents.includes(name)) context.diagnostics.push(errorDiagnostic("slide_component_unknown", `Component '${name}' is not in the selected design inventory.`, context.nodeId))
|
|
868
|
+
for (const key of ["slot", "position", "purpose", "content"] as const) {
|
|
869
|
+
if (!String(component[key] || "").trim()) context.diagnostics.push(errorDiagnostic("slide_component_plan_incomplete", `Component '${name || "unnamed"}' is missing ${key}.`, context.nodeId))
|
|
870
|
+
}
|
|
871
|
+
if (context.topLevel && component.slot && context.allowedSlots && context.allowedSlots.length > 0 && !context.allowedSlots.includes(component.slot.trim())) context.diagnostics.push(errorDiagnostic("slide_component_slot_invalid", `Component '${name || "unnamed"}' slot '${component.slot}' is not valid for this layout. Allowed slots: ${context.allowedSlots.join(", ")}.`, context.nodeId))
|
|
872
|
+
if (component.position && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(component.position)) context.diagnostics.push(errorDiagnostic("slide_component_position_invalid", `Component '${name || "unnamed"}' position must be a non-empty kebab-case anchor.`, context.nodeId))
|
|
873
|
+
const positionKey = `${component.slot?.trim() || ""}:${component.position?.trim() || ""}`
|
|
874
|
+
if (component.slot?.trim() && component.position?.trim()) {
|
|
875
|
+
if (context.positions.has(positionKey)) context.diagnostics.push(errorDiagnostic("slide_component_position_duplicate", `Duplicate component slot/position '${positionKey}' makes the plan ambiguous.`, context.nodeId))
|
|
876
|
+
context.positions.add(positionKey)
|
|
877
|
+
}
|
|
878
|
+
const children = component.children ?? []
|
|
879
|
+
const nesting = name ? context.options.componentNesting?.[name] : undefined
|
|
880
|
+
if (children.length > 0 && nesting && !nesting.acceptsChildren) context.diagnostics.push(errorDiagnostic("slide_component_children_invalid", `Component '${name}' does not accept children. Use box as the semantic container.`, context.nodeId))
|
|
881
|
+
if (children.length > 0 && nesting?.allowedChildren) {
|
|
882
|
+
for (const child of children) {
|
|
883
|
+
if (child.name && !nesting.allowedChildren.includes(child.name)) context.diagnostics.push(errorDiagnostic("slide_component_child_invalid", `Component '${name}' cannot contain child '${child.name}'.`, context.nodeId))
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
for (const child of children) validateComponentInput(child, { ...context, parentName: name, topLevel: false })
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function linksCount(sourceLinks: DeckPlanSourceLinks): number {
|
|
890
|
+
return sourceLinks.materials.length + sourceLinks.findings.length + sourceLinks.assets.length + sourceLinks.urls.length + sourceLinks.caveats.length
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function errorDiagnostic(code: string, message: string, nodeId?: string): DeckPlanProjectionDiagnostic {
|
|
894
|
+
return { severity: "error", code, message, nodeId }
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function parseDeckPlanComponentPlan(section: string): DeckPlanSlideComponentPlan[] {
|
|
898
|
+
const components: DeckPlanSlideComponentPlan[] = []
|
|
899
|
+
let current: DeckPlanSlideComponentPlan | undefined
|
|
900
|
+
let currentChild: DeckPlanSlideComponentPlan | undefined
|
|
901
|
+
let capture: "content" | undefined
|
|
902
|
+
const flush = () => {
|
|
903
|
+
if (currentChild && current) {
|
|
904
|
+
current.children = [...(current.children ?? []), normalizeComponentPlan(currentChild)]
|
|
905
|
+
currentChild = undefined
|
|
906
|
+
}
|
|
907
|
+
if (current) components.push({
|
|
908
|
+
...normalizeComponentPlan(current),
|
|
909
|
+
children: current.children,
|
|
910
|
+
})
|
|
911
|
+
}
|
|
912
|
+
const target = () => currentChild ?? current
|
|
913
|
+
const startComponent = (name: string, child: boolean) => {
|
|
914
|
+
if (child && current) {
|
|
915
|
+
if (currentChild) current.children = [...(current.children ?? []), normalizeComponentPlan(currentChild)]
|
|
916
|
+
currentChild = blankComponentPlan(name)
|
|
917
|
+
} else {
|
|
918
|
+
flush()
|
|
919
|
+
current = blankComponentPlan(name)
|
|
920
|
+
currentChild = undefined
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
for (const rawLine of section.replace(/\r\n/g, "\n").split("\n")) {
|
|
924
|
+
const heading = /^(#{3,6})\s+(.+?)\s*$/.exec(rawLine)
|
|
925
|
+
if (heading) {
|
|
926
|
+
startComponent(heading[2].trim(), heading[1].length > 5)
|
|
927
|
+
capture = undefined
|
|
928
|
+
continue
|
|
929
|
+
}
|
|
930
|
+
if (!current) continue
|
|
931
|
+
const line = rawLine.trim()
|
|
932
|
+
const field = /^-\s+([A-Za-z][A-Za-z ]+):\s*(.*)$/.exec(line)
|
|
933
|
+
if (field) {
|
|
934
|
+
capture = undefined
|
|
935
|
+
const key = field[1].toLowerCase()
|
|
936
|
+
const value = field[2].trim()
|
|
937
|
+
const item = target()
|
|
938
|
+
if (!item) continue
|
|
939
|
+
if (key === "slot") item.slot = value
|
|
940
|
+
else if (key === "position") item.position = value
|
|
941
|
+
else if (key === "placement note") item.placementNote = value
|
|
942
|
+
else if (key === "purpose") item.purpose = value
|
|
943
|
+
else if (key === "content") {
|
|
944
|
+
item.content = cleanPlanValue(value)
|
|
945
|
+
capture = value ? undefined : "content"
|
|
946
|
+
} else if (key === "claim ids") item.claimIds = parseCsv(value)
|
|
947
|
+
else if (key === "evidence ids") item.evidenceIds = parseCsv(value)
|
|
948
|
+
else if (key === "source notes") item.sourceNotes = parseListValue(value)
|
|
949
|
+
else if (key === "render notes") item.renderNotes = parseListValue(value)
|
|
950
|
+
continue
|
|
951
|
+
}
|
|
952
|
+
if (capture === "content" && rawLine.trim()) {
|
|
953
|
+
const item = target()
|
|
954
|
+
if (item) item.content += `${item.content ? "\n" : ""}${rawLine.replace(/^\s{2}/, "")}`
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
flush()
|
|
958
|
+
return components
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function blankComponentPlan(name: string): DeckPlanSlideComponentPlan {
|
|
962
|
+
return { name, slot: "", position: "", purpose: "", content: "", claimIds: [], evidenceIds: [], sourceNotes: [], renderNotes: [] }
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function normalizeComponentPlan(component: DeckPlanSlideComponentPlan): DeckPlanSlideComponentPlan {
|
|
966
|
+
return {
|
|
967
|
+
...component,
|
|
968
|
+
content: component.content.trim(),
|
|
969
|
+
claimIds: uniqueStrings(component.claimIds),
|
|
970
|
+
evidenceIds: uniqueStrings(component.evidenceIds),
|
|
971
|
+
sourceNotes: component.sourceNotes.filter(Boolean),
|
|
972
|
+
renderNotes: component.renderNotes.filter(Boolean),
|
|
973
|
+
children: component.children?.map(normalizeComponentPlan),
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function renderDeckPlanSlideMarkdown(input: DeckPlanSlideUpsertInput & { id: string }): string {
|
|
978
|
+
const components = input.components.map((component) => component.name.trim())
|
|
979
|
+
const lines: string[] = []
|
|
980
|
+
lines.push("---")
|
|
981
|
+
lines.push("type: deck-plan-slide")
|
|
982
|
+
lines.push(`id: ${input.id}`)
|
|
983
|
+
lines.push(`slideIndex: ${input.slideIndex}`)
|
|
984
|
+
lines.push(`title: ${yamlScalar(input.title)}`)
|
|
985
|
+
lines.push(`chapter: ${yamlScalar(input.chapter)}`)
|
|
986
|
+
lines.push(`layout: ${input.layout.trim()}`)
|
|
987
|
+
lines.push(`components: [${components.map(yamlScalar).join(", ")}]`)
|
|
988
|
+
lines.push(`structural: ${input.structural ? "true" : "false"}`)
|
|
989
|
+
lines.push(`narrativeRole: ${yamlScalar(input.narrativeRole)}`)
|
|
990
|
+
lines.push("---")
|
|
991
|
+
lines.push("")
|
|
992
|
+
lines.push(`# ${input.title.trim()}`)
|
|
993
|
+
lines.push("")
|
|
994
|
+
lines.push("## Purpose")
|
|
995
|
+
lines.push("")
|
|
996
|
+
lines.push(input.narrativeRole.trim())
|
|
997
|
+
lines.push("")
|
|
998
|
+
lines.push("## Visual Intent")
|
|
999
|
+
lines.push("")
|
|
1000
|
+
lines.push(renderVisualIntent(input.visualIntent))
|
|
1001
|
+
lines.push("")
|
|
1002
|
+
lines.push("## Component Plan")
|
|
1003
|
+
lines.push("")
|
|
1004
|
+
for (const component of input.components) {
|
|
1005
|
+
lines.push(`### ${component.name.trim()}`)
|
|
1006
|
+
lines.push("")
|
|
1007
|
+
lines.push(`- Slot: ${component.slot.trim()}`)
|
|
1008
|
+
lines.push(`- Position: ${component.position.trim()}`)
|
|
1009
|
+
if (component.placementNote?.trim()) lines.push(`- Placement note: ${component.placementNote.trim()}`)
|
|
1010
|
+
lines.push(`- Purpose: ${component.purpose.trim()}`)
|
|
1011
|
+
lines.push("- Content:")
|
|
1012
|
+
lines.push(...indentMultiline(component.content.trim()))
|
|
1013
|
+
lines.push(`- Claim ids: ${formatCsv(component.claimIds)}`)
|
|
1014
|
+
lines.push(`- Evidence ids: ${formatCsv(component.evidenceIds)}`)
|
|
1015
|
+
lines.push(`- Source notes: ${formatListValue(component.sourceNotes)}`)
|
|
1016
|
+
lines.push(`- Render notes: ${formatListValue(component.renderNotes)}`)
|
|
1017
|
+
lines.push("")
|
|
1018
|
+
}
|
|
1019
|
+
lines.push(renderSourceLinksMarkdown(sourceLinksForInput(input)).replace(/^####/gm, "##"))
|
|
1020
|
+
lines.push("## Caveats")
|
|
1021
|
+
lines.push("")
|
|
1022
|
+
const caveats = input.caveats?.filter((item) => item.trim()) ?? []
|
|
1023
|
+
if (caveats.length === 0) lines.push("- None.")
|
|
1024
|
+
else for (const caveat of caveats) lines.push(`- ${caveat.trim()}`)
|
|
1025
|
+
lines.push("")
|
|
1026
|
+
return lines.join("\n")
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function ensureDeckPlanIndex(workspaceRoot: string, narrativeHash?: string): void {
|
|
1030
|
+
const absolutePath = join(workspaceRoot, DECK_PLAN_INDEX_PATH)
|
|
1031
|
+
if (existsSync(absolutePath)) return
|
|
1032
|
+
mkdirSync(dirname(absolutePath), { recursive: true })
|
|
1033
|
+
writeFileSync(absolutePath, renderMinimalDeckPlanIndex(narrativeHash, []), "utf-8")
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function updateDeckPlanIndex(workspaceRoot: string, narrativeHash?: string): void {
|
|
1037
|
+
const projection = readDeckPlanProjection(workspaceRoot, { narrativeHash })
|
|
1038
|
+
const slides = projection?.slides ?? []
|
|
1039
|
+
writeFileSync(join(workspaceRoot, DECK_PLAN_INDEX_PATH), renderMinimalDeckPlanIndex(narrativeHash || projection?.narrativeHash, slides), "utf-8")
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function renderMinimalDeckPlanIndex(narrativeHash: string | undefined, slides: DeckPlanSlideProjection[]): string {
|
|
1043
|
+
const chapterMap = new Map<string, number[]>()
|
|
1044
|
+
for (const slide of slides) {
|
|
1045
|
+
const chapter = slide.chapter || "Unassigned"
|
|
1046
|
+
chapterMap.set(chapter, [...(chapterMap.get(chapter) ?? []), slide.slideIndex ?? 0].filter(Boolean))
|
|
1047
|
+
}
|
|
1048
|
+
const lines: string[] = []
|
|
1049
|
+
lines.push("---")
|
|
1050
|
+
lines.push("id: deck-plan")
|
|
1051
|
+
if (narrativeHash) lines.push(`narrativeHash: ${narrativeHash}`)
|
|
1052
|
+
lines.push("---")
|
|
1053
|
+
lines.push("")
|
|
1054
|
+
lines.push("# Deck Plan")
|
|
1055
|
+
lines.push("")
|
|
1056
|
+
lines.push("## Source Authority")
|
|
1057
|
+
lines.push("")
|
|
1058
|
+
lines.push("- Sources: local materials, reviewed findings, workspace assets, URLs, and user intent.")
|
|
1059
|
+
lines.push("- Render planning: `deck-plan.md` is the execution blueprint for HTML deck generation.")
|
|
1060
|
+
lines.push("")
|
|
1061
|
+
lines.push("## Audience / Goal / Decision")
|
|
1062
|
+
lines.push("")
|
|
1063
|
+
lines.push("- To be specified by narrative state and user intent.")
|
|
1064
|
+
lines.push("")
|
|
1065
|
+
lines.push("## Deck Parameters")
|
|
1066
|
+
lines.push("")
|
|
1067
|
+
lines.push(`- Slide count: ${slides.length}`)
|
|
1068
|
+
if (narrativeHash) lines.push(`- Narrative hash: \`${narrativeHash}\``)
|
|
1069
|
+
lines.push("")
|
|
1070
|
+
lines.push("## Chapter Map")
|
|
1071
|
+
lines.push("")
|
|
1072
|
+
if (chapterMap.size === 0) lines.push("- No slides planned yet.")
|
|
1073
|
+
else for (const [chapter, indexes] of chapterMap) lines.push(`- ${chapter}: slides ${formatSlideRange(indexes)}`)
|
|
1074
|
+
lines.push("")
|
|
1075
|
+
lines.push("## Slide Plan")
|
|
1076
|
+
lines.push("")
|
|
1077
|
+
if (slides.length === 0) lines.push("- No slide blocks yet.")
|
|
1078
|
+
else for (const slide of slides) lines.push(`- Slide ${slide.slideIndex}: [[${slide.id}]] - ${slide.title} (${slide.path}); layout ${slide.layout || "unspecified"}; components ${slide.components.join(", ") || "none"}.`)
|
|
1079
|
+
lines.push("")
|
|
1080
|
+
lines.push("## Evidence Trace")
|
|
1081
|
+
lines.push("")
|
|
1082
|
+
const evidenceIds = uniqueStrings(slides.flatMap((slide) => slide.links.filter((link) => link.relation === "uses_evidence").map((link) => link.id)))
|
|
1083
|
+
if (evidenceIds.length === 0) lines.push("- No evidence links planned yet.")
|
|
1084
|
+
else for (const id of evidenceIds) lines.push(`- [[${id}]]`)
|
|
1085
|
+
lines.push("")
|
|
1086
|
+
lines.push("## Boundary / Risk Treatment")
|
|
1087
|
+
lines.push("")
|
|
1088
|
+
const boundaryIds = uniqueStrings(slides.flatMap((slide) => slide.links.filter((link) => link.relation === "addresses_risk" || link.relation === "answers_objection" || link.relation === "mentions_gap").map((link) => link.id)))
|
|
1089
|
+
if (boundaryIds.length === 0) lines.push("- No risk, objection, or gap links planned yet.")
|
|
1090
|
+
else for (const id of boundaryIds) lines.push(`- [[${id}]]`)
|
|
1091
|
+
lines.push("")
|
|
1092
|
+
lines.push("## Chapter Writing Batches")
|
|
1093
|
+
lines.push("")
|
|
1094
|
+
if (chapterMap.size === 0) lines.push("- No chapter batches yet.")
|
|
1095
|
+
else for (const [chapter, indexes] of chapterMap) lines.push(`- ${chapter}: slides ${formatSlideRange(indexes)}.`)
|
|
1096
|
+
lines.push("")
|
|
1097
|
+
lines.push("## HTML Identity Contract")
|
|
1098
|
+
lines.push("")
|
|
1099
|
+
lines.push("- Render one `<section class=\"slide\" data-slide-index=\"N\">` per planned slide.")
|
|
1100
|
+
lines.push("- Use positive 1-based slide indexes, unique indexes, DOM order, and one direct `.slide-canvas` child per slide.")
|
|
1101
|
+
lines.push("")
|
|
1102
|
+
return lines.join("\n")
|
|
1103
|
+
}
|
|
1104
|
+
|
|
349
1105
|
function narrativeHashFromMarkdown(markdown: string): string {
|
|
350
1106
|
const match = markdown.match(/narrativeHash:\s*`?([^`\s]+)`?/)
|
|
351
1107
|
return match?.[1]?.trim() ?? ""
|
|
@@ -379,6 +1135,80 @@ function arrayField(frontmatter: Record<string, string | string[] | boolean>, ke
|
|
|
379
1135
|
return []
|
|
380
1136
|
}
|
|
381
1137
|
|
|
1138
|
+
function normalizeVisualIntent(input: DeckPlanSlideUpsertInput["visualIntent"]): { kind?: string; component?: string; rationale?: string; brief?: string } {
|
|
1139
|
+
if (typeof input === "string") return { brief: input.trim() }
|
|
1140
|
+
return {
|
|
1141
|
+
kind: input.kind?.trim(),
|
|
1142
|
+
component: input.component?.trim(),
|
|
1143
|
+
rationale: input.rationale?.trim(),
|
|
1144
|
+
brief: input.brief?.trim(),
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
function renderVisualIntent(input: DeckPlanSlideUpsertInput["visualIntent"]): string {
|
|
1149
|
+
const visual = normalizeVisualIntent(input)
|
|
1150
|
+
const lines: string[] = []
|
|
1151
|
+
if (visual.kind) lines.push(`- Kind: ${visual.kind}`)
|
|
1152
|
+
if (visual.component) lines.push(`- Component: ${visual.component}`)
|
|
1153
|
+
if (visual.rationale) lines.push(`- Rationale: ${visual.rationale}`)
|
|
1154
|
+
if (visual.brief) lines.push(`- Brief: ${visual.brief}`)
|
|
1155
|
+
if (lines.length === 0) lines.push("- Brief: Not specified.")
|
|
1156
|
+
return lines.join("\n")
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function indentMultiline(value: string): string[] {
|
|
1160
|
+
return value.replace(/\r\n/g, "\n").split("\n").map((line) => ` ${line}`)
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function yamlScalar(value: string): string {
|
|
1164
|
+
const trimmed = value.trim()
|
|
1165
|
+
if (/^[A-Za-z0-9_-]+$/.test(trimmed)) return trimmed
|
|
1166
|
+
return JSON.stringify(trimmed)
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function slugify(value: string): string {
|
|
1170
|
+
const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")
|
|
1171
|
+
return slug || "slide"
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function formatCsv(value: string[] | undefined): string {
|
|
1175
|
+
const items = uniqueStrings(value ?? [])
|
|
1176
|
+
return items.length > 0 ? items.join(", ") : "none"
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
function parseCsv(value: string): string[] {
|
|
1180
|
+
const cleaned = cleanPlanValue(value)
|
|
1181
|
+
if (!cleaned || cleaned.toLowerCase() === "none") return []
|
|
1182
|
+
return cleaned.split(",").map((item) => item.trim()).filter(Boolean)
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
function formatListValue(value: string[] | undefined): string {
|
|
1186
|
+
const items = (value ?? []).map((item) => item.trim()).filter(Boolean)
|
|
1187
|
+
return items.length > 0 ? items.join(" | ") : "none"
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
function parseListValue(value: string): string[] {
|
|
1191
|
+
const cleaned = cleanPlanValue(value)
|
|
1192
|
+
if (!cleaned || cleaned.toLowerCase() === "none") return []
|
|
1193
|
+
return cleaned.split("|").map((item) => item.trim()).filter(Boolean)
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
function cleanPlanValue(value: string): string {
|
|
1197
|
+
return value.replace(/^`|`$/g, "").trim()
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function uniqueStrings(values: string[]): string[] {
|
|
1201
|
+
const seen = new Set<string>()
|
|
1202
|
+
const result: string[] = []
|
|
1203
|
+
for (const value of values) {
|
|
1204
|
+
const trimmed = value.trim()
|
|
1205
|
+
if (!trimmed || seen.has(trimmed)) continue
|
|
1206
|
+
seen.add(trimmed)
|
|
1207
|
+
result.push(trimmed)
|
|
1208
|
+
}
|
|
1209
|
+
return result
|
|
1210
|
+
}
|
|
1211
|
+
|
|
382
1212
|
function titleFromSectionKey(key: string): string {
|
|
383
1213
|
return key.split("-").map((part) => part ? `${part[0].toUpperCase()}${part.slice(1)}` : part).join(" ")
|
|
384
1214
|
}
|