@cyber-dash-tech/revela 0.10.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 +25 -5
- package/README.zh-CN.md +25 -5
- package/lib/commands/inspect.ts +1 -1
- package/lib/commands/pdf.ts +33 -5
- package/lib/commands/pptx.ts +14 -9
- package/lib/commands/refine.ts +1 -1
- package/lib/deck-html/contract.ts +252 -0
- package/lib/decks-state.ts +101 -28
- package/lib/document-materials/extract.ts +20 -0
- package/lib/edit/resolve-deck.ts +13 -2
- package/lib/inspect/open.ts +3 -1
- package/lib/qa/export-gate.ts +8 -1
- package/lib/refine/open.ts +3 -1
- 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 +26 -1
- package/tools/decks.ts +54 -5
- 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/README.md
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
**English** | [中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
[](https://www.npmjs.com/package/@cyber-dash-tech/revela) [](LICENSE) [](https://www.npmjs.com/package/@cyber-dash-tech/revela) [](LICENSE) [](tests/) [](https://opencode.ai) [](https://bun.sh)
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
8
|
<img src="assets/img/logo.png" alt="Revela" width="800" />
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
|
-
Revela is an [OpenCode](https://opencode.ai) plugin
|
|
12
|
-
|
|
11
|
+
Revela is an [OpenCode](https://opencode.ai) plugin for building trusted narrative artifacts from workspace sources, research, evidence, and user intent.
|
|
12
|
+
Its first render target is still the HTML slide deck: enable Revela for the current session, assign a presentation task, and the agent can research, structure, write, QA, inspect, refine, and export a deck.
|
|
13
13
|
|
|
14
14
|
**[Live Demo — The AI Power Shift](https://cyber-dash-tech.github.io/revela/assets/html/ai-power-shift.html)**
|
|
15
15
|
|
|
@@ -20,8 +20,10 @@ Enable it for the current session, assign a presentation task, and the agent can
|
|
|
20
20
|
- injects a presentation-specific system prompt into your current agent with `/revela enable`
|
|
21
21
|
- builds that prompt from 3 layers: core skill, active domain, active design
|
|
22
22
|
- supports workspace document discovery, transparent text extraction for `.pdf`, `.docx`, `.pptx`, and `.xlsx`, and cached embedded-material extraction for those formats
|
|
23
|
-
- keeps
|
|
23
|
+
- keeps `DECKS.json` as the current workspace state engine for sources, research actions, findings, claims, evidence, narrative intent, render targets, and readiness
|
|
24
24
|
- checks for missing context, weak evidence, and incomplete structure before writing `decks/*.html`
|
|
25
|
+
- records review snapshots so stale readiness cannot silently authorize new deck HTML after important state changes
|
|
26
|
+
- treats HTML decks, PDF, and PPTX as render targets from shared workspace state rather than isolated output files
|
|
25
27
|
- runs fast design compliance checks whenever the agent writes, patches, or edits `decks/*.html`
|
|
26
28
|
- opens a visual comment editor for existing decks so users can Ctrl/Cmd-click elements and send precise edit requests back to OpenCode
|
|
27
29
|
- exports finished decks to PDF and editable PPTX
|
|
@@ -178,6 +180,22 @@ That prompt is built from 3 layers:
|
|
|
178
180
|
Persistent preferences live in `~/.config/revela/config.json`.
|
|
179
181
|
The enabled or disabled state is session-level only.
|
|
180
182
|
|
|
183
|
+
### Workspace State
|
|
184
|
+
|
|
185
|
+
`DECKS.json` is Revela's workspace state engine and compatibility file. It is still stored at the workspace root and remains readable as the current deck project state, but internally Revela now treats it as a lightweight persistence layer for more than a deck checklist.
|
|
186
|
+
|
|
187
|
+
The state records:
|
|
188
|
+
|
|
189
|
+
- workspace source materials and reusable extraction cache paths
|
|
190
|
+
- research plans, saved findings, and compact action provenance
|
|
191
|
+
- narrative intent, objections, risks, slide specs, claim candidates, and slide-level evidence trace
|
|
192
|
+
- render targets such as the active HTML deck plus derived PDF and PPTX artifacts
|
|
193
|
+
- review snapshots with input hashes so old readiness results become stale after meaningful state changes
|
|
194
|
+
|
|
195
|
+
0.11 keeps backward compatibility with existing root `DECKS.json` workspaces. Running `/revela init` or `/revela review` on an older project can normalize and refresh the new projection fields without requiring a manual migration, moving files, or replacing `DECKS.json` with a database.
|
|
196
|
+
|
|
197
|
+
Decks remain the primary authored artifact, but they are now treated as render targets from the same workspace state that can later support briefs, appendix material, Evidence Inspector views, Q&A, and interactive reading layers without duplicating source/evidence logic.
|
|
198
|
+
|
|
181
199
|
---
|
|
182
200
|
|
|
183
201
|
## Recommended Workflow
|
|
@@ -196,7 +214,7 @@ Use Revela as a guided deck-production mode:
|
|
|
196
214
|
|
|
197
215
|
`/revela review` checks for practical readiness problems: unclear audience, missing source material, unfinished research, unsupported claims, weak source trace, incomplete slide structure, missing design/layout information, or lightweight narrative issues such as weak so-what, missing risk/assumption handling, and abrupt transitions. It does not write the final deck.
|
|
198
216
|
|
|
199
|
-
If Revela blocks a deck write, ask the agent to run `/revela review`, resolve the reported gaps, and try again. This protects the deck file from being overwritten before the plan, evidence, and structure are ready.
|
|
217
|
+
If Revela blocks a deck write, ask the agent to run `/revela review`, resolve the reported gaps, and try again. This protects the deck file from being overwritten before the plan, evidence, and structure are ready. In 0.11, review results are also saved as input-hashed snapshots; if sources, slide specs, evidence, narrative state, or the active render target changes afterward, the old review can no longer silently authorize a write.
|
|
200
218
|
|
|
201
219
|
To remember long-term preferences, use:
|
|
202
220
|
|
|
@@ -597,6 +615,8 @@ The inspector opens in your browser with the deck on the left and fixed cards on
|
|
|
597
615
|
|
|
598
616
|
The inspector is not chat and has no freeform prompt. It does not mutate `DECKS.json` or the deck HTML. It uses recorded slide specs, narrative state, and slide-level evidence trace as grounded context. Deterministic preprocessing appears immediately; lazy LLM judgment then refines the Source and Purpose cards without inventing edits.
|
|
599
617
|
|
|
618
|
+
Inspection and refinement use the active HTML deck render target recorded in workspace state. The deck HTML must satisfy Revela's slide identity contract: every `<section class="slide">` in the active artifact needs a positive 1-based `data-slide-index` matching the current slide specs. Invalid active artifacts are refused or reported before inspect/refine/export workflows trust them.
|
|
619
|
+
|
|
600
620
|
---
|
|
601
621
|
|
|
602
622
|
## Export
|
package/README.zh-CN.md
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | **中文**
|
|
4
4
|
|
|
5
|
-
[](https://www.npmjs.com/package/@cyber-dash-tech/revela) [](LICENSE) [](https://www.npmjs.com/package/@cyber-dash-tech/revela) [](LICENSE) [](tests/) [](https://opencode.ai) [](https://bun.sh)
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
8
|
<img src="assets/img/logo.png" alt="Revela" width="800" />
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
|
-
Revela 是一个 [OpenCode](https://opencode.ai)
|
|
12
|
-
|
|
11
|
+
Revela 是一个 [OpenCode](https://opencode.ai) 插件,用来把工作区来源材料、调研、证据和用户意图转成可信的叙事型沟通 artifact。
|
|
12
|
+
它的第一个 render target 仍然是 HTML slide deck:在当前会话中启用之后,agent 可以完成调研、结构设计、HTML 写作、QA、检查、refine 和导出。
|
|
13
13
|
|
|
14
14
|
**[在线演示 — AI 权力转移](https://cyber-dash-tech.github.io/revela/assets/html/ai-power-shift.html)**
|
|
15
15
|
|
|
@@ -20,8 +20,10 @@ Revela 是一个 [OpenCode](https://opencode.ai) 插件,可以把你当前使
|
|
|
20
20
|
- 通过 `/revela enable` 向当前 agent 注入演示文稿专用 system prompt
|
|
21
21
|
- prompt 由 3 层组成:核心 skill、当前 domain、当前 design
|
|
22
22
|
- 支持工作区文档扫描,以及 `.pdf`、`.docx`、`.pptx`、`.xlsx` 的透明文本提取和嵌入素材缓存提取
|
|
23
|
-
-
|
|
23
|
+
- 将 `DECKS.json` 作为当前 workspace state engine,持续记录来源材料、调研动作、findings、claims、证据、叙事意图、render targets 和 readiness
|
|
24
24
|
- 写入 `decks/*.html` 前检查是否缺上下文、证据或结构
|
|
25
|
+
- 记录 review snapshots,避免重要状态变化后旧的 ready 结果继续默默授权写入 deck HTML
|
|
26
|
+
- 把 HTML deck、PDF 和 PPTX 视为来自同一 workspace state 的 render targets,而不是互相孤立的输出文件
|
|
25
27
|
- agent 每次写入、patch 或 edit `decks/*.html` 时自动执行快速 design compliance 检查
|
|
26
28
|
- 为已有 deck 打开可视化评论编辑器,用户可以 Ctrl/Cmd + 点击元素,并把精确修改意见发回 OpenCode
|
|
27
29
|
- 支持导出成 PDF 和可编辑 PPTX
|
|
@@ -177,6 +179,22 @@ Create a 6-slide HTML deck on humanoid robotics supply chains. Cite the main mar
|
|
|
177
179
|
持久化配置保存在 `~/.config/revela/config.json`。
|
|
178
180
|
是否启用 Revela 则只在当前会话生效。
|
|
179
181
|
|
|
182
|
+
### Workspace State
|
|
183
|
+
|
|
184
|
+
`DECKS.json` 是 Revela 当前的 workspace state engine,也是兼容旧工作流的状态文件。它仍然保存在工作区根目录,也仍然可以理解为当前 deck 项目的状态入口,但 Revela 内部已经不再把它当成单纯的 deck checklist。
|
|
185
|
+
|
|
186
|
+
状态中会记录:
|
|
187
|
+
|
|
188
|
+
- 工作区来源材料和可复用的 extraction cache 路径
|
|
189
|
+
- 调研计划、已保存 findings,以及精简的 action provenance
|
|
190
|
+
- narrative intent、objections、risks、slide specs、claim candidates 和 slide-level evidence trace
|
|
191
|
+
- active HTML deck 以及派生 PDF、PPTX 等 render targets
|
|
192
|
+
- 带 input hash 的 review snapshots,使重要状态变化后旧的 readiness 自动变 stale
|
|
193
|
+
|
|
194
|
+
0.11 继续兼容已有的根目录 `DECKS.json` 工作区。对旧项目运行 `/revela init` 或 `/revela review` 时,可以安全 normalize 并刷新新的 projection 字段;用户不需要手动迁移、不需要移动文件,也不需要把 `DECKS.json` 换成数据库。
|
|
195
|
+
|
|
196
|
+
Deck 仍然是主要 authored artifact,但现在它是从同一份 workspace state 渲染出来的目标之一。后续 briefs、appendix material、Evidence Inspector views、Q&A 和 interactive reading layers 都可以复用同一套来源/证据逻辑,而不是各自生成孤立内容。
|
|
197
|
+
|
|
180
198
|
---
|
|
181
199
|
|
|
182
200
|
## 推荐使用流程
|
|
@@ -195,7 +213,7 @@ Create a 6-slide HTML deck on humanoid robotics supply chains. Cite the main mar
|
|
|
195
213
|
|
|
196
214
|
`/revela review` 检查的是实际生产问题:受众是否清楚、是否缺来源材料、调研是否完成、关键 claim 是否有证据、source trace 是否太弱、页面结构是否完整、design/layout 信息是否齐全,以及轻量叙事问题,例如 so-what 不清晰、缺少风险/假设处理或转场突兀。它不会写最终 deck。
|
|
197
215
|
|
|
198
|
-
如果 Revela 阻止写入 deck,直接让 agent 运行 `/revela review`,根据报告补齐缺口后再写。这样可以避免在计划、证据或结构还不完整时覆盖真实 deck 文件。
|
|
216
|
+
如果 Revela 阻止写入 deck,直接让 agent 运行 `/revela review`,根据报告补齐缺口后再写。这样可以避免在计划、证据或结构还不完整时覆盖真实 deck 文件。0.11 还会把 review 结果保存为带 input hash 的 snapshot;如果之后来源材料、slide specs、证据、叙事状态或 active render target 发生变化,旧 review 不能继续默默授权写入。
|
|
199
217
|
|
|
200
218
|
记住长期偏好请使用:
|
|
201
219
|
|
|
@@ -562,6 +580,8 @@ Inspector 会在浏览器中打开,左侧是 deck,右侧是固定卡片:So
|
|
|
562
580
|
|
|
563
581
|
Inspector 不是聊天,也没有自由输入框。它不会修改 `DECKS.json` 或 deck HTML。它使用已记录的 slide spec、narrative state 和 slide-level evidence trace 作为 grounded context。确定性预处理会立即显示;LLM judgment 随后 lazy 更新 Source 和 Purpose 卡片,不会强行生成编辑动作。
|
|
564
582
|
|
|
583
|
+
Inspect 和 refine 会使用 workspace state 中记录的 active HTML deck render target。Deck HTML 必须满足 Revela 的 slide identity contract:active artifact 中每个 `<section class="slide">` 都需要有正数、1-based 的 `data-slide-index`,并且要匹配当前 slide specs。无效的 active artifact 会在 inspect/refine/export 工作流信任它之前被拒绝或报告。
|
|
584
|
+
|
|
565
585
|
---
|
|
566
586
|
|
|
567
587
|
## 导出
|
package/lib/commands/inspect.ts
CHANGED
|
@@ -11,7 +11,7 @@ export async function handleInspect(
|
|
|
11
11
|
workspaceRoot: options.workspaceRoot,
|
|
12
12
|
})
|
|
13
13
|
await send(
|
|
14
|
-
`Opened Evidence Inspector for the
|
|
14
|
+
`Opened Evidence Inspector for the active HTML deck.\n` +
|
|
15
15
|
`File: \`${result.deck.file}\`\n` +
|
|
16
16
|
`${result.stateNote}\n` +
|
|
17
17
|
`URL: ${result.url}\n\n` +
|
package/lib/commands/pdf.ts
CHANGED
|
@@ -8,28 +8,41 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { resolve } from "path"
|
|
11
|
+
import { hasDecksState, readDecksState } from "../decks-state"
|
|
11
12
|
import { exportToPdf } from "../pdf/export"
|
|
12
13
|
import { assertExportQAPassed } from "../qa/export-gate"
|
|
14
|
+
import { recordRenderedArtifact, workspaceRelative } from "../workspace-state/rendered-artifacts"
|
|
15
|
+
import { resolveActiveHtmlDeckPath } from "../workspace-state/render-targets"
|
|
13
16
|
|
|
14
17
|
export async function handlePdf(
|
|
15
18
|
filePath: string,
|
|
16
19
|
send: (text: string) => Promise<void>,
|
|
20
|
+
workspaceRoot = process.cwd(),
|
|
17
21
|
): Promise<void> {
|
|
18
|
-
|
|
22
|
+
const root = resolve(workspaceRoot)
|
|
23
|
+
const resolvedFile = resolvePdfDeckFile(root, filePath)
|
|
24
|
+
|
|
25
|
+
if (!resolvedFile) {
|
|
19
26
|
await send(
|
|
20
|
-
"**Usage:** `/revela pdf
|
|
27
|
+
"**Usage:** `/revela pdf [file_path]`\n\n" +
|
|
21
28
|
"Example: `/revela pdf decks/my-deck.html`"
|
|
22
29
|
)
|
|
23
30
|
return
|
|
24
31
|
}
|
|
25
32
|
|
|
26
|
-
const abs =
|
|
33
|
+
const abs = resolvedFile.absoluteFile
|
|
27
34
|
await send(`Running pre-export QA for \`${abs}\`...`)
|
|
28
35
|
|
|
29
36
|
try {
|
|
30
|
-
await assertExportQAPassed(abs)
|
|
37
|
+
await assertExportQAPassed(abs, { workspaceRoot: root })
|
|
31
38
|
await send(`Exporting \`${abs}\` to PDF...`)
|
|
32
|
-
const result = await exportToPdf(
|
|
39
|
+
const result = await exportToPdf(abs)
|
|
40
|
+
recordRenderedArtifact(root, {
|
|
41
|
+
sourceHtmlPath: resolvedFile.file,
|
|
42
|
+
outputPath: result.outputPath,
|
|
43
|
+
type: "pdf",
|
|
44
|
+
actor: "revela-pdf",
|
|
45
|
+
})
|
|
33
46
|
const secs = (result.durationMs / 1000).toFixed(1)
|
|
34
47
|
await send(
|
|
35
48
|
`**PDF exported successfully**\n\n` +
|
|
@@ -42,3 +55,18 @@ export async function handlePdf(
|
|
|
42
55
|
await send(`**PDF export failed**\n\n\`\`\`\n${msg}\n\`\`\``)
|
|
43
56
|
}
|
|
44
57
|
}
|
|
58
|
+
|
|
59
|
+
function resolvePdfDeckFile(workspaceRoot: string, filePath: string): { file: string; absoluteFile: string } | undefined {
|
|
60
|
+
const explicit = filePath.trim()
|
|
61
|
+
if (explicit) {
|
|
62
|
+
const absoluteFile = resolve(workspaceRoot, explicit)
|
|
63
|
+
return { file: workspaceRelative(workspaceRoot, absoluteFile), absoluteFile }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!hasDecksState(workspaceRoot)) return undefined
|
|
67
|
+
const state = readDecksState(workspaceRoot)
|
|
68
|
+
const activePath = resolveActiveHtmlDeckPath(state)
|
|
69
|
+
if (!activePath) return undefined
|
|
70
|
+
const absoluteFile = resolve(workspaceRoot, activePath)
|
|
71
|
+
return { file: workspaceRelative(workspaceRoot, absoluteFile), absoluteFile }
|
|
72
|
+
}
|
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 []
|
package/lib/commands/refine.ts
CHANGED
|
@@ -14,7 +14,7 @@ export async function handleRefine(
|
|
|
14
14
|
})
|
|
15
15
|
|
|
16
16
|
await send(
|
|
17
|
-
`Opened Revela Refine for the
|
|
17
|
+
`Opened Revela Refine for the active HTML deck.\n` +
|
|
18
18
|
`File: \`${result.deck.file}\`\n` +
|
|
19
19
|
`${result.stateNote}\n` +
|
|
20
20
|
`URL: ${result.url}\n\n` +
|
|
@@ -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
|
+
}
|