slides-grab 1.2.6 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README-ko.md +258 -0
- package/README.md +16 -12
- package/bin/ppt-agent.js +195 -1
- package/package.json +11 -6
- package/runtimes/claude-code/agents/design-critic-agent.md +23 -0
- package/runtimes/codex/agents/slides-grab-design-critic.md +22 -0
- package/scripts/design-gate.js +241 -0
- package/scripts/editor-server.js +1 -0
- package/scripts/html2png.js +246 -0
- package/scripts/install-runtime.js +216 -0
- package/skills/slides-grab/SKILL.md +14 -12
- package/skills/slides-grab/references/presentation-workflow-reference.md +1 -1
- package/skills/slides-grab-card-news/SKILL.md +1 -1
- package/skills/slides-grab-design/SKILL.md +15 -7
- package/skills/slides-grab-design/references/design-gate.md +349 -0
- package/skills/slides-grab-design/references/design-rules.md +3 -3
- package/skills/slides-grab-design/references/design-system-full.md +4 -4
- package/skills/slides-grab-export/SKILL.md +5 -4
- package/skills/slides-grab-export/references/pptx-skill-reference.md +7 -42
- package/skills/slides-grab-plan/SKILL.md +20 -7
- package/skills/slides-grab-plan/references/design-md-to-slides-conversion.md +135 -0
- package/skills/slides-grab-plan/references/plan-workflow-reference.md +14 -14
- package/src/design-diversity-data.js +6932 -0
- package/src/design-gate-report.js +244 -0
- package/src/design-gate-state.js +294 -0
- package/src/design-import.js +164 -0
- package/src/design-md-parser.js +415 -0
- package/src/design-styles.js +86 -4
- package/src/editor/codex-edit.js +61 -2
- package/src/editor/editor.html +1 -1
- package/src/editor/js/model-registry.js +1 -1
- package/templates/design-styles/README.md +2 -1
- package/templates/design-styles/preview.html +1088 -6
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
const PASS_A_SPEC = {
|
|
2
|
+
label: 'Pass A',
|
|
3
|
+
title: 'Pass A System Contract / Constraint Integrity',
|
|
4
|
+
titlePattern: /\bPass\s*A:?\s*System Contract\s*\/\s*Constraint Integrity\b/i,
|
|
5
|
+
checks: [
|
|
6
|
+
'System consistency',
|
|
7
|
+
'Color discipline',
|
|
8
|
+
'AI slop tropes',
|
|
9
|
+
'Content discipline',
|
|
10
|
+
],
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const PASS_B_SPEC = {
|
|
14
|
+
label: 'Pass B',
|
|
15
|
+
title: 'Pass B Audience Impact / Expressive Readability',
|
|
16
|
+
titlePattern: /\bPass\s*B:?\s*Audience Impact\s*\/\s*Expressive Readability\b/i,
|
|
17
|
+
checks: [
|
|
18
|
+
'Composition & hierarchy',
|
|
19
|
+
'Typography & legibility',
|
|
20
|
+
'Korean/CJK word-break integrity',
|
|
21
|
+
'Review Litmus',
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const CONFIDENCE_PATTERN = /^Confidence:\s*(High|Medium|Low)\s*$/im;
|
|
26
|
+
const EVIDENCE_PATTERN = /^Evidence:\s*.+\.png\b.*$/im;
|
|
27
|
+
const VERDICT_PATTERN = /^\s*VERDICT:\s*(\S+)\s*$/gim;
|
|
28
|
+
const BLOCKING_FINDINGS_PATTERN = /^\s*Blocking findings:\s*(.+)$/gim;
|
|
29
|
+
const UNRESOLVED_CRITICAL_PATTERN = /^\s*Unresolved Critical:\s*(\d+)\s*$/gim;
|
|
30
|
+
const SUPPORTED_FINDING_SEVERITIES = new Set(['Major', 'Minor', 'Note']);
|
|
31
|
+
|
|
32
|
+
export function assertProceedReportsComplete({ passAReport, passBReport, evidenceFiles = [], slideFingerprints = [] }) {
|
|
33
|
+
const validation = validateProceedReportsComplete({
|
|
34
|
+
passAReport,
|
|
35
|
+
passBReport,
|
|
36
|
+
evidenceFiles,
|
|
37
|
+
slideFingerprints,
|
|
38
|
+
});
|
|
39
|
+
if (validation.failures.length > 0) {
|
|
40
|
+
throw new Error(`Design gate cannot proceed: ${validation.failures.join(' ')}`);
|
|
41
|
+
}
|
|
42
|
+
return validation;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function validateProceedReportsComplete({
|
|
46
|
+
passAReport,
|
|
47
|
+
passBReport,
|
|
48
|
+
evidenceFiles = [],
|
|
49
|
+
slideFingerprints = [],
|
|
50
|
+
}) {
|
|
51
|
+
const passA = validateProceedReport(passAReport, PASS_A_SPEC, evidenceFiles, slideFingerprints);
|
|
52
|
+
const passB = validateProceedReport(passBReport, PASS_B_SPEC, evidenceFiles, slideFingerprints);
|
|
53
|
+
const failures = [
|
|
54
|
+
...passA.failures,
|
|
55
|
+
...passB.failures,
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
status: failures.length === 0 ? 'passed' : 'failed',
|
|
60
|
+
failures,
|
|
61
|
+
passA: passA.summary,
|
|
62
|
+
passB: passB.summary,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function validateProceedReport(report, spec, evidenceFiles, slideFingerprints) {
|
|
67
|
+
const failures = [];
|
|
68
|
+
const confidenceMatch = report.match(CONFIDENCE_PATTERN);
|
|
69
|
+
const criticalMatches = [...report.matchAll(UNRESOLVED_CRITICAL_PATTERN)];
|
|
70
|
+
const blockingMatches = [...report.matchAll(BLOCKING_FINDINGS_PATTERN)];
|
|
71
|
+
const verdict = parseVerdict(report);
|
|
72
|
+
const missingChecks = spec.checks.filter((check) => !hasPassedCheck(report, check));
|
|
73
|
+
|
|
74
|
+
if (!spec.titlePattern.test(report)) {
|
|
75
|
+
failures.push(`${spec.label} is missing required role title "${spec.title}".`);
|
|
76
|
+
}
|
|
77
|
+
failures.push(...validatePassVerdict(verdict, spec.label));
|
|
78
|
+
if (!CONFIDENCE_PATTERN.test(report)) {
|
|
79
|
+
failures.push(`${spec.label} is missing required Confidence: High|Medium|Low.`);
|
|
80
|
+
}
|
|
81
|
+
if (!EVIDENCE_PATTERN.test(report)) {
|
|
82
|
+
failures.push(`${spec.label} is missing required rendered PNG evidence reference.`);
|
|
83
|
+
}
|
|
84
|
+
failures.push(...validateEvidenceFiles(report, spec.label, evidenceFiles));
|
|
85
|
+
failures.push(...validateSlideFingerprints(report, spec.label, slideFingerprints));
|
|
86
|
+
failures.push(...validateFindingsTable(report, spec.label));
|
|
87
|
+
|
|
88
|
+
failures.push(...validateCriticalSummary(criticalMatches, blockingMatches, spec.label));
|
|
89
|
+
failures.push(...validateCriticalFindingRows(report, spec.label));
|
|
90
|
+
failures.push(...missingChecks.map((check) => `${spec.label} missing required passed check: ${check}.`));
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
failures,
|
|
94
|
+
summary: {
|
|
95
|
+
label: spec.label,
|
|
96
|
+
verdict: verdict.values.length === 1 ? verdict.values[0] : '',
|
|
97
|
+
confidence: confidenceMatch?.[1] || '',
|
|
98
|
+
unresolvedCritical: summarizeUnresolvedCritical(criticalMatches),
|
|
99
|
+
blockingFindings: parseBlockingFindings(blockingMatches),
|
|
100
|
+
checks: spec.checks.map((check) => ({
|
|
101
|
+
name: check,
|
|
102
|
+
status: missingChecks.includes(check) ? 'missing' : 'pass',
|
|
103
|
+
})),
|
|
104
|
+
evidenceFiles: evidenceFiles.filter((fileName) => report.includes(fileName)),
|
|
105
|
+
slideFingerprints: slideFingerprints.filter((entry) => report.includes(entry.file) && report.includes(entry.sha256)),
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function validateEvidenceFiles(report, label, evidenceFiles) {
|
|
111
|
+
return evidenceFiles
|
|
112
|
+
.filter((fileName) => !report.includes(fileName))
|
|
113
|
+
.map((fileName) => `${label} is missing rendered evidence file: ${fileName}.`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function validateSlideFingerprints(report, label, slideFingerprints) {
|
|
117
|
+
return slideFingerprints
|
|
118
|
+
.filter((entry) => !report.includes(entry.file) || !report.includes(entry.sha256))
|
|
119
|
+
.map((entry) => `${label} is missing current slide fingerprint: ${entry.file}: ${entry.sha256}.`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function validateFindingsTable(report, label) {
|
|
123
|
+
const headerPattern = /^\|\s*Slide\s*\|\s*Finding\s*\|\s*Severity\s*\|\s*Fix\s*\|\s*Status\s*\|/im;
|
|
124
|
+
const separatorPattern = /^\|\s*-+\s*\|\s*-+\s*\|\s*-+\s*\|\s*-+\s*\|\s*-+\s*\|/im;
|
|
125
|
+
const dataRows = parseFindingRows(report);
|
|
126
|
+
const failures = [];
|
|
127
|
+
|
|
128
|
+
if (!headerPattern.test(report)) {
|
|
129
|
+
failures.push(`${label} is missing required findings table columns: Slide | Finding | Severity | Fix | Status.`);
|
|
130
|
+
}
|
|
131
|
+
if (!separatorPattern.test(report)) {
|
|
132
|
+
failures.push(`${label} is missing required findings table separator row.`);
|
|
133
|
+
}
|
|
134
|
+
if (dataRows.length === 0) {
|
|
135
|
+
failures.push(`${label} is missing at least one findings table data row with a supported severity.`);
|
|
136
|
+
}
|
|
137
|
+
failures.push(...validateFindingRowSeverities(dataRows, label));
|
|
138
|
+
|
|
139
|
+
return failures;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function validateCriticalFindingRows(report, label) {
|
|
143
|
+
return parseFindingRows(report)
|
|
144
|
+
.filter((row) => row.severity.toLowerCase() === 'critical')
|
|
145
|
+
.map(() => `${label} lists a Critical severity finding and cannot proceed.`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function validateFindingRowSeverities(rows, label) {
|
|
149
|
+
return rows
|
|
150
|
+
.filter((row) => !SUPPORTED_FINDING_SEVERITIES.has(row.severity))
|
|
151
|
+
.map((row) => `${label} lists unsupported finding severity "${row.severity}".`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function validatePassVerdict(verdict, label) {
|
|
155
|
+
if (verdict.values.length === 0) {
|
|
156
|
+
return [`${label} is missing required "VERDICT: PASS".`];
|
|
157
|
+
}
|
|
158
|
+
if (verdict.values.length > 1) {
|
|
159
|
+
return [`${label} must include exactly one VERDICT line.`];
|
|
160
|
+
}
|
|
161
|
+
if (verdict.values[0] !== 'PASS') {
|
|
162
|
+
return [`${label} must use "VERDICT: PASS".`];
|
|
163
|
+
}
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function parseVerdict(report) {
|
|
168
|
+
return {
|
|
169
|
+
values: [...report.matchAll(VERDICT_PATTERN)].map((match) => match[1].trim().toUpperCase()),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function parseFindingRows(report) {
|
|
174
|
+
const lines = report.split(/\r?\n/);
|
|
175
|
+
const headerIndex = lines.findIndex((line) => (
|
|
176
|
+
/^\|\s*Slide\s*\|\s*Finding\s*\|\s*Severity\s*\|\s*Fix\s*\|\s*Status\s*\|/i.test(line.trim())
|
|
177
|
+
));
|
|
178
|
+
if (headerIndex === -1) return [];
|
|
179
|
+
|
|
180
|
+
const rows = [];
|
|
181
|
+
for (const rawLine of lines.slice(headerIndex + 1)) {
|
|
182
|
+
const line = rawLine.trim();
|
|
183
|
+
if (!line.startsWith('|')) {
|
|
184
|
+
if (line) break;
|
|
185
|
+
if (rows.length > 0) break;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const cells = line.split('|').slice(1, -1).map((cell) => cell.trim());
|
|
189
|
+
if (cells.length < 5) continue;
|
|
190
|
+
if (cells.every((cell) => /^-+$/.test(cell))) continue;
|
|
191
|
+
if (/^slide$/i.test(cells[0]) && /^finding$/i.test(cells[1])) continue;
|
|
192
|
+
rows.push({
|
|
193
|
+
slide: cells[0],
|
|
194
|
+
finding: cells[1],
|
|
195
|
+
severity: cells[2],
|
|
196
|
+
fix: cells[3],
|
|
197
|
+
status: cells[4],
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return rows;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function validateCriticalSummary(criticalMatches, blockingMatches, label) {
|
|
204
|
+
const failures = [];
|
|
205
|
+
|
|
206
|
+
if (criticalMatches.length === 0) {
|
|
207
|
+
failures.push(`${label} is missing required Unresolved Critical: 0.`);
|
|
208
|
+
} else if (criticalMatches.length > 1) {
|
|
209
|
+
failures.push(`${label} must include exactly one Unresolved Critical line.`);
|
|
210
|
+
} else if (criticalMatches[0][1] !== '0') {
|
|
211
|
+
failures.push(`${label} has unresolved Critical findings and blocks proceed.`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (blockingMatches.length === 0) {
|
|
215
|
+
failures.push(`${label} is missing required Blocking findings: None.`);
|
|
216
|
+
} else if (blockingMatches.length > 1) {
|
|
217
|
+
failures.push(`${label} must include exactly one Blocking findings line.`);
|
|
218
|
+
} else if (blockingMatches[0][1].trim().toLowerCase() !== 'none') {
|
|
219
|
+
failures.push(`${label} has blocking findings and cannot proceed.`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return failures;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function hasPassedCheck(report, check) {
|
|
226
|
+
const checkPattern = new RegExp(`^\\s*-\\s*\\[[xX]\\]\\s*${escapeRegex(check)}\\s*:\\s*PASS\\b`, 'im');
|
|
227
|
+
return checkPattern.test(report);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function summarizeUnresolvedCritical(matches) {
|
|
231
|
+
if (matches.length !== 1) return null;
|
|
232
|
+
return Number.parseInt(matches[0][1], 10);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function parseBlockingFindings(matches) {
|
|
236
|
+
if (matches.length !== 1) return null;
|
|
237
|
+
const value = matches[0][1].trim();
|
|
238
|
+
if (value.toLowerCase() === 'none') return [];
|
|
239
|
+
return [value];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function escapeRegex(value) {
|
|
243
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
244
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { basename, join, relative, resolve } from 'node:path';
|
|
5
|
+
import { classifyImageSource, extractCssUrls } from './image-contract.js';
|
|
6
|
+
|
|
7
|
+
const SLIDE_FILE_PATTERN = /^slide-.*\.html$/i;
|
|
8
|
+
const GATE_DIR_NAME = '.slides-grab';
|
|
9
|
+
const GATE_STATE_FILE = 'design-gate.json';
|
|
10
|
+
const GATE_REPORT_FILE = 'design-gate-report.md';
|
|
11
|
+
const GATE_PREVIEW_DIR = 'gate-preview';
|
|
12
|
+
const PROCEED = 'proceed';
|
|
13
|
+
const VALID_VERDICTS = new Set([PROCEED, 'revise', 'rethink']);
|
|
14
|
+
|
|
15
|
+
export function normalizeGateVerdict(value) {
|
|
16
|
+
const verdict = String(value || '').trim().toLowerCase();
|
|
17
|
+
if (!VALID_VERDICTS.has(verdict)) {
|
|
18
|
+
throw new Error(`Unknown design gate verdict "${value}". Expected: proceed, revise, or rethink.`);
|
|
19
|
+
}
|
|
20
|
+
return verdict;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function buildDesignGatePaths(slidesDir) {
|
|
24
|
+
const resolvedSlidesDir = resolve(process.cwd(), slidesDir);
|
|
25
|
+
const gateDir = join(resolvedSlidesDir, GATE_DIR_NAME);
|
|
26
|
+
return {
|
|
27
|
+
slidesDir: resolvedSlidesDir,
|
|
28
|
+
gateDir,
|
|
29
|
+
previewDir: join(gateDir, GATE_PREVIEW_DIR),
|
|
30
|
+
reportPath: join(gateDir, GATE_REPORT_FILE),
|
|
31
|
+
statePath: join(gateDir, GATE_STATE_FILE),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function findGateSlideFiles(slidesDir) {
|
|
36
|
+
const entries = await readdir(slidesDir, { withFileTypes: true });
|
|
37
|
+
const files = entries
|
|
38
|
+
.filter((entry) => entry.isFile() && SLIDE_FILE_PATTERN.test(entry.name))
|
|
39
|
+
.map((entry) => entry.name)
|
|
40
|
+
.sort(sortSlideFiles);
|
|
41
|
+
|
|
42
|
+
if (files.length === 0) {
|
|
43
|
+
throw new Error(`No slide-*.html files found in ${slidesDir}.`);
|
|
44
|
+
}
|
|
45
|
+
return files;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function collectSlideFingerprints(slidesDir) {
|
|
49
|
+
const resolvedSlidesDir = resolve(process.cwd(), slidesDir);
|
|
50
|
+
const files = await findGateSlideFiles(resolvedSlidesDir);
|
|
51
|
+
return Promise.all(files.map(async (fileName) => {
|
|
52
|
+
return {
|
|
53
|
+
file: fileName,
|
|
54
|
+
sha256: await hashFile(join(resolvedSlidesDir, fileName)),
|
|
55
|
+
};
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function collectLocalAssetFingerprints(slidesDir) {
|
|
60
|
+
const resolvedSlidesDir = resolve(process.cwd(), slidesDir);
|
|
61
|
+
const slideFiles = await findGateSlideFiles(resolvedSlidesDir);
|
|
62
|
+
const localAssets = new Set();
|
|
63
|
+
|
|
64
|
+
for (const fileName of slideFiles) {
|
|
65
|
+
const html = await readFile(join(resolvedSlidesDir, fileName), 'utf-8');
|
|
66
|
+
for (const source of extractLocalAssetSources(html)) {
|
|
67
|
+
localAssets.add(source);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return collectFileFingerprints(resolvedSlidesDir, Array.from(localAssets).sort(sortSlideFiles));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function collectFileFingerprints(baseDir, files) {
|
|
75
|
+
const resolvedBaseDir = resolve(process.cwd(), baseDir);
|
|
76
|
+
return Promise.all(files.map(async (fileName) => ({
|
|
77
|
+
file: fileName,
|
|
78
|
+
sha256: await hashFile(join(resolvedBaseDir, fileName)),
|
|
79
|
+
})));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function readDesignGateState(slidesDir) {
|
|
83
|
+
const { statePath } = buildDesignGatePaths(slidesDir);
|
|
84
|
+
if (!existsSync(statePath)) return null;
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
return JSON.parse(await readFile(statePath, 'utf-8'));
|
|
88
|
+
} catch (error) {
|
|
89
|
+
throw new Error(`Design gate state is unreadable: ${statePath}. ${error.message}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function writeDesignGateState(slidesDir, state, reportMarkdown = '') {
|
|
94
|
+
const paths = buildDesignGatePaths(slidesDir);
|
|
95
|
+
await mkdir(paths.gateDir, { recursive: true });
|
|
96
|
+
await writeFile(paths.statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf-8');
|
|
97
|
+
if (reportMarkdown) {
|
|
98
|
+
await writeFile(paths.reportPath, reportMarkdown, 'utf-8');
|
|
99
|
+
}
|
|
100
|
+
return paths;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function createDesignGateState(options) {
|
|
104
|
+
const slidesDir = resolve(process.cwd(), options.slidesDir);
|
|
105
|
+
const verdict = normalizeGateVerdict(options.verdict);
|
|
106
|
+
const slideFingerprints = await collectSlideFingerprints(slidesDir);
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
schemaVersion: 2,
|
|
110
|
+
verdict,
|
|
111
|
+
generatedAt: new Date().toISOString(),
|
|
112
|
+
slideMode: options.slideMode,
|
|
113
|
+
resolution: options.resolution,
|
|
114
|
+
previewDir: options.previewDir ? relative(slidesDir, options.previewDir) : join(GATE_DIR_NAME, GATE_PREVIEW_DIR),
|
|
115
|
+
reportPath: options.reportPath ? relative(slidesDir, options.reportPath) : join(GATE_DIR_NAME, GATE_REPORT_FILE),
|
|
116
|
+
passA: normalizeEvidence(options.passA),
|
|
117
|
+
passB: normalizeEvidence(options.passB),
|
|
118
|
+
gateValidation: options.gateValidation || { status: verdict === PROCEED ? 'passed' : 'not-run' },
|
|
119
|
+
slideFingerprints,
|
|
120
|
+
localAssetFingerprints: options.localAssetFingerprints || await collectLocalAssetFingerprints(slidesDir),
|
|
121
|
+
passReportFingerprints: options.passReportFingerprints || [],
|
|
122
|
+
previewFingerprints: options.previewFingerprints || [],
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function assertDesignGateReady(slidesDir, options = {}) {
|
|
127
|
+
const label = options.label || 'export';
|
|
128
|
+
const paths = buildDesignGatePaths(slidesDir);
|
|
129
|
+
const state = await readDesignGateState(paths.slidesDir);
|
|
130
|
+
if (!state) {
|
|
131
|
+
throw new Error(`${label} blocked: run slides-grab design-gate and record a Proceed verdict before exporting.`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (normalizeGateVerdict(state.verdict) !== PROCEED) {
|
|
135
|
+
throw new Error(`${label} blocked: latest design gate verdict is "${state.verdict}", not "proceed".`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (state.schemaVersion !== 2) {
|
|
139
|
+
throw new Error(`${label} blocked: design gate receipt is from a legacy unenforced schema; rerun slides-grab design-gate.`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (state.gateValidation?.status !== 'passed') {
|
|
143
|
+
throw new Error(`${label} blocked: design gate report validation did not pass.`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!state.passA?.reportPath || !state.passB?.reportPath) {
|
|
147
|
+
throw new Error(`${label} blocked: design gate state is missing dual-oracle Pass A/Pass B evidence.`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
assertPassReady(state.passA, 'Pass A', label);
|
|
151
|
+
assertPassReady(state.passB, 'Pass B', label);
|
|
152
|
+
|
|
153
|
+
const currentFingerprints = await collectSlideFingerprints(paths.slidesDir);
|
|
154
|
+
const previousFingerprints = Array.isArray(state.slideFingerprints) ? state.slideFingerprints : [];
|
|
155
|
+
const staleFiles = diffFingerprints(previousFingerprints, currentFingerprints);
|
|
156
|
+
if (staleFiles.length > 0) {
|
|
157
|
+
throw new Error(`${label} blocked: design gate is stale because slides changed: ${staleFiles.join(', ')}.`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const localAssetFingerprints = Array.isArray(state.localAssetFingerprints) ? state.localAssetFingerprints : null;
|
|
161
|
+
if (!localAssetFingerprints) {
|
|
162
|
+
throw new Error(`${label} blocked: design gate receipt is missing local asset fingerprints; rerun slides-grab design-gate.`);
|
|
163
|
+
}
|
|
164
|
+
let currentAssetFingerprints = [];
|
|
165
|
+
try {
|
|
166
|
+
currentAssetFingerprints = await collectLocalAssetFingerprints(paths.slidesDir);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
throw new Error(`${label} blocked: design gate is stale because local assets changed. ${error.message}`);
|
|
169
|
+
}
|
|
170
|
+
const staleAssets = diffFingerprints(localAssetFingerprints, currentAssetFingerprints);
|
|
171
|
+
if (staleAssets.length > 0) {
|
|
172
|
+
throw new Error(`${label} blocked: design gate is stale because local assets changed: ${staleAssets.join(', ')}.`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const passReportFingerprints = Array.isArray(state.passReportFingerprints) ? state.passReportFingerprints : [];
|
|
176
|
+
if (passReportFingerprints.length < 2) {
|
|
177
|
+
throw new Error(`${label} blocked: design gate receipt is missing Pass A/Pass B report fingerprints.`);
|
|
178
|
+
}
|
|
179
|
+
const currentReportFingerprints = await collectFileFingerprints(paths.slidesDir, passReportFingerprints.map((entry) => entry.file));
|
|
180
|
+
const staleReports = diffFingerprints(passReportFingerprints, currentReportFingerprints);
|
|
181
|
+
if (staleReports.length > 0) {
|
|
182
|
+
throw new Error(`${label} blocked: design gate is stale because Pass A/Pass B reports changed: ${staleReports.join(', ')}.`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const previewFingerprints = Array.isArray(state.previewFingerprints) ? state.previewFingerprints : [];
|
|
186
|
+
if (previewFingerprints.length === 0) {
|
|
187
|
+
throw new Error(`${label} blocked: design gate receipt is missing rendered evidence fingerprints.`);
|
|
188
|
+
}
|
|
189
|
+
const currentPreviewFingerprints = await collectFileFingerprints(paths.slidesDir, previewFingerprints.map((entry) => entry.file));
|
|
190
|
+
const stalePreviewFiles = diffFingerprints(previewFingerprints, currentPreviewFingerprints);
|
|
191
|
+
if (stalePreviewFiles.length > 0) {
|
|
192
|
+
throw new Error(`${label} blocked: design gate is stale because rendered evidence changed: ${stalePreviewFiles.join(', ')}.`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return state;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function buildDesignGateReport(state, passAReport, passBReport) {
|
|
199
|
+
return [
|
|
200
|
+
'# slides-grab Design Gate Report',
|
|
201
|
+
'',
|
|
202
|
+
`Verdict: ${state.verdict}`,
|
|
203
|
+
`Generated: ${state.generatedAt}`,
|
|
204
|
+
`Slide mode: ${state.slideMode}`,
|
|
205
|
+
`Resolution: ${state.resolution}`,
|
|
206
|
+
'',
|
|
207
|
+
'## Pass A: System Contract / Constraint Integrity',
|
|
208
|
+
'',
|
|
209
|
+
passAReport.trim(),
|
|
210
|
+
'',
|
|
211
|
+
'## Pass B: Audience Impact / Expressive Readability',
|
|
212
|
+
'',
|
|
213
|
+
passBReport.trim(),
|
|
214
|
+
'',
|
|
215
|
+
'## Slide Fingerprints',
|
|
216
|
+
'',
|
|
217
|
+
...state.slideFingerprints.map((entry) => `- ${entry.file}: ${entry.sha256}`),
|
|
218
|
+
'',
|
|
219
|
+
].join('\n');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function normalizeEvidence(value = {}) {
|
|
223
|
+
return {
|
|
224
|
+
reportPath: value.reportPath || '',
|
|
225
|
+
summary: value.summary || '',
|
|
226
|
+
verdict: value.verdict || '',
|
|
227
|
+
unresolvedCritical: value.unresolvedCritical ?? null,
|
|
228
|
+
blockingFindings: Array.isArray(value.blockingFindings) ? value.blockingFindings : [],
|
|
229
|
+
checks: Array.isArray(value.checks) ? value.checks : [],
|
|
230
|
+
evidenceFiles: Array.isArray(value.evidenceFiles) ? value.evidenceFiles : [],
|
|
231
|
+
slideFingerprints: Array.isArray(value.slideFingerprints) ? value.slideFingerprints : [],
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function extractLocalAssetSources(html) {
|
|
236
|
+
const sources = new Set();
|
|
237
|
+
const attributePattern = /\b(?:src|poster)\s*=\s*(['"])(.*?)\1/gi;
|
|
238
|
+
let match;
|
|
239
|
+
|
|
240
|
+
while ((match = attributePattern.exec(html)) !== null) {
|
|
241
|
+
addLocalAssetSource(sources, match[2]);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
for (const source of extractCssUrls(html)) {
|
|
245
|
+
addLocalAssetSource(sources, source);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return Array.from(sources);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function addLocalAssetSource(sources, source) {
|
|
252
|
+
const value = String(source || '').trim();
|
|
253
|
+
if (classifyImageSource(value).kind === 'local-asset-path') {
|
|
254
|
+
sources.add(value);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function hashFile(filePath) {
|
|
259
|
+
const bytes = await readFile(filePath);
|
|
260
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function assertPassReady(pass, passLabel, exportLabel) {
|
|
264
|
+
if (pass.verdict !== 'PASS') {
|
|
265
|
+
throw new Error(`${exportLabel} blocked: ${passLabel} did not record VERDICT: PASS.`);
|
|
266
|
+
}
|
|
267
|
+
if (pass.unresolvedCritical !== 0) {
|
|
268
|
+
throw new Error(`${exportLabel} blocked: ${passLabel} has unresolved Critical findings.`);
|
|
269
|
+
}
|
|
270
|
+
if (pass.blockingFindings.length > 0) {
|
|
271
|
+
throw new Error(`${exportLabel} blocked: ${passLabel} has blocking findings.`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function toSlideOrder(fileName) {
|
|
276
|
+
const match = basename(fileName).match(/\d+/);
|
|
277
|
+
return match ? Number.parseInt(match[0], 10) : Number.POSITIVE_INFINITY;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function sortSlideFiles(left, right) {
|
|
281
|
+
const leftOrder = toSlideOrder(left);
|
|
282
|
+
const rightOrder = toSlideOrder(right);
|
|
283
|
+
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
|
|
284
|
+
return left.localeCompare(right);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function diffFingerprints(previous, current) {
|
|
288
|
+
const previousByFile = new Map(previous.map((entry) => [entry.file, entry.sha256]));
|
|
289
|
+
const currentByFile = new Map(current.map((entry) => [entry.file, entry.sha256]));
|
|
290
|
+
const files = new Set([...previousByFile.keys(), ...currentByFile.keys()]);
|
|
291
|
+
return Array.from(files)
|
|
292
|
+
.filter((file) => previousByFile.get(file) !== currentByFile.get(file))
|
|
293
|
+
.sort(sortSlideFiles);
|
|
294
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_MAX_BYTES = 256 * 1024;
|
|
5
|
+
const ALLOWED_PROTOCOLS = new Set(['https:']);
|
|
6
|
+
|
|
7
|
+
function getHeader(headers, name) {
|
|
8
|
+
if (!headers) return '';
|
|
9
|
+
if (typeof headers.get === 'function') return headers.get(name) ?? '';
|
|
10
|
+
return headers[name] ?? headers[name.toLowerCase()] ?? '';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function validateFinalDesignUrl(rawUrl, { allowedProtocols = ALLOWED_PROTOCOLS } = {}) {
|
|
14
|
+
let url;
|
|
15
|
+
try {
|
|
16
|
+
url = new URL(rawUrl);
|
|
17
|
+
} catch (cause) {
|
|
18
|
+
throw new DesignImportError(`Invalid final URL after redirects: ${rawUrl}`, { cause });
|
|
19
|
+
}
|
|
20
|
+
if (!allowedProtocols.has(url.protocol)) {
|
|
21
|
+
throw new DesignImportError(
|
|
22
|
+
`Redirect final URL protocol ${url.protocol} is not allowed. Allowed: ${[...allowedProtocols].join(', ')}`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return url;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function readResponseBody(response, { maxBytes }) {
|
|
29
|
+
if (response.body && typeof response.body.getReader === 'function') {
|
|
30
|
+
const reader = response.body.getReader();
|
|
31
|
+
const chunks = [];
|
|
32
|
+
let total = 0;
|
|
33
|
+
try {
|
|
34
|
+
while (true) {
|
|
35
|
+
const { done, value } = await reader.read();
|
|
36
|
+
if (done) break;
|
|
37
|
+
const chunk = Buffer.from(value);
|
|
38
|
+
total += chunk.byteLength;
|
|
39
|
+
if (total > maxBytes) {
|
|
40
|
+
if (typeof reader.cancel === 'function') {
|
|
41
|
+
await reader.cancel().catch(() => {});
|
|
42
|
+
}
|
|
43
|
+
throw new DesignImportError(
|
|
44
|
+
`DESIGN.md exceeds max size (${total} > ${maxBytes} bytes).`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
chunks.push(chunk);
|
|
48
|
+
}
|
|
49
|
+
} finally {
|
|
50
|
+
if (typeof reader.releaseLock === 'function') reader.releaseLock();
|
|
51
|
+
}
|
|
52
|
+
return Buffer.concat(chunks, total);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
56
|
+
if (buffer.byteLength > maxBytes) {
|
|
57
|
+
throw new DesignImportError(
|
|
58
|
+
`DESIGN.md exceeds max size (${buffer.byteLength} > ${maxBytes} bytes).`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return buffer;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class DesignImportError extends Error {
|
|
65
|
+
constructor(message, { cause } = {}) {
|
|
66
|
+
super(message);
|
|
67
|
+
this.name = 'DesignImportError';
|
|
68
|
+
if (cause) this.cause = cause;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function validateDesignUrl(rawUrl, { allowedProtocols = ALLOWED_PROTOCOLS } = {}) {
|
|
73
|
+
if (typeof rawUrl !== 'string' || rawUrl.trim() === '') {
|
|
74
|
+
throw new DesignImportError('Design URL is required.');
|
|
75
|
+
}
|
|
76
|
+
let url;
|
|
77
|
+
try {
|
|
78
|
+
url = new URL(rawUrl);
|
|
79
|
+
} catch (cause) {
|
|
80
|
+
throw new DesignImportError(`Invalid URL: ${rawUrl}`, { cause });
|
|
81
|
+
}
|
|
82
|
+
if (!allowedProtocols.has(url.protocol)) {
|
|
83
|
+
throw new DesignImportError(
|
|
84
|
+
`URL protocol ${url.protocol} is not allowed. Allowed: ${[...allowedProtocols].join(', ')}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return url;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function fetchDesignMarkdown(rawUrl, options = {}) {
|
|
91
|
+
const {
|
|
92
|
+
fetchImpl = globalThis.fetch,
|
|
93
|
+
maxBytes = DEFAULT_MAX_BYTES,
|
|
94
|
+
timeoutMs = 15000,
|
|
95
|
+
allowedProtocols,
|
|
96
|
+
} = options;
|
|
97
|
+
|
|
98
|
+
if (typeof fetchImpl !== 'function') {
|
|
99
|
+
throw new DesignImportError('No fetch implementation available; Node.js >= 18 required.');
|
|
100
|
+
}
|
|
101
|
+
const url = validateDesignUrl(rawUrl, { allowedProtocols });
|
|
102
|
+
|
|
103
|
+
const controller = new AbortController();
|
|
104
|
+
const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);
|
|
105
|
+
|
|
106
|
+
let response;
|
|
107
|
+
try {
|
|
108
|
+
response = await fetchImpl(url.toString(), {
|
|
109
|
+
redirect: 'follow',
|
|
110
|
+
signal: controller.signal,
|
|
111
|
+
headers: { 'User-Agent': 'slides-grab/import-design (+https://github.com/NomaDamas/slides-grab)' },
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const finalUrl = validateFinalDesignUrl(response.url ?? url.toString(), { allowedProtocols });
|
|
115
|
+
|
|
116
|
+
if (!response.ok) {
|
|
117
|
+
throw new DesignImportError(`Fetch returned HTTP ${response.status} for ${url}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const contentType = getHeader(response.headers, 'content-type');
|
|
121
|
+
const looksLikeText = contentType === '' ||
|
|
122
|
+
contentType.includes('text/') ||
|
|
123
|
+
contentType.includes('markdown') ||
|
|
124
|
+
contentType.includes('application/octet-stream');
|
|
125
|
+
if (!looksLikeText) {
|
|
126
|
+
throw new DesignImportError(
|
|
127
|
+
`Refusing to import non-text response (content-type: ${contentType}).`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const buffer = await readResponseBody(response, { maxBytes });
|
|
132
|
+
const text = buffer.toString('utf8');
|
|
133
|
+
return {
|
|
134
|
+
url: finalUrl.toString(),
|
|
135
|
+
contentType,
|
|
136
|
+
bytes: buffer.byteLength,
|
|
137
|
+
fetchedAt: new Date().toISOString(),
|
|
138
|
+
text,
|
|
139
|
+
};
|
|
140
|
+
} catch (cause) {
|
|
141
|
+
if (cause instanceof DesignImportError) throw cause;
|
|
142
|
+
throw new DesignImportError(`Fetch failed for ${url}: ${cause.message}`, { cause });
|
|
143
|
+
} finally {
|
|
144
|
+
clearTimeout(timeoutHandle);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function formatImportedDesignMarkdown({ url, content, fetchedAt }) {
|
|
149
|
+
const banner = [
|
|
150
|
+
'<!--',
|
|
151
|
+
` Imported by slides-grab import-design`,
|
|
152
|
+
` source: ${url}`,
|
|
153
|
+
` fetched-at: ${fetchedAt}`,
|
|
154
|
+
'-->',
|
|
155
|
+
'',
|
|
156
|
+
].join('\n');
|
|
157
|
+
return `${banner}${content}\n`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function saveImportedDesign({ outputPath, markdown }) {
|
|
161
|
+
const absolutePath = resolve(outputPath);
|
|
162
|
+
writeFileSync(absolutePath, markdown, 'utf8');
|
|
163
|
+
return absolutePath;
|
|
164
|
+
}
|