diff --git a/.gitignore b/.gitignore index 2c4fa952..659e07c0 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,7 @@ CLAUDE.md # Generated printable checklists docs/public/printable/ -.direnv \ No newline at end of file +.direnv + +# Avoid submitting certs data to repo +cert-data.json diff --git a/components/cert/ExportAllCerts.tsx b/components/cert/ExportAllCerts.tsx new file mode 100644 index 00000000..0871e62c --- /dev/null +++ b/components/cert/ExportAllCerts.tsx @@ -0,0 +1,187 @@ +import { useState } from "react"; +import ExcelJS from "exceljs"; +import { ControlData, ControlState, Section } from "./types"; +import "./control.css"; + +interface CertDefinition { + name: string; + label: string; + sections: Section[]; +} + +const stateToText: Record = { + no: "No", + yes: "Yes", + partial: "Partial", + na: "N/A", +}; + +function getStoredData(certName: string): Record { + try { + const saved = localStorage.getItem(`certList-${certName}`); + return saved ? JSON.parse(saved) : {}; + } catch { + return {}; + } +} + +function addCertSheet( + workbook: ExcelJS.Workbook, + label: string, + sections: Section[], + controlData: Record, +) { + const sheetName = label.length > 31 ? label.slice(0, 31) : label; + const worksheet = workbook.addWorksheet(sheetName); + + worksheet.columns = [ + { header: "Section", key: "section", width: 12 }, + { header: "Control ID", key: "id", width: 15 }, + { header: "Question", key: "question", width: 80 }, + { header: "Baseline Requirements", key: "baselines", width: 60 }, + { header: "Response", key: "response", width: 12 }, + { header: "N/A Justification", key: "justification", width: 40 }, + { header: "Evidence / Notes", key: "notes", width: 50 }, + ]; + + const headerRow = worksheet.getRow(1); + headerRow.font = { bold: true, color: { argb: "FFFFFFFF" } }; + headerRow.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FF4339DB" }, + }; + headerRow.alignment = { vertical: "middle", horizontal: "left" }; + headerRow.height = 25; + + let currentRow = 2; + + sections.forEach((section, sectionIndex) => { + const sectionHeaderRow = worksheet.getRow(currentRow); + worksheet.mergeCells(currentRow, 1, currentRow, 7); + + const sectionCell = sectionHeaderRow.getCell(1); + sectionCell.value = `Section ${sectionIndex + 1} — ${section.title}`; + sectionCell.font = { bold: true, size: 12 }; + sectionCell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFF3F4F6" }, + }; + sectionCell.alignment = { vertical: "middle", horizontal: "left" }; + sectionHeaderRow.height = 22; + currentRow++; + + section.controls.forEach((control) => { + const data = controlData[control.id] || { state: "no", justification: "", evidence: "" }; + const baselinesText = control.baselines?.length + ? control.baselines.map((b, i) => `${i + 1}. ${b}`).join("\n") + : ""; + + const row = worksheet.addRow({ + section: `Section ${sectionIndex + 1}`, + id: control.id, + question: control.description, + baselines: baselinesText, + response: stateToText[data.state], + justification: data.justification, + notes: data.evidence, + }); + + const rowNumber = row.number; + + worksheet.getCell(`E${rowNumber}`).dataValidation = { + type: "list", + allowBlank: false, + formulae: ['"No,Yes,Partial,N/A"'], + showErrorMessage: true, + errorTitle: "Invalid Response", + error: "Please select: No, Yes, Partial, or N/A", + }; + worksheet.getCell(`E${rowNumber}`).alignment = { vertical: "middle", horizontal: "left" }; + + for (const col of ["C", "D", "F", "G"]) { + worksheet.getCell(`${col}${rowNumber}`).alignment = { wrapText: true, vertical: "top", horizontal: "left" }; + } + for (const col of ["A", "B"]) { + worksheet.getCell(`${col}${rowNumber}`).alignment = { vertical: "middle", horizontal: "left" }; + } + + row.eachCell((cell) => { + cell.border = { + top: { style: "thin", color: { argb: "FFD1D5DB" } }, + left: { style: "thin", color: { argb: "FFD1D5DB" } }, + bottom: { style: "thin", color: { argb: "FFD1D5DB" } }, + right: { style: "thin", color: { argb: "FFD1D5DB" } }, + }; + }); + currentRow++; + }); + }); + + worksheet.autoFilter = { from: "A1", to: `G${worksheet.rowCount}` }; + worksheet.views = [{ state: "frozen", xSplit: 0, ySplit: 1, topLeftCell: "A2", activeCell: "A2" }]; +} + +export function ExportAllCerts() { + const [exporting, setExporting] = useState(false); + const [error, setError] = useState(null); + + const handleExportAll = async () => { + setExporting(true); + setError(null); + + try { + // Fetch cert data generated at build time + const resp = await fetch("/cert-data.json"); + if (!resp.ok) throw new Error("Failed to load certification data"); + const certs: CertDefinition[] = await resp.json(); + + const workbook = new ExcelJS.Workbook(); + workbook.creator = "SEAL Certifications"; + workbook.created = new Date(); + + for (const cert of certs) { + const controlData = getStoredData(cert.name); + addCertSheet(workbook, cert.label, cert.sections, controlData); + } + + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + const timestamp = new Date().toISOString().replace(/T/, "-").replace(/:/g, "-").split(".")[0]; + a.download = `seal-certifications-all-${timestamp}.xlsx`; + a.click(); + URL.revokeObjectURL(url); + } catch (err) { + setError(`Export failed: ${err instanceof Error ? err.message : "Unknown error"}`); + } finally { + setExporting(false); + } + }; + + return ( +
+ + {error && ( +
+ {error} +
+ )} +
+ ); +} + +ExportAllCerts.displayName = "ExportAllCerts"; diff --git a/components/index.ts b/components/index.ts index da3a5a3f..8ee66ac6 100644 --- a/components/index.ts +++ b/components/index.ts @@ -16,6 +16,7 @@ export { ContributeFooter } from './footer/ContributeFooter' export { Contributors } from './contributors/Contributors' export { BenchmarkList } from './benchmark/Benchmark' export { CertList } from './cert/CertList' +export { ExportAllCerts } from './cert/ExportAllCerts' export type { Control, Section, CertListProps, ControlState, ControlData } from './cert/types' export { default as MermaidRenderer } from './mermaid/MermaidRenderer'; export * from './shared/tagColors' diff --git a/docs/pages/certs/overview.mdx b/docs/pages/certs/overview.mdx index ac89d733..50059948 100644 --- a/docs/pages/certs/overview.mdx +++ b/docs/pages/certs/overview.mdx @@ -6,7 +6,7 @@ tags: - Certifications --- -import { TagList, AttributionList, TagProvider, TagFilter, ContributeFooter } from '../../../components' +import { TagList, AttributionList, TagProvider, TagFilter, ContributeFooter, ExportAllCerts } from '../../../components' @@ -51,6 +51,8 @@ We welcome feedback on the current certifications, suggestions for new modular c ## Certifications Being Developed + + - **[DevOps & Infrastructure](/certs/sfc-devops-infrastructure.mdx)** - Development environments, CI/CD pipelines, infrastructure security, supply chain - **[DNS Security](/certs/sfc-dns-registrar.mdx)** - Domain management, DNS configurations, registrar protection diff --git a/package.json b/package.json index 1aa2625d..e726fafb 100644 --- a/package.json +++ b/package.json @@ -14,13 +14,14 @@ "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "docs:dev": "pnpm run generate-tags && pnpm run generate-indexes && pnpm run mermaid-wrapper && vocs dev --host 0.0.0.0 --port 5173", - "docs:build": "pnpm run generate-tags && pnpm run generate-indexes && pnpm run mermaid-wrapper && pnpm run generate-printables && vocs build", + "docs:build": "pnpm run generate-tags && pnpm run generate-indexes && pnpm run mermaid-wrapper && pnpm run generate-printables && pnpm run generate-cert-data && vocs build", "postdocs:build": "node utils/searchbar-indexing.js && node utils/sitemap-generator.js", "docs:preview": "vocs preview", "generate-tags": "node utils/tags-fetcher.js", "mermaid-wrapper": "node utils/mermaid-block-wrapper.js", "generate-indexes": "node utils/generate-folder-indexes.js", - "generate-printables": "node utils/generate-printable-checklists.js" + "generate-printables": "node utils/generate-printable-checklists.js", + "generate-cert-data": "node utils/generate-cert-data.js" }, "keywords": [], "author": "", diff --git a/utils/generate-cert-data.js b/utils/generate-cert-data.js new file mode 100644 index 00000000..989a0297 --- /dev/null +++ b/utils/generate-cert-data.js @@ -0,0 +1,72 @@ +/** + * Generate Cert Data JSON + * + * Extracts all SFC certification section/control data from MDX frontmatter + * and writes a JSON file for use by the ExportAllCerts component. + * + * Usage: node utils/generate-cert-data.js + */ + +const fs = require('fs'); +const path = require('path'); +const matter = require('gray-matter'); + +const CERTS_DIR = path.join(__dirname, '../docs/pages/certs'); +const OUTPUT_PATH = path.join(__dirname, '../docs/public/cert-data.json'); + +const CERT_ORDER = [ + { file: 'sfc-devops-infrastructure.mdx', label: 'DevOps & Infrastructure' }, + { file: 'sfc-dns-registrar.mdx', label: 'DNS Registrar' }, + { file: 'sfc-incident-response.mdx', label: 'Incident Response' }, + { file: 'sfc-multisig-ops.mdx', label: 'Multisig Operations' }, + { file: 'sfc-treasury-ops.mdx', label: 'Treasury Operations' }, + { file: 'sfc-workspace-security.mdx', label: 'Workspace Security' }, +]; + +function main() { + console.log('Generating cert data JSON...\n'); + + const certs = []; + + for (const { file, label } of CERT_ORDER) { + const filePath = path.join(CERTS_DIR, file); + if (!fs.existsSync(filePath)) { + console.log(` Skipping ${file} - not found`); + continue; + } + + const content = fs.readFileSync(filePath, 'utf8'); + const { data } = matter(content); + + if (!data.cert || !Array.isArray(data.cert)) { + console.log(` Skipping ${file} - no cert data`); + continue; + } + + const name = file.replace('.mdx', ''); + const controlCount = data.cert.reduce((sum, s) => sum + (s.controls?.length || 0), 0); + + certs.push({ + name, + label, + sections: data.cert, + }); + + console.log(` ✓ ${name} (${data.cert.length} sections, ${controlCount} controls)`); + } + + // Ensure output directory exists + const outputDir = path.dirname(OUTPUT_PATH); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + fs.writeFileSync(OUTPUT_PATH, JSON.stringify(certs, null, 2)); + + const totalControls = certs.reduce((sum, c) => + sum + c.sections.reduce((s, sec) => s + (sec.controls?.length || 0), 0), 0 + ); + console.log(`\n✅ Generated cert-data.json (${certs.length} certs, ${totalControls} total controls)`); +} + +main();