@cyber-dash-tech/revela 0.9.0 → 0.11.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 +54 -9
- package/README.zh-CN.md +54 -9
- package/designs/monet/DESIGN.md +9 -9
- package/designs/starter/DESIGN.md +8 -8
- package/designs/summit/DESIGN.md +9 -9
- package/lib/commands/help.ts +2 -0
- package/lib/commands/inspect.ts +23 -0
- package/lib/commands/pdf.ts +33 -5
- package/lib/commands/pptx.ts +14 -9
- package/lib/commands/refine.ts +26 -0
- package/lib/commands/review.ts +8 -2
- package/lib/deck-html/contract.ts +252 -0
- package/lib/decks-state.ts +574 -31
- package/lib/document-materials/extract.ts +20 -0
- package/lib/edit/resolve-deck.ts +13 -2
- package/lib/inspect/open.ts +63 -0
- package/lib/inspect/prompt.ts +32 -0
- package/lib/inspect/request.ts +70 -0
- package/lib/inspect/requests.ts +86 -0
- package/lib/inspect/server.ts +1063 -0
- package/lib/inspect/slide-index.ts +12 -0
- package/lib/inspection-context/compile.ts +346 -0
- package/lib/inspection-context/match.ts +169 -0
- package/lib/inspection-context/project.ts +263 -0
- package/lib/inspection-context/result.ts +160 -0
- package/lib/qa/export-gate.ts +8 -1
- package/lib/refine/open.ts +70 -0
- package/lib/refine/server.ts +1581 -0
- package/lib/workspace-state/actions.ts +71 -0
- package/lib/workspace-state/compat.ts +10 -0
- package/lib/workspace-state/evidence-status.ts +267 -0
- package/lib/workspace-state/graph.ts +426 -0
- package/lib/workspace-state/render-targets.ts +182 -0
- package/lib/workspace-state/rendered-artifacts.ts +43 -0
- package/lib/workspace-state/repository.ts +43 -0
- package/lib/workspace-state/research-attachments.ts +130 -0
- package/lib/workspace-state/review-snapshots.ts +127 -0
- package/lib/workspace-state/types.ts +119 -0
- package/package.json +1 -1
- package/plugin.ts +48 -1
- package/skill/SKILL.md +10 -5
- package/tools/decks.ts +61 -2
- package/tools/inspection-context.ts +22 -0
- package/tools/inspection-result.ts +63 -0
- package/tools/pdf.ts +9 -1
- package/tools/pptx.ts +10 -0
- package/tools/research-save.ts +15 -0
- package/tools/workspace-scan.ts +15 -0
package/lib/commands/pptx.ts
CHANGED
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
import { existsSync, readdirSync } from "fs"
|
|
11
11
|
import { relative, resolve, sep } from "path"
|
|
12
12
|
import { hasDecksState, isDeckHtmlPath, readDecksState } from "../decks-state"
|
|
13
|
+
import { assertDeckHtmlContractValid } from "../deck-html/contract"
|
|
13
14
|
import { exportToPptx } from "../pptx/export"
|
|
15
|
+
import { recordRenderedArtifact } from "../workspace-state/rendered-artifacts"
|
|
16
|
+
import { resolveActiveHtmlDeckPath } from "../workspace-state/render-targets"
|
|
14
17
|
|
|
15
18
|
export interface PptxArgs {
|
|
16
19
|
filePath: string
|
|
@@ -20,7 +23,7 @@ export interface PptxArgs {
|
|
|
20
23
|
export interface ResolvedPptxDeck {
|
|
21
24
|
file: string
|
|
22
25
|
absoluteFile: string
|
|
23
|
-
source: "decks-state" | "fallback" | "file-path"
|
|
26
|
+
source: "render-target" | "decks-state" | "fallback" | "file-path"
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
function formatSecs(ms: number): string {
|
|
@@ -46,12 +49,12 @@ export function resolvePptxDeck(workspaceRoot: string, filePath = ""): ResolvedP
|
|
|
46
49
|
|
|
47
50
|
if (hasDecksState(root)) {
|
|
48
51
|
const state = readDecksState(root)
|
|
49
|
-
const
|
|
50
|
-
const outputPath = key ? state.decks[key]?.outputPath : undefined
|
|
52
|
+
const outputPath = resolveActiveHtmlDeckPath(state)
|
|
51
53
|
if (outputPath && isDeckHtmlPath(outputPath)) {
|
|
52
54
|
const absoluteFile = resolve(root, outputPath)
|
|
53
55
|
if (existsSync(absoluteFile)) {
|
|
54
|
-
|
|
56
|
+
const source = state.renderTargets.some((target) => target.type === "html_deck" && target.outputPath === outputPath) ? "render-target" : "decks-state"
|
|
57
|
+
return { file: workspaceRelative(root, absoluteFile), absoluteFile, source }
|
|
55
58
|
}
|
|
56
59
|
}
|
|
57
60
|
}
|
|
@@ -114,6 +117,7 @@ export async function handlePptx(
|
|
|
114
117
|
const deck = resolvePptxDeck(workspaceRoot, args.filePath)
|
|
115
118
|
const abs = deck.absoluteFile
|
|
116
119
|
|
|
120
|
+
assertDeckHtmlContractValid(workspaceRoot, abs)
|
|
117
121
|
await send(`Exporting \`${abs}\` to PPTX...`)
|
|
118
122
|
let lastSlideUpdate = 0
|
|
119
123
|
let longDeckThreshold: number | null = null
|
|
@@ -141,6 +145,12 @@ export async function handlePptx(
|
|
|
141
145
|
await send(`Editable export progress: slide ${current}/${total}`)
|
|
142
146
|
},
|
|
143
147
|
})
|
|
148
|
+
recordRenderedArtifact(workspaceRoot, {
|
|
149
|
+
sourceHtmlPath: deck.file,
|
|
150
|
+
outputPath: result.outputPath,
|
|
151
|
+
type: "pptx",
|
|
152
|
+
actor: "revela-pptx",
|
|
153
|
+
})
|
|
144
154
|
|
|
145
155
|
await send(
|
|
146
156
|
`**PPTX exported successfully**\n\n` +
|
|
@@ -159,11 +169,6 @@ export async function handlePptx(
|
|
|
159
169
|
}
|
|
160
170
|
}
|
|
161
171
|
|
|
162
|
-
function singleDeckKey(decks: Record<string, unknown>): string | undefined {
|
|
163
|
-
const keys = Object.keys(decks)
|
|
164
|
-
return keys.length === 1 ? keys[0] : undefined
|
|
165
|
-
}
|
|
166
|
-
|
|
167
172
|
function listDeckHtmlFiles(workspaceRoot: string): string[] {
|
|
168
173
|
const dir = resolve(workspaceRoot, "decks")
|
|
169
174
|
if (!existsSync(dir)) return []
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { openRefineDeck } from "../refine/open"
|
|
2
|
+
import type { RefineMode } from "../refine/server"
|
|
3
|
+
|
|
4
|
+
export async function handleRefine(
|
|
5
|
+
options: { client: any; sessionID: string; workspaceRoot: string; mode?: RefineMode },
|
|
6
|
+
send: (text: string) => Promise<void>,
|
|
7
|
+
): Promise<void> {
|
|
8
|
+
try {
|
|
9
|
+
const result = openRefineDeck("", {
|
|
10
|
+
client: options.client,
|
|
11
|
+
sessionID: options.sessionID,
|
|
12
|
+
workspaceRoot: options.workspaceRoot,
|
|
13
|
+
mode: options.mode ?? "edit",
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
await send(
|
|
17
|
+
`Opened Revela Refine for the active HTML deck.\n` +
|
|
18
|
+
`File: \`${result.deck.file}\`\n` +
|
|
19
|
+
`${result.stateNote}\n` +
|
|
20
|
+
`URL: ${result.url}\n\n` +
|
|
21
|
+
`Use Ctrl/Cmd-click in the browser to reference deck elements. The Edit tab sends targeted change comments; the Inspect tab reviews the same selection with Source/Purpose cards and does not edit the deck.`
|
|
22
|
+
)
|
|
23
|
+
} catch (e: any) {
|
|
24
|
+
await send(`**Refine failed:** ${e.message || String(e)}`)
|
|
25
|
+
}
|
|
26
|
+
}
|
package/lib/commands/review.ts
CHANGED
|
@@ -22,6 +22,9 @@ Goal:
|
|
|
22
22
|
- For substantial decision decks, use the read-only Task subagent \`revela-narrative-reviewer\` for independent rubric-based critique of narrative brief and slide-plan alignment. Do not self-certify semantic narrative quality in the primary agent.
|
|
23
23
|
- Treat \`revela-narrative-reviewer\` findings as advisory critique only. Do not represent them as \`revela-decks\` readiness issues, blockers, or authoritative \`writeReadiness\`.
|
|
24
24
|
- Treat source trace mapping as part of evidence readiness: when research findings have been read, relevant findings should appear in slide-level \`slides[].evidence[]\` records rather than only in raw research files.
|
|
25
|
+
- When \`revela-decks review\` returns \`evidenceCandidates\`, treat them as conservative binding candidates only. They are not proof that the full slide is supported, and they are not automatically applied to \`slides[].evidence[]\`. If a candidate has \`sourceKind: "researchesFallback"\`, say it was discovered from workspace \`researches/\` files that are not currently referenced by \`researchPlan\`.
|
|
26
|
+
- When an evidence candidate includes \`evidenceDraft\`, report it as a proposed slide evidence record with its \`candidateId\`; it still requires explicit user/agent confirmation before calling \`revela-decks\` action \`applyEvidenceCandidates\`. Also report \`unsupportedScope\` and \`recommendedRewrite\` so partial evidence is not stretched to future-state claims.
|
|
27
|
+
- When a missing-evidence issue has \`evidenceCandidateSearch\`, use it to explain search coverage: which \`researchPlan\` findings were searched, which fallback \`researches/**/*.md\` files were searched, and any near misses that were below binding threshold.
|
|
25
28
|
|
|
26
29
|
Current state:
|
|
27
30
|
- ${state}
|
|
@@ -36,7 +39,7 @@ Workspace boundary rules:
|
|
|
36
39
|
Workflow:
|
|
37
40
|
1. Call \`revela-decks\` with action \`read\` for the current workspace deck.
|
|
38
41
|
2. If no current deck exists but the conversation contains enough deck context, call \`revela-decks\` action \`upsertDeck\` with goal, outputPath, theme, requiredInputs, researchPlan, and narrativeBrief if the story intent is clear. Do not invent or ask for a deck key; the tool uses the workspace folder name internally.
|
|
39
|
-
3. If \`researchPlan[].status\` is \`done\` or \`read\` and \`researchPlan[].findingsFile\` exists, verify that evidence-sensitive slide claims are backed by compact \`slides[].evidence[]\` records that reference the relevant findings file or source material where known.
|
|
42
|
+
3. If \`researchPlan[].status\` is \`done\` or \`read\` and \`researchPlan[].findingsFile\` exists, verify that evidence-sensitive slide claims are backed by compact \`slides[].evidence[]\` records that reference the relevant findings file or source material where known. The review tool may surface conservative \`evidenceCandidates\` for missing evidence by matching slide text against those findings files, and may fall back to bounded workspace \`researches/**/*.md\` discovery when the research plan has no matching findings file; report these as candidate bindings, not as already-bound evidence.
|
|
40
43
|
4. If a user-confirmed slide plan is available, call \`revela-decks\` action \`upsertSlides\` with every slide's title, purpose, narrativeRole, layout, components, structured content, evidence, visuals, and status. Use only lightweight narrativeRole values that are clear from the plan: \`context\`, \`tension\`, \`evidence\`, \`recommendation\`, \`risk\`, \`ask\`, \`appendix\`, or \`close\`.
|
|
41
44
|
5. Prefer evidence records with \`findingsFile\`, \`sourcePath\`, \`location\`, \`quote\`, \`url\`, \`caveat\`, \`extractedTextPath\`, or \`extractedManifestPath\` when those fields are known from research files or extracted workspace materials.
|
|
42
45
|
6. Do not invent quotes, page references, locations, URLs, caveats, or extraction paths. If source trace is missing, preserve the blocker or warning and report exactly what trace is needed.
|
|
@@ -44,7 +47,7 @@ Workflow:
|
|
|
44
47
|
8. For substantial decision decks, preserve a compact \`narrativeBrief\` through \`upsertDeck\` when the conversation or confirmed plan supports it. Do not invent stakeholder beliefs, objections, or risks; leave gaps visible if unknown.
|
|
45
48
|
9. For substantial decision decks, launch the Task subagent with \`subagent_type: "revela-narrative-reviewer"\` after deck/slides are up to date. Ask it to read the current \`DECKS.json\`, run only its fixed rubric, use stable finding IDs, return \`Findings: none\` when all checks pass, and avoid optional pre-write improvements. Do not ask it to write state, call \`revela-decks review\`, or produce HTML.
|
|
46
49
|
10. Call \`revela-decks\` action \`review\`. The tool computes and writes \`writeReadiness\` plus structured readiness issues for the current workspace deck.
|
|
47
|
-
11. Briefly report whether the deck is ready. If blocked, list the exact blockers returned by the tool. If warnings exist, list them after blockers as residual risks; separate evidence/source warnings from narrative warnings when possible. If the reviewer returned findings, include them in a separate \`Narrative reviewer notes\` section and label them advisory.
|
|
50
|
+
11. Briefly report whether the deck is ready. If blocked, list the exact blockers returned by the tool. If warnings exist, list them after blockers as residual risks; separate evidence/source warnings from narrative warnings when possible. If the review result includes \`evidenceCandidates\`, add a separate \`Candidate evidence bindings\` section with candidateId, slide index/title, supported claim scope, sourceKind, findingsFile/sourcePath, quote/snippet, caveat, evidenceDraft summary, unsupportedScope, and recommendedRewrite. Tell the user they may explicitly ask to apply selected candidate IDs; do not apply them during review. If candidates are absent but \`evidenceCandidateSearch\` is present, briefly report searched file counts and the best near misses so the user can tell whether review failed to search or searched but did not find a bindable match. If the reviewer returned findings, include them in a separate \`Narrative reviewer notes\` section and label them advisory.
|
|
48
51
|
|
|
49
52
|
Minimum conditions for \`ready\`:
|
|
50
53
|
- Topic, audience, slide count, language, and visual style/design are decided.
|
|
@@ -68,6 +71,9 @@ Report format:
|
|
|
68
71
|
- Do not invent evidence or silently downgrade blockers. Use the tool result as authoritative.
|
|
69
72
|
- Do not convert \`revela-narrative-reviewer\` advisory findings into tool readiness issues. Keep them separate from \`revela-decks review\` blockers and warnings, and preserve the reviewer's stable finding IDs when reporting them.
|
|
70
73
|
- When reporting weak evidence, say whether the missing trace is \`findingsFile\`, \`sourcePath\`, \`location\`, \`quote\`, \`url\`, or \`caveat\` if that is clear from the reviewed materials.
|
|
74
|
+
- When reporting candidate evidence bindings, distinguish partial support from full-slide support. Never say a candidate supports unrelated future-state, recommendation, roadmap, or product-vision claims unless the candidate explicitly supports those claims.
|
|
75
|
+
- Treat \`evidenceDraft\` as a proposed record, not a mutation. Do not call \`upsertSlides\` to bind it. Only call \`revela-decks\` action \`applyEvidenceCandidates\` with explicit \`candidateIds\` if the user asks to apply candidate bindings.
|
|
76
|
+
- When reporting candidate search diagnostics, do not present near misses as evidence. Say they are below binding threshold and use them only to explain why no candidate was returned.
|
|
71
77
|
|
|
72
78
|
Rules:
|
|
73
79
|
- Do not write or overwrite \`decks/*.html\` during review.
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "fs"
|
|
2
|
+
import { relative, resolve, sep } from "path"
|
|
3
|
+
import { hasDecksState, readDecksState, type DeckSpec } from "../decks-state"
|
|
4
|
+
import { resolveActiveHtmlDeckPath, normalizeWorkspacePath } from "../workspace-state/render-targets"
|
|
5
|
+
|
|
6
|
+
export type DeckHtmlContractStatus = "valid" | "invalid" | "skipped"
|
|
7
|
+
|
|
8
|
+
export type DeckHtmlContractIssueType =
|
|
9
|
+
| "file_not_found"
|
|
10
|
+
| "no_matching_deck_spec"
|
|
11
|
+
| "missing_slide_section"
|
|
12
|
+
| "slide_count_mismatch"
|
|
13
|
+
| "missing_data_slide_index"
|
|
14
|
+
| "invalid_data_slide_index"
|
|
15
|
+
| "duplicate_data_slide_index"
|
|
16
|
+
| "slide_index_mismatch"
|
|
17
|
+
| "legacy_data_index_noncanonical"
|
|
18
|
+
|
|
19
|
+
export interface DeckHtmlContractIssue {
|
|
20
|
+
type: DeckHtmlContractIssueType
|
|
21
|
+
severity: "error" | "warning"
|
|
22
|
+
message: string
|
|
23
|
+
slidePosition?: number
|
|
24
|
+
expectedIndex?: number
|
|
25
|
+
actualIndex?: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DeckHtmlContractReport {
|
|
29
|
+
status: DeckHtmlContractStatus
|
|
30
|
+
ok: boolean
|
|
31
|
+
workspaceRoot: string
|
|
32
|
+
filePath: string
|
|
33
|
+
deckSlug?: string
|
|
34
|
+
activeHtmlPath?: string
|
|
35
|
+
expectedIndexes: number[]
|
|
36
|
+
actualIndexes: number[]
|
|
37
|
+
issues: DeckHtmlContractIssue[]
|
|
38
|
+
warnings: DeckHtmlContractIssue[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface SlideSectionAttrs {
|
|
42
|
+
position: number
|
|
43
|
+
dataSlideIndex?: string
|
|
44
|
+
dataIndex?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function validateDeckHtmlContract(workspaceRoot: string, filePath: string): DeckHtmlContractReport {
|
|
48
|
+
const root = resolve(workspaceRoot)
|
|
49
|
+
const absoluteFile = resolve(root, filePath)
|
|
50
|
+
const relativeFile = workspaceRelative(root, absoluteFile)
|
|
51
|
+
const base: Omit<DeckHtmlContractReport, "status" | "ok"> = {
|
|
52
|
+
workspaceRoot: root,
|
|
53
|
+
filePath: relativeFile,
|
|
54
|
+
expectedIndexes: [],
|
|
55
|
+
actualIndexes: [],
|
|
56
|
+
issues: [],
|
|
57
|
+
warnings: [],
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!hasDecksState(root)) return skipped(base, "No DECKS.json exists; deck HTML contract validation is skipped.")
|
|
61
|
+
|
|
62
|
+
const state = readDecksState(root)
|
|
63
|
+
const activeHtmlPath = resolveActiveHtmlDeckPath(state)
|
|
64
|
+
const activeKey = state.activeDeck || singleDeckKey(state.decks)
|
|
65
|
+
const deck = activeKey ? state.decks[activeKey] : undefined
|
|
66
|
+
const normalizedActive = normalizeWorkspacePath(activeHtmlPath ?? "")
|
|
67
|
+
const normalizedTarget = normalizeWorkspacePath(relativeFile)
|
|
68
|
+
|
|
69
|
+
base.activeHtmlPath = normalizedActive || undefined
|
|
70
|
+
base.deckSlug = deck?.slug
|
|
71
|
+
|
|
72
|
+
if (!deck || !normalizedActive || normalizedActive !== normalizedTarget) {
|
|
73
|
+
return skipped(base, `No matching active deck spec exists for ${relativeFile}; deck HTML contract validation is skipped.`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!existsSync(absoluteFile)) {
|
|
77
|
+
base.issues.push({
|
|
78
|
+
type: "file_not_found",
|
|
79
|
+
severity: "error",
|
|
80
|
+
message: `Deck HTML file not found: ${relativeFile}`,
|
|
81
|
+
})
|
|
82
|
+
return finalize(base)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const html = readFileSync(absoluteFile, "utf-8")
|
|
86
|
+
const sections = extractSlideSections(html)
|
|
87
|
+
base.expectedIndexes = expectedSlideIndexes(deck)
|
|
88
|
+
|
|
89
|
+
if (sections.length === 0) {
|
|
90
|
+
base.issues.push({
|
|
91
|
+
type: "missing_slide_section",
|
|
92
|
+
severity: "error",
|
|
93
|
+
message: "Deck HTML must contain one <section class=\"slide\"> element per slide spec.",
|
|
94
|
+
})
|
|
95
|
+
return finalize(base)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (sections.length !== base.expectedIndexes.length) {
|
|
99
|
+
base.issues.push({
|
|
100
|
+
type: "slide_count_mismatch",
|
|
101
|
+
severity: "error",
|
|
102
|
+
message: `Deck HTML has ${sections.length} slide sections, but DECKS.json expects ${base.expectedIndexes.length}.`,
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const seen = new Set<number>()
|
|
107
|
+
sections.forEach((section, offset) => {
|
|
108
|
+
const expectedIndex = base.expectedIndexes[offset]
|
|
109
|
+
if (section.dataIndex !== undefined) {
|
|
110
|
+
base.warnings.push({
|
|
111
|
+
type: "legacy_data_index_noncanonical",
|
|
112
|
+
severity: "warning",
|
|
113
|
+
message: `Slide ${section.position} has legacy data-index; use data-slide-index as the canonical slide identity.`,
|
|
114
|
+
slidePosition: section.position,
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (section.dataSlideIndex === undefined) {
|
|
119
|
+
base.issues.push({
|
|
120
|
+
type: "missing_data_slide_index",
|
|
121
|
+
severity: "error",
|
|
122
|
+
message: `Slide ${section.position} is missing data-slide-index.`,
|
|
123
|
+
slidePosition: section.position,
|
|
124
|
+
expectedIndex,
|
|
125
|
+
})
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const actualIndex = Number(section.dataSlideIndex)
|
|
130
|
+
if (!Number.isInteger(actualIndex) || actualIndex < 1 || String(actualIndex) !== section.dataSlideIndex.trim()) {
|
|
131
|
+
base.issues.push({
|
|
132
|
+
type: "invalid_data_slide_index",
|
|
133
|
+
severity: "error",
|
|
134
|
+
message: `Slide ${section.position} has invalid data-slide-index=${JSON.stringify(section.dataSlideIndex)}; expected a positive 1-based integer.`,
|
|
135
|
+
slidePosition: section.position,
|
|
136
|
+
expectedIndex,
|
|
137
|
+
})
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
base.actualIndexes.push(actualIndex)
|
|
142
|
+
if (seen.has(actualIndex)) {
|
|
143
|
+
base.issues.push({
|
|
144
|
+
type: "duplicate_data_slide_index",
|
|
145
|
+
severity: "error",
|
|
146
|
+
message: `Slide ${section.position} repeats data-slide-index=${actualIndex}.`,
|
|
147
|
+
slidePosition: section.position,
|
|
148
|
+
actualIndex,
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
seen.add(actualIndex)
|
|
152
|
+
|
|
153
|
+
if (expectedIndex !== undefined && actualIndex !== expectedIndex) {
|
|
154
|
+
base.issues.push({
|
|
155
|
+
type: "slide_index_mismatch",
|
|
156
|
+
severity: "error",
|
|
157
|
+
message: `Slide ${section.position} has data-slide-index=${actualIndex}, but DECKS.json expects ${expectedIndex}.`,
|
|
158
|
+
slidePosition: section.position,
|
|
159
|
+
expectedIndex,
|
|
160
|
+
actualIndex,
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
return finalize(base)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function assertDeckHtmlContractValid(workspaceRoot: string, filePath: string): void {
|
|
169
|
+
const report = validateDeckHtmlContract(workspaceRoot, filePath)
|
|
170
|
+
if (report.status !== "invalid") return
|
|
171
|
+
throw new Error(
|
|
172
|
+
"Deck HTML contract validation failed. Fix slide identity before inspection or export.\n\n" +
|
|
173
|
+
formatDeckHtmlContractReport(report)
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function formatDeckHtmlContractReport(report: DeckHtmlContractReport): string {
|
|
178
|
+
const lines = [
|
|
179
|
+
`Status: ${report.status}`,
|
|
180
|
+
`File: ${report.filePath}`,
|
|
181
|
+
]
|
|
182
|
+
if (report.deckSlug) lines.push(`Deck: ${report.deckSlug}`)
|
|
183
|
+
if (report.activeHtmlPath) lines.push(`Active HTML target: ${report.activeHtmlPath}`)
|
|
184
|
+
if (report.expectedIndexes.length > 0) lines.push(`Expected slide indexes: ${report.expectedIndexes.join(", ")}`)
|
|
185
|
+
if (report.actualIndexes.length > 0) lines.push(`Actual slide indexes: ${report.actualIndexes.join(", ")}`)
|
|
186
|
+
|
|
187
|
+
if (report.issues.length > 0) {
|
|
188
|
+
lines.push("", "Errors:")
|
|
189
|
+
for (const issue of report.issues) lines.push(`- ${issue.message}`)
|
|
190
|
+
}
|
|
191
|
+
if (report.warnings.length > 0) {
|
|
192
|
+
lines.push("", "Warnings:")
|
|
193
|
+
for (const warning of report.warnings) lines.push(`- ${warning.message}`)
|
|
194
|
+
}
|
|
195
|
+
if (report.status === "skipped" && report.warnings.length > 0) {
|
|
196
|
+
lines.push("", "Note: skipped reports do not block standalone HTML files.")
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return lines.join("\n")
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function extractSlideSections(html: string): SlideSectionAttrs[] {
|
|
203
|
+
const sections: SlideSectionAttrs[] = []
|
|
204
|
+
const sectionTagPattern = /<section\b([^>]*)>/gi
|
|
205
|
+
let match: RegExpExecArray | null
|
|
206
|
+
while ((match = sectionTagPattern.exec(html))) {
|
|
207
|
+
const attrs = match[1] ?? ""
|
|
208
|
+
if (!/\bclass\s*=\s*(["'])[^"']*\bslide\b[^"']*\1/i.test(attrs)) continue
|
|
209
|
+
sections.push({
|
|
210
|
+
position: sections.length + 1,
|
|
211
|
+
dataSlideIndex: readAttr(attrs, "data-slide-index"),
|
|
212
|
+
dataIndex: readAttr(attrs, "data-index"),
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
return sections
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function readAttr(attrs: string, name: string): string | undefined {
|
|
219
|
+
const pattern = new RegExp(`\\b${escapeRegExp(name)}\\s*=\\s*(["'])(.*?)\\1`, "i")
|
|
220
|
+
return pattern.exec(attrs)?.[2]
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function expectedSlideIndexes(deck: DeckSpec): number[] {
|
|
224
|
+
return deck.slides.map((slide) => slide.index)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function finalize(base: Omit<DeckHtmlContractReport, "status" | "ok">): DeckHtmlContractReport {
|
|
228
|
+
const status: DeckHtmlContractStatus = base.issues.length > 0 ? "invalid" : "valid"
|
|
229
|
+
return { ...base, status, ok: status === "valid" }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function skipped(base: Omit<DeckHtmlContractReport, "status" | "ok">, message: string): DeckHtmlContractReport {
|
|
233
|
+
return {
|
|
234
|
+
...base,
|
|
235
|
+
status: "skipped",
|
|
236
|
+
ok: true,
|
|
237
|
+
warnings: [{ type: "no_matching_deck_spec", severity: "warning", message }],
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function singleDeckKey(decks: Record<string, DeckSpec>): string | undefined {
|
|
242
|
+
const keys = Object.keys(decks)
|
|
243
|
+
return keys.length === 1 ? keys[0] : undefined
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function workspaceRelative(root: string, target: string): string {
|
|
247
|
+
return relative(root, target).split(sep).join("/")
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function escapeRegExp(value: string): string {
|
|
251
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
252
|
+
}
|