From 73026ffb075d552cb39d6663b0d059a087046180 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Sun, 15 Mar 2026 12:36:16 +0200 Subject: [PATCH 1/5] feat: add playground --- .gitignore | 2 + cli/playground.html | 282 +++++ cli/src/bundler/vite-builder.ts | 13 +- cli/src/cli.ts | 2 +- cli/src/server/config.ts | 2 +- cli/src/server/facet-cache.ts | 110 ++ cli/src/server/facet-types.ts | 1782 +++++++++++++++++++++++++++++ cli/src/server/playground-html.ts | 421 +++++++ cli/src/server/preview.ts | 20 +- cli/src/server/render-stream.ts | 98 ++ cli/src/server/request.ts | 30 +- cli/src/server/routes.ts | 200 +++- cli/src/utils/pdf-generator.ts | 47 +- cli/test/render-api.test.ts | 81 ++ 14 files changed, 3036 insertions(+), 54 deletions(-) create mode 100644 cli/playground.html create mode 100644 cli/src/server/facet-cache.ts create mode 100644 cli/src/server/facet-types.ts create mode 100644 cli/src/server/playground-html.ts create mode 100644 cli/src/server/render-stream.ts diff --git a/.gitignore b/.gitignore index 579908d..656f383 100644 --- a/.gitignore +++ b/.gitignore @@ -160,3 +160,5 @@ src/examples/*/dist/ cli/cli cmd/facet/facet-cli.tar.gz facet +hack/ +*.png diff --git a/cli/playground.html b/cli/playground.html new file mode 100644 index 0000000..3e31575 --- /dev/null +++ b/cli/playground.html @@ -0,0 +1,282 @@ + + + + + + Facet Playground + + + +
+ Facet Playground +
+ + + + +
+ +
+ + +
+
+
+
+ + +
+
+ +
+
+
Click "Render" to preview your template
+
+
+ + + + + diff --git a/cli/src/bundler/vite-builder.ts b/cli/src/bundler/vite-builder.ts index 976eee1..67aa4b8 100644 --- a/cli/src/bundler/vite-builder.ts +++ b/cli/src/bundler/vite-builder.ts @@ -12,6 +12,7 @@ import { writeFileSync, readFileSync, mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { Logger } from '../utils/logger.js'; import { FacetDirectory } from '../builders/facet-directory.js'; +import { depHash, linkCachedNodeModules, promoteToCacheAfterInstall } from '../server/facet-cache.js'; export interface BuildResult { html: string; @@ -68,8 +69,15 @@ export async function buildTemplate(options: BuildOptions): Promise facetDir.generatePostCSSConfig(); facetDir.generateTailwindConfig(); - // Install dependencies only when package.json changed or node_modules is missing - if (facetDir.needsInstall()) { + // Try to reuse cached node_modules based on dependency hash + let pkgDeps: Record | undefined; + try { + const pkg = JSON.parse(readFileSync(join(facetRoot, 'package.json'), 'utf-8')); + pkgDeps = pkg.dependencies; + } catch {} + const hash = depHash(pkgDeps); + + if (!linkCachedNodeModules(facetRoot, hash, logger) && facetDir.needsInstall()) { logger.debug('Installing dependencies...'); try { const npmResult = await $`cd ${facetRoot} && npm install --ignore-scripts 2>&1`.quiet(); @@ -78,6 +86,7 @@ export async function buildTemplate(options: BuildOptions): Promise const output = error?.stdout?.toString?.() || error?.stderr?.toString?.() || error?.message || String(error); throw new Error(`npm install failed in ${facetRoot}:\n${output}`); } + promoteToCacheAfterInstall(facetRoot, hash, logger); logger.debug('Dependencies installed'); } else { logger.debug('Dependencies up to date, skipping npm install'); diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 22384b1..21f30d0 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -122,7 +122,7 @@ addSharedOptions( program .command('serve') .description('Start API server for rendering templates') - .option('-p, --port ', 'Server port', '3000') + .option('-p, --port ', 'Server port', '3010') .option('--templates-dir ', 'Directory containing templates', '.') .option('--workers ', 'Number of browser workers', '2') .option('--timeout ', 'Render timeout in milliseconds', '60000') diff --git a/cli/src/server/config.ts b/cli/src/server/config.ts index b7bbba1..0ae4e12 100644 --- a/cli/src/server/config.ts +++ b/cli/src/server/config.ts @@ -37,7 +37,7 @@ export interface ServerCLIFlags { export function loadConfig(flags: ServerCLIFlags): ServerConfig { const config: ServerConfig = { - port: parseInt(flags.port ?? process.env['FACET_PORT'] ?? '3000', 10), + port: parseInt(flags.port ?? process.env['FACET_PORT'] ?? '3010', 10), templatesDir: flags.templatesDir ?? process.env['FACET_TEMPLATES_DIR'] ?? '.', workers: parseInt(flags.workers ?? process.env['FACET_WORKERS'] ?? '2', 10), renderTimeout: parseInt(flags.timeout ?? process.env['FACET_RENDER_TIMEOUT'] ?? '60000', 10), diff --git a/cli/src/server/facet-cache.ts b/cli/src/server/facet-cache.ts new file mode 100644 index 0000000..f526964 --- /dev/null +++ b/cli/src/server/facet-cache.ts @@ -0,0 +1,110 @@ +/** + * Caches .facet/node_modules/ directories keyed by dependency hash. + * + * The expensive part of each render is `npm install` inside .facet/. + * This cache maintains warmed node_modules/ directories and symlinks + * them into each render's .facet/ dir, so FacetDirectory.needsInstall() + * returns false and npm install is skipped entirely. + * + * Template source files in .facet/src/ are symlinked before each render + * and cleaned up after, so the cached node_modules stays clean. + */ + +import { createHash } from 'crypto'; +import { existsSync, mkdirSync, readdirSync, rmSync, statSync, symlinkSync, readlinkSync, renameSync, cpSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { Logger } from '../utils/logger.js'; + +const CACHE_ROOT = join(tmpdir(), 'facet-cache'); +const MAX_CACHE_ENTRIES = 10; + +/** Compute a short hash from the dependency map for cache keying. */ +export function depHash(deps: Record | undefined): string { + const key = deps ? JSON.stringify(deps, Object.keys(deps).sort()) : '{}'; + return createHash('sha256').update(key).digest('hex').slice(0, 16); +} + +function cacheDir(hash: string): string { + return join(CACHE_ROOT, hash); +} + +/** + * If a cached node_modules exists for this dep hash, symlink it into + * facetRoot so npm install is skipped. Returns true if cache was used. + */ +export function linkCachedNodeModules(facetRoot: string, hash: string, logger: Logger): boolean { + const cached = join(cacheDir(hash), 'node_modules'); + if (!existsSync(cached)) return false; + + const target = join(facetRoot, 'node_modules'); + if (existsSync(target)) { + try { + if (readlinkSync(target) === cached) return true; + rmSync(target, { force: true }); + } catch { + // Not a symlink — real dir from a prior in-place install; remove it. + rmSync(target, { recursive: true, force: true }); + } + } + + symlinkSync(cached, target, 'junction'); + logger.debug(`Linked cached node_modules (${hash})`); + // Touch the cache dir so LRU eviction works + try { statSync(cacheDir(hash)); } catch {} + return true; +} + +/** + * After a fresh npm install, move the newly-created node_modules into + * the cache and symlink it back so subsequent renders reuse it. + */ +export function promoteToCacheAfterInstall(facetRoot: string, hash: string, logger: Logger): void { + const nmPath = join(facetRoot, 'node_modules'); + if (!existsSync(nmPath)) return; + + // Don't promote if it's already a symlink (already cached) + try { readlinkSync(nmPath); return; } catch { /* real dir — promote it */ } + + ensureCacheDir(); + const dest = join(cacheDir(hash), 'node_modules'); + mkdirSync(cacheDir(hash), { recursive: true }); + + // Move node_modules → cache, then symlink back + if (existsSync(dest)) rmSync(dest, { recursive: true, force: true }); + + // Use rename for atomic move (same filesystem) + try { + renameSync(nmPath, dest); + } catch { + // Cross-device: fall back to copy + delete + cpSync(nmPath, dest, { recursive: true }); + rmSync(nmPath, { recursive: true, force: true }); + } + + symlinkSync(dest, nmPath, 'junction'); + logger.debug(`Promoted node_modules to cache (${hash})`); + evictCache(logger); +} + +function ensureCacheDir(): void { + mkdirSync(CACHE_ROOT, { recursive: true }); +} + +/** Evict oldest cache entries when the cache grows too large. */ +function evictCache(logger: Logger): void { + if (!existsSync(CACHE_ROOT)) return; + + const entries = readdirSync(CACHE_ROOT) + .map(name => { + const full = join(CACHE_ROOT, name); + try { return { name, full, mtime: statSync(full).mtimeMs }; } catch { return null; } + }) + .filter((e): e is { name: string; full: string; mtime: number } => e !== null) + .sort((a, b) => b.mtime - a.mtime); + + for (const entry of entries.slice(MAX_CACHE_ENTRIES)) { + logger.debug(`Evicting facet cache: ${entry.name}`); + rmSync(entry.full, { recursive: true, force: true }); + } +} diff --git a/cli/src/server/facet-types.ts b/cli/src/server/facet-types.ts new file mode 100644 index 0000000..daf58af --- /dev/null +++ b/cli/src/server/facet-types.ts @@ -0,0 +1,1782 @@ +export const facetTypes = `declare module '@flanksource/facet' { + import React from 'react'; + + export interface TaskSummary { + theme: string; + status: string; + description: string; + commits?: { + count: number; + additions?: number; + deletions?: number; + }; + notes?: string | React.ReactNode; + achievements?: string[]; + } + + /** + * Common Types + * Shared type definitions used across components + */ + /** + * User Interface + * Represents a user with optional avatar and contact info + */ + export interface User { + id?: string; + name: string; + email?: string; + avatar?: string; + } + /** + * Status Color Type + * Standard color palette for status indicators + */ + export type StatusColor = 'red' | 'green' | 'orange' | 'gray' | 'yellow'; + /** + * Size Type + * Standard size variants for components + */ + export type Size = 'xs' | 'sm' | 'md' | 'lg'; + + /** + * POC Evaluation Data Structure + * Defines the schema for technical POC evaluation data + */ + /** Star rating type (1-5 scale) */ + export type StarRating = 1 | 2 | 3 | 4 | 5; + /** KPI metric types */ + export type KpiType = 'time' | 'percentage' | 'count' | 'custom'; + /** + * KPI Metric Interface + * Represents a measurable key performance indicator + */ + export interface KpiMetric { + /** Numeric value of the metric */ + value: number | string; + /** Type of metric (time, percentage, count, custom) */ + type: KpiType; + /** Unit of measurement (e.g., 'seconds', 'steps', '%') */ + unit?: string; + /** Display value for the metric (optional, for formatting) */ + displayValue?: string; + } + /** + * KPI with Target and Actual for comparison + * Used in completed stage to show goal vs result + */ + export interface KpiComparison { + /** Metric description */ + metric: string; + /** Target/goal value (what was planned) */ + target: KpiMetric; + /** Actual achieved value */ + actual: KpiMetric; + /** Percentage difference (positive = exceeded, negative = fell short) */ + percentageOfTarget?: number; + } + /** + * Test Variant Interface + * Represents a single test execution with before/after measurements + */ + export interface TestVariant { + /** Description of test scenario */ + step: string; + /** Name or role of person who performed test */ + tester: string; + /** KPI measurement before POC */ + beforeKpi: KpiMetric; + /** KPI measurement after POC */ + afterKpi: KpiMetric; + /** Calculated percentage improvement */ + improvement: number; + /** Benefit rating (1-5 stars) */ + rating: StarRating; + /** Qualitative assessment and comments */ + comments: string; + } + /** + * POC Objective Interface + * Represents one of the four evaluation objectives + */ + export interface PocObjective { + /** Unique identifier (e.g., 'tash', 'self-service') */ + id: string; + /** Objective title */ + title: string; + /** Detailed explanation of objective */ + description: string; + /** Icon component name */ + icon: string; + /** Category for grouping objectives */ + category?: string; + /** Key KPI summary for this objective */ + keyKpi: { + /** Metric description */ + metric: string; + /** Before measurement */ + before: KpiMetric; + /** After measurement */ + after: KpiMetric; + /** Calculated percentage improvement */ + improvement?: number; + }; + /** Array of 3-5 test scenarios */ + tests: TestVariant[]; + } + export interface Customer { + name: string; + logoUrl?: string; + } + /** POC lifecycle stage */ + export type PocStage = 'planned' | 'in-progress' | 'completed'; + /** Rating category names for completed POCs */ + export type RatingCategory = 'Technical Feasibility' | 'User Experience' | 'Performance & Scalability' | 'Integration Complexity' | 'Security'; + /** Category rating with 1-5 scale */ + export interface CategoryRating { + category: RatingCategory; + rating: StarRating; + notes?: string; + } + /** Planned stage information */ + export interface PlannedInfo { + goals: string[]; + successCriteria: string[]; + scope: string; + timeline: { + duration: string; + milestones: string[]; + }; + resourcesNeeded: { + team: string[]; + infrastructure: string[]; + dataAccess: string[]; + }; + } + /** In-progress tracking information */ + export interface InProgressInfo { + progressUpdates: { + date: string; + status: string; + blockers?: string[]; + }[]; + observations: string[]; + timelineAdjustments?: string; + } + /** Completed evaluation information */ + export interface CompletedInfo { + categoryRatings: CategoryRating[]; + qualitativeAssessment: { + strengths: string[]; + weaknesses: string[]; + summary: string; + }; + nextSteps: { + decision: 'go' | 'no-go' | 'conditional'; + actionItems: string[]; + recommendations: string[]; + }; + } + /** + * Root POC Evaluation Data Interface + * Contains all four objectives and metadata + */ + export interface PocEvaluationData { + /** Evaluation title */ + title: string; + /** Optional subtitle */ + subtitle?: string; + /** Current date time */ + date: string; + customer: Customer; + /** POC start date */ + startDate: string; + /** POC end date */ + endDate?: string; + /** Test environment description */ + environment: string; + /** Exactly 4 objectives */ + objectives: PocObjective[]; + /** Current POC stage */ + status: PocStage; + /** Planned stage information (present when status is 'planned') */ + plannedInfo?: PlannedInfo; + /** In-progress tracking (present when status is 'in-progress') */ + inProgressInfo?: InProgressInfo; + /** Completed evaluation (present when status is 'completed') */ + completedInfo?: CompletedInfo; + } + /** + * Component Props Interface + */ + export interface PocEvaluationProps { + /** Evaluation data (defaults to sampleEvaluationData if not provided) */ + data?: PocEvaluationData; + /** Show methodology section (default: true) */ + showMethodology?: boolean; + /** Show glossary section (default: true) */ + showGlossary?: boolean; + /** Inline CSS string */ + css: string; + } + + export type AlertSeverity = 'critical' | 'high' | 'medium' | 'low'; + export type AlertType = 'dependabot' | 'code-scanning' | 'secret-scanning'; + export interface Alert { + type: AlertType; + severity: AlertSeverity; + title: string; + url?: string; + location?: string; + references?: Array<{ + label: string; + url: string; + }>; + } + export interface SeverityTrend { + critical: number; + high: number; + medium: number; + low: number; + } + export interface SecurityTrend { + recentlyClosed: number; + recentlyClosedDependabot: number; + recentlyClosedCodeScanning: number; + totalClosed: number; + bySeverity: SeverityTrend; + } + + export function getColorFromString(input: string): string; + + /** + * Format duration from milliseconds to human-readable string + * @param value Duration in milliseconds + * @returns Formatted string like "2d5h30m15s" or "450ms" + */ + export function formatDuration(value: number): string; + /** + * Check if a value is empty (null, undefined, empty string, or empty array) + */ + export function isEmpty(value: any): boolean; + + export interface AgeProps { + className?: string; + from?: Date | string; + to?: Date | string | null; + suffix?: boolean; + } + export function Age({ className, from, to, suffix }: AgeProps): JSX.Element | null; + + import { Alert } from '../types/security'; + interface AlertsTableProps { + alerts: Alert[]; + className?: string; + } + /** + * Displays security alerts in a compact single-line format with ellipsis + */ + export function AlertsTable({ alerts, className }: AlertsTableProps): JSX.Element | null; + + import { User } from '../types/common'; + export interface AvatarProps { + size?: 'xs' | 'sm' | 'md' | 'lg'; + circular?: boolean; + inline?: boolean; + alt?: string; + user?: Partial; + className?: string; + showName?: boolean; + } + export function Avatar({ user, size, alt, inline, circular, className, showName }: AvatarProps): JSX.Element; + + import { User } from '../types/common'; + export interface AvatarGroupProps { + users: Partial[]; + size?: 'xs' | 'sm' | 'md' | 'lg'; + maxCount?: number; + className?: string; + } + export function AvatarGroup({ users, size, maxCount, className }: AvatarGroupProps): JSX.Element; + + /** + * Badge size variant + */ + export type BadgeSize = 'xs' | 'sm' | 'md' | 'lg'; + /** + * Badge shape variant + */ + export type BadgeShape = 'pill' | 'rounded' | 'square'; + /** + * Badge visual variant + */ + export type BadgeVariant = 'status' | 'metric' | 'custom' | 'outlined'; + /** + * Semantic status types for status badges + */ + export type BadgeStatus = 'success' | 'error' | 'warning' | 'info'; + /** + * Props for the Badge component + */ + export interface BadgeProps { + /** + * Visual variant of the badge + * @default 'metric' + */ + variant?: BadgeVariant; + /** + * Semantic status for status variant badges + * Only applicable when variant='status' + */ + status?: BadgeStatus; + /** + * Custom background color (hex or Tailwind class) + * Only applicable when variant='custom' + */ + color?: string; + /** + * Custom text color (hex or Tailwind class) + * Only applicable when variant='custom' + */ + textColor?: string; + /** + * Custom border color (hex or Tailwind class) + * Only applicable when variant='custom' or 'outlined' + */ + borderColor?: string; + /** + * Icon component to display (left-aligned) + * Should be a React component that accepts className prop + */ + icon?: React.ComponentType<{ + className?: string; + }>; + /** + * Label text (left section) + */ + label?: string; + /** + * Value text (right section) + */ + value?: string; + /** + * Size of the badge + * @default 'md' + */ + size?: BadgeSize; + /** + * Shape/border-radius of the badge + * @default 'pill' + */ + shape?: BadgeShape; + /** + * URL for clickable badge (renders as tag) + */ + href?: string; + /** + * Link target attribute + * @default '_self' + */ + target?: '_blank' | '_self' | '_parent' | '_top'; + /** + * Link rel attribute (e.g., 'noopener noreferrer' for external links) + */ + rel?: string; + /** + * Additional CSS classes + */ + className?: string; + } + /** + * Badge component for displaying status indicators, metrics, and labeled values + * + * @example + * // Status badge + * + * + * @example + * // Metric badge with icon + * + * + * @example + * // Custom color badge + * + * + * @example + * // Outlined badge as link + * + */ + export function Badge({ variant, status, color, textColor, borderColor, icon: Icon, label, value, size, shape, href, target, rel, className, }: BadgeProps): JSX.Element; + + interface BulletItem { + term: string | React.ReactNode; + subtitle?: string; + description?: string; + color?: string; + icon?: string | React.ComponentType<{ + className?: string; + style?: React.CSSProperties; + }>; + } + interface BulletGroup { + title: string; + items: BulletItem[]; + } + interface BulletListProps { + items?: BulletItem[]; + groups?: BulletGroup[]; + variant?: 'default' | 'border-left' | 'gradient' | 'icon-left' | 'compact' | 'card'; + columns?: number; + size?: 'xs' | 'sm' | 'md'; + termClass?: string; + descriptionClass?: string; + className?: string; + } + /** + * BulletList Component (DS-17) + * + * Enforces bold lead-in phrase formatting for bullet points with multiple style variants. + * + * Supports three variants: + * - default: Simple list with bold term and description + * - border-left: Styled with left border accent (used in architecture page) + * - gradient: Gradient background with icons (used in component showcases) + * + * DS-17: List items SHOULD start with bold lead-in phrases followed by details + * + * @example Default usage + * \`\`\`tsx + * + * \`\`\` + * + * @example Default with columns + * \`\`\`tsx + * + * \`\`\` + * + * @example Border-left variant (Architecture page style) + * \`\`\`tsx + * + * \`\`\` + * + * @example Gradient variant with groups (Component showcase style) + * \`\`\`tsx + * + * \`\`\` + */ + export function BulletList({ items, groups, variant, columns, size, termClass, descriptionClass, className }: BulletListProps): JSX.Element | null; + + interface CTAButton { + label: string; + url: string; + } + interface CallToActionProps { + primary: CTAButton; + secondary?: CTAButton[]; + audience?: 'enterprise' | 'technical' | 'security'; + } + /** + * CallToAction Component (DS-25, DS-26) + * + * Displays a clear next step with primary CTA and optional secondary actions. + * Provides audience-specific default CTAs. + * + * DS-25: Must include clear next steps + * DS-26: Match CTA to audience + * + * Default CTAs by audience: + * - enterprise: "Request Enterprise Trial" + "Talk to Sales" + * - technical: "Start Free Trial" + "View Documentation" + * - security: "Download Security Whitepaper" + "Schedule Demo" + * + * Usage: + * + */ + export function CallToAction({ primary, secondary, audience }: CallToActionProps): JSX.Element; + + /** + * CalloutBox Props + */ + export interface CalloutBoxProps { + /** Content to display inside the callout */ + children: React.ReactNode; + /** Visual variant of the callout */ + variant?: 'info' | 'warning' | 'success' | 'default'; + /** Optional title for the callout */ + title?: string; + /** Optional CSS class name */ + className?: string; + } + /** + * CalloutBox Component + * + * Displays an emphasized callout box with different visual styles. + * Useful for highlighting important information, warnings, or tips. + * + * @example + * \`\`\`tsx + * + * This is important information that users should know. + * + * \`\`\` + */ + export function CalloutBox({ children, variant, title, className, }: CalloutBoxProps): JSX.Element; + + interface Feature { + title: string; + description: string; + icon?: React.ReactNode; + } + interface CapabilitySectionProps { + outcome: string; + features: Feature[]; + icon?: React.ReactNode; + } + /** + * CapabilitySection Component (DS-5, DS-17) + * + * @deprecated Use Section component with variant="hero" and BulletList children instead: + *
+ * + *
+ * + * Displays a capability section leading with customer outcome, followed by features. + * Enforces outcome-first structure and bold lead-in formatting. + * + * DS-5: Lead with outcomes, not features + * DS-17: Use bold lead-in text followed by description + * + * Usage: + * + */ + export function CapabilitySection({ outcome, features, icon }: CapabilitySectionProps): JSX.Element; + + export type TableSize = 'xs' | 'sm' | 'base' | 'md'; + interface CompactTableRow { + label: string; + value: string | string[]; + } + interface CompactTableProps { + data?: CompactTableRow[] | (string | React.ReactNode)[][]; + variant?: 'compact' | 'inline' | 'reference'; + title?: string; + className?: string; + columns?: string[]; + size?: TableSize; + } + export function CompactTable({ data, variant, title, className, columns, size, }: CompactTableProps): JSX.Element; + + interface ComparisonTableProps { + pros: string[]; + cons: string[]; + prosTitle?: string; + consTitle?: string; + } + /** + * ComparisonTable Component + * + * Displays a two-column comparison table with pros/cons or advantages/disadvantages. + * Based on the design from docs/index.mdx with checkmarks and crosses. + */ + export function ComparisonTable({ pros, cons, prosTitle, consTitle }: ComparisonTableProps): JSX.Element; + + export interface CountBadgeProps { + value?: number; + size?: 'xs' | 'sm' | 'md'; + title?: string; + className?: string; + colorClass?: string; + roundedClass?: string; + } + export const CountBadge: React.NamedExoticComponent; + + export interface PageConfig { + content: React.ReactNode; + title?: string; + product?: string; + margins?: boolean; + } + export interface DatasheetTemplateProps { + pages?: PageConfig[]; + title?: string; + css?: string; + subtitle?: string; + PageComponent?: React.ComponentType; + children?: React.ReactNode; + } + export function DatasheetTemplate({ pages, title, subtitle, css, PageComponent, children, }: DatasheetTemplateProps): JSX.Element; + + interface Stat { + value: string; + label: string; + icon?: React.ComponentType<{ + className?: string; + style?: React.CSSProperties; + }>; + } + interface BulletPoint { + term: string; + description?: string; + icon?: React.ComponentType<{ + className?: string; + style?: React.CSSProperties; + }>; + color?: string; + } + interface FeatureLayoutProps { + title: string; + description: string; + bullets: BulletPoint[]; + span?: number; + stats: Stat[]; + children: React.ReactNode; + direction?: 'left-right' | 'right-left'; + className?: string; + } + /** + * FeatureLayout Component + * + * Displays a feature comparison with title, description, bullets with icons/checkmarks, + * stats with outlined icons, and a content area (e.g., AI response example). + * + * Layout: Two-column grid with flexible direction (left-right or right-left) + * Left content: Title, description, bullets with custom icons/colors, stats with icons + * Right content: Children (typically QueryResponseExample or similar) + * + * Bullet Points: + * - Default: Green checkmark (✓) + * - Custom icon: Pass icon component and optional color + * - Icon size: 12pt, aligned left + * + * Usage: + * + * + * + */ + export function FeatureLayout({ title, description, bullets, stats, children, direction, span, className }: FeatureLayoutProps): JSX.Element; + + interface FooterProps { + variant?: 'default' | 'compact' | 'minimal'; + type?: PageType; + height?: number; + company?: string; + copyright?: string; + web?: string; + docs?: string; + email?: string; + phone?: string; + github?: string; + linkedin?: string; + children?: React.ReactNode; + } + export function Footer({ variant, type, height, company, copyright, web, docs, email, phone, github, linkedin, children, }: FooterProps): JSX.Element; + + export interface GaugeProps { + value: number; + units?: string; + minValue: number; + maxValue: number; + width?: string; + arcColor?: string; + arcBgColor?: string; + label?: React.ReactNode; + showMinMax?: boolean; + } + export const Gauge: FC; + + /** + * GlossaryTerm Interface + */ + export interface GlossaryTerm { + /** Term name/abbreviation */ + term: string; + /** Definition of the term */ + definition: string; + } + /** + * RatingScaleItem Interface + */ + export interface RatingScaleItem { + /** Star rating value */ + stars: number; + /** Meaning/description of the rating */ + meaning: string; + } + /** + * LegendItem Interface + */ + export interface LegendItem { + /** Example value to display */ + example: string; + /** Description of what the example represents */ + description: string; + } + /** + * GlossarySection Interface + */ + export interface GlossarySection { + /** Section title */ + title: string; + /** Section content - can be terms, ratings, or legend items */ + content: React.ReactNode; + } + /** + * Glossary Props + */ + export interface GlossaryProps { + /** Main title for the glossary section */ + title?: string; + /** Array of glossary terms */ + terms?: GlossaryTerm[]; + /** Array of rating scale items with visual component */ + ratingScale?: RatingScaleItem[]; + /** Component to render star ratings */ + StarRatingComponent?: React.ComponentType<{ + rating: number; + }>; + /** Array of legend items (format examples) */ + legend?: LegendItem[]; + /** Legend section title */ + legendTitle?: string; + /** Custom sections to add */ + customSections?: GlossarySection[]; + /** Number of columns for layout (1 or 2) */ + columns?: 1 | 2; + /** Optional CSS class name */ + className?: string; + } + /** + * Glossary Component + * + * Displays a glossary section with terms, rating scales, and format legends. + * Flexible component that can display technical terms, rating explanations, + * and format examples in a multi-column layout. + * + * @example + * \`\`\`tsx + * + * \`\`\` + */ + export function Glossary({ title, terms, ratingScale, StarRatingComponent, legend, legendTitle, customSections, columns, className, }: GlossaryProps): JSX.Element; + + export type PageType = 'first' | 'default' | 'last'; + interface HeaderProps { + variant?: 'default' | 'solid' | 'minimal'; + logo?: React.ReactNode; + title?: string; + subtitle?: string; + type?: PageType; + height?: number; + children?: React.ReactNode; + } + export function Header({ variant, logo, title, subtitle, type, height, children, }: HeaderProps): JSX.Element; + + /** + * @deprecated Use LogoGrid instead. IntegrationGrid is an alias for backward compatibility. + */ + + import { KpiComparison } from '../types/poc'; + interface KPITargetActualProps { + kpi: KpiComparison; + className?: string; + } + export function KPITargetActual({ kpi, className }: KPITargetActualProps): JSX.Element; + + /** + * KPI Value Interface + */ + export interface KpiValue { + /** Numeric value */ + value: number | string; + /** Display formatted value */ + displayValue: string; + /** Unit of measurement */ + unit?: string; + } + /** + * KpiComparison Props + */ + export interface KpiComparisonProps { + /** Before metric value */ + before: KpiValue; + /** After metric value */ + after: KpiValue; + /** Percentage improvement (positive = better) */ + improvement?: number; + /** Label for the metric */ + label?: string; + /** Format type for visualization */ + format?: 'time' | 'percentage' | 'count' | 'custom'; + /** Show summary in top right */ + showSummary?: boolean; + /** Show improvement arrow/indicator */ + showImprovement?: boolean; + } + /** + * KpiComparison Component + * + * Displays before/after KPI comparison with visual bar chart. + * Useful for showing performance improvements or metric changes. + * + * @example + * \`\`\`tsx + * + * \`\`\` + */ + export function KpiComparison({ before, after, improvement, label, showSummary, showImprovement, }: KpiComparisonProps): JSX.Element; + + interface FeatureSupport { + enabled: boolean; + url?: string; + } + interface Logo { + name: string; + title?: string; + icon?: string | React.ComponentType<{ + className?: string; + title?: string; + }>; + icons?: Array>; + logo?: string; + logoSvg?: string; + url?: string; + urlPath?: string; + health?: FeatureSupport | boolean; + configuration?: FeatureSupport | boolean; + change?: FeatureSupport | boolean; + playbooks?: FeatureSupport | boolean; + } + interface LogoGridProps { + logos: Logo[]; + viewAllUrl?: string; + title?: string; + variant?: 'default' | 'compact' | 'table'; + baseDocsUrl?: string; + size?: TableSize; + } + export function LogoGrid({ logos, viewAllUrl, title, variant, baseDocsUrl, size, }: LogoGridProps): JSX.Element; + + /** + * Metric Interface + * Represents a single metric value with label + */ + export interface Metric { + /** Numeric or string value to display */ + value: string | number; + /** Label/description of the metric */ + label: string; + /** Optional icon component */ + icon?: React.ComponentType<{ + className?: string; + }>; + /** Optional color variant for value text */ + valueColor?: 'blue' | 'green' | 'red' | 'gray' | 'yellow'; + } + /** + * MetricGrid Props + */ + export interface MetricGridProps { + /** Array of metrics to display */ + metrics: Metric[]; + /** Number of columns in grid (2-4) */ + columns?: 2 | 3 | 4; + /** Optional CSS class name */ + className?: string; + } + /** + * MetricGrid Component + * + * Displays a grid of metrics with consistent styling. + * Commonly used for dashboard-style metric displays. + * + * @example + * \`\`\`tsx + * + * \`\`\` + */ + export function MetricGrid({ metrics, columns, className }: MetricGridProps): JSX.Element; + + /** + * KPI Value Interface + */ + export interface KpiValue { + /** Numeric or string value */ + value: number; + /** Optional unit of measurement */ + unit?: string; + } + /** + * MetricHeaderProps - Base Props + */ + interface MetricHeaderBaseProps { + /** Metric title/label */ + title: string; + /** Optional subtitle/description */ + subtitle?: string; + /** Optional CSS class name */ + className?: string; + } + interface MetricHeaderGaugeProps extends MetricHeaderBaseProps { + variant: 'gauge'; + score: number; + maxScore?: number; + } + /** + * MetricHeaderProps - Comparison Variant + */ + interface MetricHeaderComparisonProps extends MetricHeaderBaseProps { + /** Variant type */ + variant: 'comparison'; + /** Before measurement */ + before: KpiValue; + /** After measurement */ + after: KpiValue; + /** Percentage improvement */ + improvement?: number; + /** Show visual bars */ + showBars?: boolean; + /** Optional comparison component */ + ComparisonComponent?: React.ComponentType<{ + before: KpiValue; + after: KpiValue; + improvement?: number; + }>; + } + export type MetricHeaderProps = MetricHeaderGaugeProps | MetricHeaderComparisonProps; + /** + * MetricHeader Component + * + * Displays a metric header with either a gauge (score) or comparison (before/after) variant. + * Used for highlighting key metrics at the top of sections. + * + * @example Gauge Variant + * \`\`\`tsx + * + * \`\`\` + * + * @example Comparison Variant + * \`\`\`tsx + * + * \`\`\` + */ + export function MetricHeader(props: MetricHeaderProps): JSX.Element | null; + + interface Metric { + value: string; + label: string; + icon?: string | React.ComponentType<{ + className?: string; + style?: React.CSSProperties; + }>; + } + interface MetricsCalloutProps { + metrics: Metric[]; + variant?: 'primary' | 'secondary'; + } + /** + * MetricsCallout Component (DS-14) + * + * Displays 3-5 key metrics in a visually prominent boxed layout. + * Metrics should use percentages, time units, or counts with proper formatting. + * + * Formatting rules (DS-14): + * - Percentages: Bold, 14-16pt, brand color + * - Time metrics: Include units (hours, minutes, days) + * - Large numbers: Use thousands separator (45,234) + * + * Icon support: + * - Unplugin icons: Pass React component directly (e.g., IconTime from '~icons/ion/time-outline') + * - Iconify icons: Pass string like "ion:alert-circle-outline" + * - Flanksource icons: Pass string like "postgres" or "kubernetes" + * - Emoji/text: Pass any string like "⏱️" or "✓" + * + * Usage: + * + */ + export function MetricsCallout({ metrics, variant }: MetricsCalloutProps): JSX.Element; + + export type PageSize = 'a4' | 'a3' | 'letter' | 'legal' | 'fhd' | 'qhd' | 'wqhd' | '4k' | '5k' | '16k'; + interface PageMargins { + top?: number; + right?: number; + bottom?: number; + left?: number; + } + interface PageProps { + children: React.ReactNode; + title?: string; + product?: string; + className?: string; + pageSize?: PageSize; + margins?: PageMargins; + watermark?: string; + type?: PageType; + } + export function Page({ children, title, product, className, pageSize, margins, watermark, type, }: PageProps): JSX.Element; + + /** + * PageBreak Component + * + * Inserts a page break for PDF generation and print layouts. + * Uses CSS page-break-after property to ensure content after this component + * starts on a new page. + * + * @example + * \`\`\`tsx + * + * + * + * + * + * + * + * \`\`\` + */ + export function PageBreak(): JSX.Element; + + /** + * ProgressBar Props + */ + export interface ProgressBarProps { + /** Title/label displayed on the left */ + title: string; + /** Percentage value (0-100) */ + percentage: number; + /** Optional subtitle/description below title */ + subtitle?: string; + /** Optional display value instead of percentage */ + displayValue?: string; + /** Color variant for the progress bar */ + variant?: 'primary' | 'success' | 'warning' | 'danger' | 'info' | 'gray'; + /** Size of the progress bar */ + size?: 'sm' | 'md' | 'lg'; + /** Show percentage value inside the bar */ + showPercentageInBar?: boolean; + /** Show percentage value on the right side */ + showPercentageLabel?: boolean; + /** Optional CSS class name */ + className?: string; + choldren?: React.ReactNode; + } + /** + * ProgressBar Component + * + * Displays a horizontal progress bar with title on the left and optional percentage display. + * Useful for showing completion status, scores, or any percentage-based metric. + * + * @example Basic Usage + * \`\`\`tsx + * + * \`\`\` + * + * @example With Subtitle + * \`\`\`tsx + * + * \`\`\` + * + * @example Multiple Progress Bars + * \`\`\`tsx + *
+ * + * + * + *
+ * \`\`\` + */ + export function ProgressBar({ title, percentage, displayValue, subtitle, variant, size, showPercentageInBar, showPercentageLabel, className, children }: ProgressBarProps): JSX.Element; + + interface ProjectSummaryCardProps { + icon?: React.ComponentType<{ + className?: string; + }>; + name: string; + description: string; + security: any; + githubUrl: string; + className?: string; + } + /** + * Compact summary card for additional projects showing key security metrics + */ + export function ProjectSummaryCard({ icon: Icon, name, description, security, githubUrl, className }: ProjectSummaryCardProps): JSX.Element; + + export interface MCPToolCall { + tool: string; + description: string; + result?: string; + } + export interface QueryResponseChatProps { + userQuery: string; + mcpTools?: MCPToolCall[]; + aiResponse: string; + compact?: boolean; + } + export function QueryResponseChat({ userQuery, mcpTools, aiResponse, compact }: QueryResponseChatProps): JSX.Element; + + export interface MCPToolCall { + tool: string; + description: string; + result?: string; + } + interface QueryResponseExampleProps { + userQuery: string; + mcpTools?: MCPToolCall[]; + aiResponse: string; + variant?: 'chat' | 'terminal' | 'both'; + compact?: boolean; + title?: string; + } + /** + * QueryResponseExample Component + * + * Wrapper component for displaying AI query/response interactions. + * Supports multiple visualization styles: chat bubbles, terminal, or both. + * Can show or hide MCP tool calls via compact mode. + * + * Usage: + * + */ + export function QueryResponseExample({ userQuery, mcpTools, aiResponse, variant, compact, title }: QueryResponseExampleProps): JSX.Element; + + interface MCPToolCall { + tool: string; + description: string; + result?: string; + } + interface QueryResponseTerminalProps { + userQuery: string; + mcpTools?: MCPToolCall[]; + aiResponse: string; + compact?: boolean; + } + /** + * QueryResponseTerminal Component + * + * Displays AI query/response interaction in Claude Code terminal style. + * Uses monospace font with dark background (matching Claude Code's theme). + * User queries prefixed with command prompt. + * MCP tool calls shown with execution indicators. + * Styled to match Claude Code's integrated terminal aesthetic. + * + * Usage: + * + */ + export function QueryResponseTerminal({ userQuery, mcpTools, aiResponse, compact }: QueryResponseTerminalProps): JSX.Element; + + import { CategoryRating } from '../types/poc'; + interface RatingCategoryInputProps { + rating: CategoryRating; + className?: string; + } + export function RatingCategoryInput({ rating, className }: RatingCategoryInputProps): JSX.Element; + + interface ScoreGaugeProps { + score: number; + label?: string; + size?: 'sm' | 'md' | 'lg'; + className?: string; + showMinMax?: boolean; + } + /** + * Circular gauge displaying OpenSSF Scorecard score (0-10) + * Color-coded: 0-4 (red), 4-7 (yellow), 7-10 (green) + */ + export function ScoreGauge({ score, label, size, className, showMinMax }: ScoreGaugeProps): JSX.Element; + + interface SectionProps { + title?: string; + subtitle?: string; + icon?: React.ComponentType<{ + className?: string; + style?: React.CSSProperties; + }>; + description?: string; + metric?: React.ReactNode; + variant?: 'hero' | 'card-grid' | 'two-column' | 'two-column-reverse' | 'metric-grid' | 'summary-grid' | 'dashboard'; + size?: 'sm' | 'md' | 'lg'; + color?: string; + className?: string; + span?: number; + columns?: 2 | 3 | 4; + layout?: '9-3' | '10-2' | '8-4' | '6-6'; + children?: React.ReactNode; + } + /** + * Section Component + * + * Unified component for various section layouts in datasheets. + * Replaces ValueProposition and CapabilitySection with flexible variant system. + * + * Variants: + * - 'hero': Single-column full-width layout for headers and value propositions + * - 'two-column': Left content + right metric sidebar (configurable span or layout) + * - 'two-column-reverse': Right content + left metric sidebar + * - 'card-grid': Header with grid of cards below (2 columns) + * - 'metric-grid': Header with grid of metric cards below (2-4 columns) + * - 'summary-grid': Header with grid of entity/project cards below (2-3 columns) + * - 'dashboard': Title/subtitle left, metric right, description/children below + * + * Size variants control typography and spacing: + * - sm: 12pt title, 2mm gaps + * - md: 14pt title, 4mm gaps (default) + * - lg: 18pt title, 6mm gaps + * + * Layout shortcuts for two-column variants: + * - '9-3': 9 columns content, 3 columns metric + * - '10-2': 10 columns content, 2 columns metric + * - '8-4': 8 columns content, 4 columns metric + * - '6-6': 6 columns content, 6 columns metric + * + * Usage: + *
+ * + * + * + *
+ */ + export function Section({ title, subtitle, icon: IconComponent, description, metric, variant, size, color, className, span, columns, layout, children }: SectionProps): JSX.Element; + + interface SecurityCheck { + name: string; + score: number; + reason: string; + details?: string[]; + documentation?: { + url: string; + }; + } + interface SecurityChecksTableProps { + checks: SecurityCheck[]; + className?: string; + size?: TableSize; + } + export function SecurityChecksTable({ checks, className, size, }: SecurityChecksTableProps): JSX.Element; + + interface SeverityStatCardProps { + color: 'red' | 'orange' | 'yellow' | 'blue' | 'green' | 'gray'; + value: number; + label: string; + trend?: { + added: number; + closed: number; + delta: number; + }; + downIsGood?: boolean; + className?: string; + } + /** + * Severity stat card component with optional trend indicator + */ + export function SeverityStatCard({ color, value, label, trend, downIsGood, className }: SeverityStatCardProps): JSX.Element; + + interface CustomerLogo { + name: string; + logo: string; + } + interface Testimonial { + quote: string; + attribution: string; + metric?: string; + } + interface SocialProofProps { + logos?: CustomerLogo[]; + testimonial?: Testimonial; + } + /** + * SocialProof Component (DS-24) + * + * Displays customer logos or a brief testimonial. + * Choose ONE format: logo bar OR testimonial, not both. + * + * DS-24: Include one of: + * - Customer logo bar (6-8 logos maximum) + * - Brief customer quote (1-2 sentences) + * - Case study reference with metric + * + * MUST NOT include: + * - Long testimonials + * - More than one quote + * - Generic quotes without specific outcomes + * + * Usage (Logos): + * + * + * Usage (Testimonial): + * + */ + export function SocialProof({ logos, testimonial }: SocialProofProps): JSX.Element; + + interface Specification { + category: string; + value: string | string[]; + } + interface SpecificationTableProps { + title?: string; + specifications: Specification[]; + className?: string; + size?: TableSize; + } + export function SpecificationTable({ title, specifications, className, size }: SpecificationTableProps): JSX.Element; + + type StarRating = 1 | 2 | 3 | 4 | 5; + interface StarRatingProps { + mode?: 'display' | 'interactive'; + value: StarRating; + onChange?: (rating: StarRating) => void; + size?: 'sm' | 'md' | 'lg'; + showLabel?: boolean; + className?: string; + } + export function StarRating({ mode, value, onChange, size, showLabel, className, }: StarRatingProps): JSX.Element; + + interface StatCardProps { + value: UnitValue | string | number; + compareFrom?: UnitValue | string | number; + label: string; + icon?: React.ComponentType<{ + className?: string; + style?: React.CSSProperties; + }>; + variant?: 'card' | 'badge' | 'hero' | 'bordered' | 'icon-heavy' | 'left-aligned' | 'metric'; + compareVariant?: 'trendline' | 'up-down' | 'before-after' | 'before-after-progress'; + size?: 'sm' | 'md' | 'lg'; + valueClassName?: string; + iconClassName?: string; + iconColor?: string; + valueColor?: string; + sublabel?: React.ReactNode; + sublabelClassName?: string; + conditionalStyles?: ConditionalStyle[]; + color?: 'blue' | 'green' | 'purple' | 'orange' | 'red' | 'gray'; + } + export type ConditionalStyle = ConditionalStyleFunction | 'red-green' | 'green-red'; + export interface ConditionalStyleFunction { + condition: (value: UnitValue) => boolean; + classes: string; + } + export type TimeUnit = 'milliseconds' | 'seconds' | 'minutes' | 'hours' | 'days' | 'weeks' | 'months' | 'years'; + export type DataUnit = 'bytes' | 'kilobytes' | 'megabytes' | 'gigabytes' | 'terabytes'; + export type NumberUnit = 'none' | 'percent' | 'currency'; + export type Unit = TimeUnit | DataUnit | NumberUnit; + export type UnitValue = TimeUnitValue | DataUnitValue | NumberUnitValue; + export class TimeUnitValue { + value: number; + unit: TimeUnit; + constructor(value: number, unit: TimeUnit); + toUnit(targetUnit: TimeUnit): TimeUnitValue; + seconds(): number; + toString(): string; + } + export class DataUnitValue { + value: number; + unit: DataUnit; + constructor(value: number, unit: DataUnit); + toUnit(targetUnit: DataUnit): DataUnitValue; + bytes(): number; + toString(): string; + } + export class NumberUnitValue { + value: number; + unit: NumberUnit; + constructor(value: number, unit: NumberUnit); + toString(): string; + } + /** + * StatCard Component + * + * Displays a metric with optional icon, value, label, and sublabel. + * Supports multiple styling variants for different use cases. + * + * Variants: + * - 'card': Clean unbordered card style (40mm × 40mm, outlined icon) + * - 'badge': Compact inline badge (light blue background) + * - 'hero': Large emphasis metric (36-48pt values) + * - 'bordered': Bordered card variant (for when borders are needed) + * - 'icon-heavy': Large icon with overlaid value badge + * - 'left-aligned': Icon on left, value and label stacked on right + * - 'metric': Summary metric card with colored background and border + * + * Compare Variants (when compareFrom is provided): + * - 'trendline': Show trend icon and delta + * - 'up-down': Show up/down arrows with conditional coloring + * - 'before-after': Show "X → Y" format + * - 'before-after-progress': Show before → after with progress bar + * + * Usage: + * + */ + export function StatCard({ value, label, icon: IconComponent, variant, size, iconColor, valueColor, sublabel, compareFrom, compareVariant, color, conditionalStyles, valueClassName, iconClassName, sublabelClassName }: StatCardProps): JSX.Element; + + export interface StatusProps { + good?: boolean; + mixed?: boolean; + status?: string; + className?: string; + statusText?: string; + hideText?: boolean; + } + export function Status({ status, statusText, good, mixed, className, hideText }: StatusProps): JSX.Element | null; + + export interface Step { + label: string; + description?: string; + } + interface StepsProps { + steps: Step[]; + currentStep: number; + className?: string; + } + export function Steps({ steps, currentStep, className }: StepsProps): JSX.Element; + + interface SyntaxHighlighterProps { + code: string; + language?: string; + title?: string; + showLineNumbers?: boolean; + className?: string; + } + /** + * SyntaxHighlighter component using Shiki + * + * Pre-renders syntax-highlighted code blocks during SSR build. + * Uses Shiki to generate static HTML with embedded styles. + * + * @param code - The code to highlight + * @param language - Programming language (default: 'yaml') + * @param title - Optional title displayed above code block + * @param showLineNumbers - Show line numbers (default: false) + * @param className - Additional CSS classes + */ + export function SyntaxHighlighter({ code, language, title, showLineNumbers, className }: SyntaxHighlighterProps): JSX.Element; + + import { TaskSummary } from '../types/billing'; + interface TaskSummarySectionProps { + tasks: TaskSummary[]; + } + export function TaskSummarySection({ tasks }: TaskSummarySectionProps): JSX.Element | null; + + interface TerminalOutputProps { + command: string; + children?: React.ReactNode; + } + export function TerminalOutput({ command, children }: TerminalOutputProps): JSX.Element; + + interface TwoColumnSectionProps { + leftContent: React.ReactNode; + rightContent: React.ReactNode; + className?: string; + } + /** + * TwoColumnSection Component (DS-2) + * + * Multi-column layout for content below the fold. + * Automatically stacks on small screens for responsive design. + * + * DS-2: Single-column layout above fold, multi-column below fold + * + * Usage: + * } + * rightContent={} + * /> + */ + export function TwoColumnSection({ leftContent, rightContent, className }: TwoColumnSectionProps): JSX.Element; + + interface ValuePropositionProps { + tagline: string; + description: string; + children?: React.ReactNode; + } + /** + * ValueProposition Component (DS-1, DS-2) + * + * @deprecated Use Section component with variant="hero" instead: + *
+ * + *
+ * + * Single-column hero section displaying product tagline and core value proposition. + * Must appear above the fold, before any multi-column content. + * + * Usage: + * + * + * + */ + export function ValueProposition({ tagline, description, children }: ValuePropositionProps): JSX.Element; + + interface VulnerabilityData { + dependabot: any[]; + codeScanning: any[]; + secretScanning: any[]; + totalCount: number; + severity: { + critical: number; + high: number; + medium: number; + low: number; + }; + trend?: { + recentlyAdded: number; + recentlyClosed: number; + recentlyClosedDependabot: number; + recentlyClosedCodeScanning: number; + totalClosed: number; + addedBySeverity: { + critical: number; + high: number; + medium: number; + low: number; + }; + closedBySeverity: { + critical: number; + high: number; + medium: number; + low: number; + }; + deltaBySeverity: { + critical: number; + high: number; + medium: number; + low: number; + }; + }; + error?: string; + } + interface VulnerabilityBreakdownProps { + data: VulnerabilityData; + projectName: string; + githubUrl: string; + className?: string; + } + /** + * Displays GitHub security alerts breakdown with severity categorization + */ + export function VulnerabilityBreakdown({ data, projectName, githubUrl, className }: VulnerabilityBreakdownProps): JSX.Element; + +} +`; diff --git a/cli/src/server/playground-html.ts b/cli/src/server/playground-html.ts new file mode 100644 index 0000000..ffecdd2 --- /dev/null +++ b/cli/src/server/playground-html.ts @@ -0,0 +1,421 @@ +const HTML = ` + + + + + Facet Playground + + + +
+ Facet Playground +
+ + + + +
+ +
+ + +
+
+
+
+ + +
+
+ +
+
+
+
Click "Render" to preview your template
+
+
+
+ Render Log + + + +
+
+
+
+
+ + + + + +`; + +export function playgroundHtml(facetVersion: string = 'latest'): string { + return HTML.replace('__FACET_VERSION__', facetVersion); +} diff --git a/cli/src/server/preview.ts b/cli/src/server/preview.ts index d963189..0df5c3d 100644 --- a/cli/src/server/preview.ts +++ b/cli/src/server/preview.ts @@ -6,7 +6,13 @@ import { errorResponse } from './errors.js'; import { WorkerPool } from './worker-pool.js'; import { discoverTemplates, type TemplateInfo } from './templates.js'; import { S3Uploader } from './s3.js'; -import { handleHealthz, handleTemplates, handleRender } from './routes.js'; +import { handleHealthz, handleTemplates, handleRender, handleRenderStream } from './routes.js'; +import { playgroundHtml } from './playground-html.js'; +import { facetTypes } from './facet-types.js'; +import { readFileSync } from 'fs'; +import rootPackageJson from '../../../package.json' with { type: 'file' }; + +const facetVersion: string = JSON.parse(readFileSync(rootPackageJson, 'utf-8')).version; export interface ServerHandle { port: number; @@ -34,6 +40,14 @@ export async function createServer(config: ServerConfig): Promise async fetch(request: Request): Promise { const url = new URL(request.url); + if (url.pathname === '/' && request.method === 'GET') { + return new Response(playgroundHtml(facetVersion), { headers: { 'content-type': 'text/html; charset=utf-8' } }); + } + + if (url.pathname === '/types' && request.method === 'GET') { + return new Response(facetTypes, { headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'public, max-age=3600' } }); + } + if (url.pathname === '/healthz' && request.method === 'GET') { return handleHealthz(pool); } @@ -45,6 +59,10 @@ export async function createServer(config: ServerConfig): Promise return handleTemplates(templates); } + if (url.pathname === '/render/stream' && request.method === 'POST') { + return handleRenderStream(request, config, pool, templates, s3); + } + if (url.pathname === '/render' && request.method === 'POST') { return handleRender(request, config, pool, templates, s3); } diff --git a/cli/src/server/render-stream.ts b/cli/src/server/render-stream.ts new file mode 100644 index 0000000..2ba38c4 --- /dev/null +++ b/cli/src/server/render-stream.ts @@ -0,0 +1,98 @@ +/** + * Server-Sent Events streaming for render progress. + * The playground POSTs to /render/stream, which returns an SSE stream + * with progress events, then the final result. + */ + +export type RenderStage = + | 'parsing' + | 'resolving' + | 'installing' + | 'building' + | 'tailwind' + | 'rendering-html' + | 'rendering-pdf' + | 'uploading' + | 'done' + | 'error'; + +export interface ProgressEvent { + stage: RenderStage; + message: string; + elapsed?: number; +} + +export class RenderProgress { + private controller: ReadableStreamDefaultController | null = null; + private startTime = Date.now(); + + static create(): { progress: RenderProgress; stream: ReadableStream } { + let ctrl!: ReadableStreamDefaultController; + const textStream = new ReadableStream({ + start(controller) { + ctrl = controller; + }, + }); + + const progress = new RenderProgress(); + progress.controller = ctrl; + + const encoder = new TextEncoder(); + const byteStream = new ReadableStream({ + async start(controller) { + const reader = textStream.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + controller.enqueue(encoder.encode(value)); + } + controller.close(); + } catch { + controller.close(); + } + }, + }); + + return { progress, stream: byteStream }; + } + + emit(stage: RenderStage, message: string): void { + if (!this.controller) return; + const event: ProgressEvent = { + stage, + message, + elapsed: Date.now() - this.startTime, + }; + try { + this.controller.enqueue(`data: ${JSON.stringify(event)}\n\n`); + } catch { + // stream closed + } + } + + emitResult(contentType: string, data: string): void { + if (!this.controller) return; + try { + this.controller.enqueue( + `event: result\ndata: ${JSON.stringify({ contentType, data })}\n\n`, + ); + } catch {} + } + + emitError(message: string): void { + if (!this.controller) return; + try { + this.controller.enqueue( + `event: error\ndata: ${JSON.stringify({ message })}\n\n`, + ); + } catch {} + } + + close(): void { + try { + this.controller?.close(); + } catch {} + this.controller = null; + } +} diff --git a/cli/src/server/request.ts b/cli/src/server/request.ts index 6a4b927..f2cf1ae 100644 --- a/cli/src/server/request.ts +++ b/cli/src/server/request.ts @@ -5,7 +5,8 @@ import type { BufferPDFOptions } from '../utils/pdf-generator.js'; export type TemplateSource = | { kind: 'local'; name: string } | { kind: 'remote'; template: string } - | { kind: 'archive'; data: Buffer; entryFile?: string }; + | { kind: 'archive'; data: Buffer; entryFile?: string } + | { kind: 'inline'; code: string }; export interface ParsedRenderRequest { source: TemplateSource; @@ -15,6 +16,7 @@ export interface ParsedRenderRequest { s3Key?: string; filename?: string; pdfOptions?: BufferPDFOptions; + dependencies?: Record; } export async function parseRenderRequest( @@ -44,18 +46,22 @@ async function parseJsonRequest(request: Request): Promise throw new RenderError('INVALID_REQUEST', 'Invalid JSON body', 400); } + const code = body.code; const template = body.template; - if (typeof template !== 'string' || !template) { - throw new RenderError('INVALID_REQUEST', 'Missing required field: template', 400); + + if (typeof code !== 'string' && (typeof template !== 'string' || !template)) { + throw new RenderError('INVALID_REQUEST', 'Missing required field: template or code', 400); } const data = (body.data ?? {}) as Record; const format = (body.format as string) === 'html' ? 'html' as const : 'pdf' as const; const output = (body.output as string) === 's3' ? 's3' as const : 'direct' as const; - const source: TemplateSource = parseRemoteRef(template) - ? { kind: 'remote', template } - : { kind: 'local', name: template }; + const source: TemplateSource = typeof code === 'string' + ? { kind: 'inline', code } + : parseRemoteRef(template as string) + ? { kind: 'remote', template: template as string } + : { kind: 'local', name: template as string }; return { source, @@ -65,9 +71,19 @@ async function parseJsonRequest(request: Request): Promise s3Key: body.s3Key as string | undefined, filename: body.filename as string | undefined, pdfOptions: parsePDFOptions(body.pdfOptions as Record | undefined), + dependencies: parseDependencies(body.dependencies), }; } +function parseDependencies(raw: unknown): Record | undefined { + if (!raw || typeof raw !== 'object') return undefined; + const deps: Record = {}; + for (const [k, v] of Object.entries(raw as Record)) { + if (typeof v === 'string') deps[k] = v; + } + return Object.keys(deps).length > 0 ? deps : undefined; +} + async function parseMultipartRequest( request: Request, maxUploadSize: number, @@ -159,5 +175,7 @@ function parsePDFOptions(raw?: Record): BufferPDFOptions | unde if (!raw) return undefined; return { landscape: raw.landscape as boolean | undefined, + debug: raw.debug as boolean | undefined, + defaultPageSize: raw.defaultPageSize as string | undefined, }; } diff --git a/cli/src/server/routes.ts b/cli/src/server/routes.ts index 375ff9b..b31b5c3 100644 --- a/cli/src/server/routes.ts +++ b/cli/src/server/routes.ts @@ -1,7 +1,7 @@ import { mkdtemp, cp, writeFile, readFile } from 'fs/promises'; import { join, basename } from 'path'; import { tmpdir } from 'os'; -import { rmSync } from 'fs'; +import { rmSync, lstatSync } from 'fs'; import { $ } from 'bun'; import type { ServerConfig } from './config.js'; @@ -19,6 +19,7 @@ import { buildTemplate } from '../bundler/vite-builder.js'; import { combineHTMLAndCSS } from '../bundler/renderer.js'; import { generatePDFBuffer } from '../utils/pdf-generator.js'; import { Logger } from '../utils/logger.js'; +import { RenderProgress } from './render-stream.js'; export function handleHealthz(pool: WorkerPool): Response { return Response.json({ status: 'ok', workers: pool.stats() }); @@ -62,6 +63,64 @@ export async function handleRender( } } +export function handleRenderStream( + request: Request, + config: ServerConfig, + pool: WorkerPool, + templates: TemplateInfo[], + s3?: S3Uploader, +): Response { + const { progress, stream } = RenderProgress.create(); + + // Run the render pipeline asynchronously, streaming progress + (async () => { + const logger = new Logger(config.verbose); + let parsed: ParsedRenderRequest; + try { + progress.emit('parsing', 'Parsing request...'); + parsed = await parseRenderRequest(request, config.maxUploadSize); + } catch (err) { + const msg = err instanceof RenderError ? err.message : String(err); + progress.emitError(msg); + progress.close(); + return; + } + + const timeout = setTimeout(() => { + progress.emitError('Render timed out'); + progress.close(); + }, config.renderTimeout); + + try { + const result = await doRenderStreamed(parsed, config, pool, templates, s3, logger, progress); + clearTimeout(timeout); + + if (typeof result === 'string') { + progress.emitResult('text/html', result); + } else { + // PDF: base64 encode for SSE transport + progress.emitResult('application/pdf', Buffer.from(result).toString('base64')); + } + progress.emit('done', 'Render complete'); + } catch (err) { + clearTimeout(timeout); + const msg = err instanceof RenderError ? err.message : err instanceof Error ? err.message : String(err); + progress.emitError(msg); + } finally { + progress.close(); + } + })(); + + return new Response(stream, { + headers: { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + 'connection': 'keep-alive', + 'access-control-allow-origin': '*', + }, + }); +} + async function doRender( parsed: ParsedRenderRequest, config: ServerConfig, @@ -94,7 +153,53 @@ async function doRender( } } finally { archiveCleanup?.(); - if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + if (tempDir) cleanupTempDir(tempDir); + } +} + +async function doRenderStreamed( + parsed: ParsedRenderRequest, + config: ServerConfig, + pool: WorkerPool, + templates: TemplateInfo[], + s3: S3Uploader | undefined, + logger: Logger, + progress: RenderProgress, +): Promise { + let tempDir: string | undefined; + let archiveCleanup: (() => void) | undefined; + + try { + progress.emit('resolving', `Resolving template (${parsed.source.kind})...`); + const resolved = await resolveTemplateSource(parsed, templates, config); + tempDir = resolved.tempDir; + archiveCleanup = resolved.archiveCleanup; + + progress.emit('building', 'Building template with Vite SSR...'); + const html = await renderHTMLStreamed(resolved.consumerRoot, resolved.entryFile, parsed.data, logger, progress); + + if (parsed.format === 'html') { + progress.emit('done', 'HTML render complete'); + return html; + } + + progress.emit('rendering-pdf', 'Acquiring browser worker...'); + const worker = await pool.acquire(); + try { + progress.emit('rendering-pdf', 'Generating PDF with Puppeteer...'); + const pdfBuffer = await generatePDFBuffer(worker.browser, html, parsed.pdfOptions); + + if (parsed.output === 's3' && s3) { + progress.emit('uploading', 'Uploading to S3...'); + } + + return pdfBuffer; + } finally { + await pool.release(worker); + } + } finally { + archiveCleanup?.(); + if (tempDir) cleanupTempDir(tempDir); } } @@ -113,6 +218,23 @@ async function resolveTemplateSource( ): Promise { const { source } = parsed; + if (source.kind === 'inline') { + const tempDir = await mkdtemp(join(tmpdir(), 'facet-inline-')); + await writeFile(join(tempDir, 'Template.tsx'), source.code, 'utf-8'); + if (parsed.dependencies) { + await writeFile(join(tempDir, 'package.json'), JSON.stringify({ + name: 'facet-inline', + dependencies: parsed.dependencies, + }), 'utf-8'); + } + return { + consumerRoot: tempDir, + entryFile: 'Template.tsx', + templateName: 'inline', + tempDir, + }; + } + if (source.kind === 'archive') { const extracted = await extractArchive(source.data, source.entryFile, config.maxUploadSize); return { @@ -158,6 +280,21 @@ async function copyToTempDir(sourceDir: string): Promise { return tempDir; } +/** + * Safely remove a temp render directory without following symlinks + * into the cached node_modules. Unlinks .facet/node_modules first + * if it's a symlink, then removes the rest of the tree. + */ +function cleanupTempDir(tempDir: string): void { + const nmPath = join(tempDir, '.facet', 'node_modules'); + try { + if (lstatSync(nmPath).isSymbolicLink()) { + rmSync(nmPath, { force: true }); + } + } catch {} + rmSync(tempDir, { recursive: true, force: true }); +} + async function renderHTML( consumerRoot: string, entryFile: string, @@ -174,27 +311,58 @@ async function renderHTML( }); try { - const facetRoot = join(consumerRoot, '.facet'); - const tempHtmlPath = join(consumerRoot, '_render.temp.html'); - await writeFile(tempHtmlPath, buildResult.html, 'utf-8'); - - const stylesInput = join(facetRoot, 'src/styles.css'); - const outputCssPath = join(consumerRoot, '_render.css'); + return await applyTailwind(consumerRoot, buildResult.html, buildResult.css, logger); + } finally { + await buildResult.cleanup(); + } +} - let css = buildResult.css; - try { - await $`cd ${facetRoot} && npx tailwindcss -i ${stylesInput} --content ${tempHtmlPath} -o ${outputCssPath}`.quiet(); - css = await readFile(outputCssPath, 'utf-8'); - } catch { - logger.debug('Tailwind CSS failed, using Vite CSS fallback'); - } +async function renderHTMLStreamed( + consumerRoot: string, + entryFile: string, + data: Record, + logger: Logger, + progress: RenderProgress, +): Promise { + progress.emit('building', 'Compiling template with Vite SSR...'); + const buildResult = await buildTemplate({ + templatePath: entryFile, + data, + consumerRoot, + logger, + }); - return combineHTMLAndCSS(buildResult.html, css); + try { + progress.emit('tailwind', 'Processing Tailwind CSS...'); + return await applyTailwind(consumerRoot, buildResult.html, buildResult.css, logger); } finally { await buildResult.cleanup(); } } +async function applyTailwind( + consumerRoot: string, + html: string, + css: string, + logger: Logger, +): Promise { + const facetRoot = join(consumerRoot, '.facet'); + const tempHtmlPath = join(consumerRoot, '_render.temp.html'); + await writeFile(tempHtmlPath, html, 'utf-8'); + + const stylesInput = join(facetRoot, 'src/styles.css'); + const outputCssPath = join(consumerRoot, '_render.css'); + + try { + await $`cd ${facetRoot} && npx tailwindcss -i ${stylesInput} --content ${tempHtmlPath} -o ${outputCssPath}`.quiet(); + css = await readFile(outputCssPath, 'utf-8'); + } catch { + logger.debug('Tailwind CSS failed, using Vite CSS fallback'); + } + + return combineHTMLAndCSS(html, css); +} + async function respondWithOutput( content: Buffer | string, contentType: string, diff --git a/cli/src/utils/pdf-generator.ts b/cli/src/utils/pdf-generator.ts index c6a439a..a397420 100644 --- a/cli/src/utils/pdf-generator.ts +++ b/cli/src/utils/pdf-generator.ts @@ -57,9 +57,8 @@ async function loadAndPrepare(browser: Browser, html: string, widthMm?: number): if (widthMm) { await page.setViewport({ width: mmToPx(widthMm), height: mmToPx(297) }); } - await page.setContent(html, { waitUntil: 'networkidle0', timeout: 30000 }); + await page.setContent(html, { waitUntil: 'domcontentloaded', timeout: 30000 }); await page.evaluateHandle('document.fonts.ready'); - await new Promise(r => setTimeout(r, 500)); return page; } @@ -119,7 +118,7 @@ async function renderLegacyElementPdf( const { renderElementPdf } = await import('./pdf-multipass.js'); const page = await browser.newPage(); try { - await page.setContent(html, { waitUntil: 'networkidle0', timeout: 30000 }); + await page.setContent(html, { waitUntil: 'domcontentloaded', timeout: 30000 }); const exists = await page.evaluate((sel: string) => !!document.querySelector(sel), selector); await page.close(); if (!exists) return null; @@ -278,17 +277,15 @@ export async function generatePDFFromHTML(options: PDFOptions): Promise { try { const page = await browser.newPage(); - if (defaultPageSize) { - const initDims = resolvePageSize(defaultPageSize); - await page.setViewport({ width: mmToPx(initDims.width), height: mmToPx(initDims.height) }); + const dims = defaultPageSize ? resolvePageSize(defaultPageSize) : undefined; + if (dims) { + await page.setViewport({ width: mmToPx(dims.width), height: mmToPx(dims.height) }); } - await page.setContent(html, { waitUntil: 'networkidle0', timeout: 30000 }); - if (defaultPageSize) { - const initDims = resolvePageSize(defaultPageSize); - await page.addStyleTag({ content: `body { max-width: ${initDims.width}mm !important; }` }); + await page.setContent(html, { waitUntil: 'domcontentloaded', timeout: 30000 }); + if (dims) { + await page.addStyleTag({ content: `body { max-width: ${dims.width}mm !important; }` }); } await page.evaluateHandle('document.fonts.ready'); - await new Promise((resolve) => setTimeout(resolve, 500)); const typeInfo = await detectPageTypes(page, defaultPageSize); @@ -331,17 +328,15 @@ export async function generatePDFWithBrowser( const page = await browser.newPage(); try { - if (defaultPageSize) { - const initDims = resolvePageSize(defaultPageSize); - await page.setViewport({ width: mmToPx(initDims.width), height: mmToPx(initDims.height) }); + const dims = defaultPageSize ? resolvePageSize(defaultPageSize) : undefined; + if (dims) { + await page.setViewport({ width: mmToPx(dims.width), height: mmToPx(dims.height) }); } - await page.setContent(html, { waitUntil: 'networkidle0', timeout: 30000 }); - if (defaultPageSize) { - const initDims = resolvePageSize(defaultPageSize); - await page.addStyleTag({ content: `body { max-width: ${initDims.width}mm !important; }` }); + await page.setContent(html, { waitUntil: 'domcontentloaded', timeout: 30000 }); + if (dims) { + await page.addStyleTag({ content: `body { max-width: ${dims.width}mm !important; }` }); } await page.evaluateHandle('document.fonts.ready'); - await new Promise((resolve) => setTimeout(resolve, 500)); const typeInfo = await detectPageTypes(page, defaultPageSize); @@ -376,17 +371,15 @@ export async function generatePDFBuffer( ): Promise { const page = await browser.newPage(); try { - if (options?.defaultPageSize) { - const initDims = resolvePageSize(options.defaultPageSize); - await page.setViewport({ width: mmToPx(initDims.width), height: mmToPx(initDims.height) }); + const dims = options?.defaultPageSize ? resolvePageSize(options.defaultPageSize) : undefined; + if (dims) { + await page.setViewport({ width: mmToPx(dims.width), height: mmToPx(dims.height) }); } - await page.setContent(html, { waitUntil: 'networkidle0', timeout: 30000 }); - if (options?.defaultPageSize) { - const initDims = resolvePageSize(options.defaultPageSize); - await page.addStyleTag({ content: `body { max-width: ${initDims.width}mm !important; }` }); + await page.setContent(html, { waitUntil: 'domcontentloaded', timeout: 30000 }); + if (dims) { + await page.addStyleTag({ content: `body { max-width: ${dims.width}mm !important; }` }); } await page.evaluateHandle('document.fonts.ready'); - await new Promise((resolve) => setTimeout(resolve, 500)); const typeInfo = await detectPageTypes(page, options?.defaultPageSize); diff --git a/cli/test/render-api.test.ts b/cli/test/render-api.test.ts index c57de14..0d09fcc 100644 --- a/cli/test/render-api.test.ts +++ b/cli/test/render-api.test.ts @@ -204,6 +204,87 @@ export default function InlineTemplate({ data }: { data: any }) { expect(nonWhiteCount / totalPixels).toBeGreaterThan(0.001); }, 60000); + test('GET / returns playground HTML', async () => { + const res = await fetch(`${server.url}/`); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/html'); + const html = await res.text(); + expect(html).toContain('monaco'); + expect(html).toContain('Facet Playground'); + }); + + test('POST /render with inline code returns valid HTML', async () => { + const code = ` +import React from 'react'; +export default function Template({ data }: { data: any }) { + return ( + + +

{data.title || 'Inline Test'}

+ + + ); +}`; + const res = await fetch(`${server.url}/render`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code, format: 'html', data: { title: 'Hello Inline' } }), + }); + if (res.status !== 200) console.error('Inline render error:', await res.text()); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/html'); + const html = await res.text(); + expect(html).toContain('Hello Inline'); + }, 120000); + + test('POST /render with inline code returns valid PDF', async () => { + const code = ` +import React from 'react'; +export default function Template({ data }: { data: any }) { + return ( + + +

{data.title || 'PDF Inline'}

+ + + ); +}`; + const res = await fetch(`${server.url}/render`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code, format: 'pdf', data: { title: 'Inline PDF' } }), + }); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('application/pdf'); + const pdfBytes = Buffer.from(await res.arrayBuffer()); + expect(pdfBytes.length).toBeGreaterThan(1000); + const doc = await PDFDocument.load(pdfBytes); + expect(doc.getPageCount()).toBeGreaterThanOrEqual(1); + }, 60000); + + test('POST /render/stream returns SSE with progress and result', async () => { + const code = ` +import React from 'react'; +export default function Template({ data }: { data: any }) { + return

{data.msg}

; +}`; + const res = await fetch(`${server.url}/render/stream`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code, format: 'html', data: { msg: 'Stream Test' } }), + }); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe('text/event-stream'); + + const text = await res.text(); + // Should contain progress events + expect(text).toContain('"stage"'); + expect(text).toContain('"building"'); + // Should contain the final result event + expect(text).toContain('event: result'); + expect(text).toContain('Stream Test'); + }, 120000); + test('POST /render with unknown template returns 404', async () => { const res = await fetch(`${server.url}/render`, { method: 'POST', From 31ec283a9c1e529360974063d0d7fd3dfb1ae884 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 16 Mar 2026 14:45:40 +0200 Subject: [PATCH 2/5] feat(render): always return PDF URLs instead of raw binary or base64 /render now returns JSON { url: "/results/{id}" } for PDF output instead of raw binary. /render/stream sends empty data with the URL for PDFs. Cache keys are computed for all request types, not just inline templates. --- cli/src/server/routes.ts | 160 +++++++++++- cli/src/utils/pdf-security.ts | 128 ++++++++++ cli/test/pdf-security.test.ts | 224 +++++++++++++++++ openapi.yaml | 456 ++++++++++++++++++++++++++++++++++ 4 files changed, 958 insertions(+), 10 deletions(-) create mode 100644 cli/src/utils/pdf-security.ts create mode 100644 cli/test/pdf-security.test.ts create mode 100644 openapi.yaml diff --git a/cli/src/server/routes.ts b/cli/src/server/routes.ts index b31b5c3..924d640 100644 --- a/cli/src/server/routes.ts +++ b/cli/src/server/routes.ts @@ -18,8 +18,23 @@ import { parseRemoteRef, resolveRemoteRef } from '../utils/remote-resolver.js'; import { buildTemplate } from '../bundler/vite-builder.js'; import { combineHTMLAndCSS } from '../bundler/renderer.js'; import { generatePDFBuffer } from '../utils/pdf-generator.js'; +import { applyPDFSecurity } from '../utils/pdf-security.js'; import { Logger } from '../utils/logger.js'; import { RenderProgress } from './render-stream.js'; +import { computeCacheKey, RenderCache } from './render-cache.js'; + +export function handleResultsRoute(id: string, cache: RenderCache): Response { + const cached = cache.get(id); + if (!cached) return Response.json({ error: { code: 'NOT_FOUND', message: 'Result not found or expired' } }, { status: 404 }); + const ext = cached.contentType === 'application/pdf' ? 'pdf' : 'html'; + return new Response(cached.data, { + headers: { + 'content-type': cached.contentType, + 'content-disposition': `inline; filename="render.${ext}"`, + 'cache-control': 'private, max-age=600', + }, + }); +} export function handleHealthz(pool: WorkerPool): Response { return Response.json({ status: 'ok', workers: pool.stats() }); @@ -41,6 +56,7 @@ export async function handleRender( config: ServerConfig, pool: WorkerPool, templates: TemplateInfo[], + cache: RenderCache, s3?: S3Uploader, ): Promise { const logger = new Logger(config.verbose); @@ -51,7 +67,7 @@ export async function handleRender( return errorResponse(err); } - const renderPromise = doRender(parsed, config, pool, templates, s3, logger); + const renderPromise = doRender(parsed, config, pool, templates, cache, s3, logger); const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new RenderError('RENDER_TIMEOUT', 'Render timed out', 504)), config.renderTimeout), ); @@ -68,11 +84,11 @@ export function handleRenderStream( config: ServerConfig, pool: WorkerPool, templates: TemplateInfo[], + cache: RenderCache, s3?: S3Uploader, ): Response { const { progress, stream } = RenderProgress.create(); - // Run the render pipeline asynchronously, streaming progress (async () => { const logger = new Logger(config.verbose); let parsed: ParsedRenderRequest; @@ -86,6 +102,20 @@ export function handleRenderStream( return; } + const cacheKey = cacheKeyForRequest(parsed); + const cached = cache.get(cacheKey); + if (cached) { + progress.emit('done', 'Cache hit'); + const resultUrl = `/results/${cacheKey}`; + if (cached.contentType === 'text/html') { + progress.emitResult(cached.contentType, cached.data.toString('utf-8'), resultUrl); + } else { + progress.emitResult(cached.contentType, '', resultUrl); + } + progress.close(); + return; + } + const timeout = setTimeout(() => { progress.emitError('Render timed out'); progress.close(); @@ -96,10 +126,13 @@ export function handleRenderStream( clearTimeout(timeout); if (typeof result === 'string') { - progress.emitResult('text/html', result); + const buf = Buffer.from(result); + cache.set(cacheKey, 'text/html', buf); + progress.emitResult('text/html', result, `/results/${cacheKey}`); } else { - // PDF: base64 encode for SSE transport - progress.emitResult('application/pdf', Buffer.from(result).toString('base64')); + const buf = Buffer.from(result); + cache.set(cacheKey, 'application/pdf', buf); + progress.emitResult('application/pdf', '', `/results/${cacheKey}`); } progress.emit('done', 'Render complete'); } catch (err) { @@ -121,14 +154,43 @@ export function handleRenderStream( }); } +function cacheKeyForRequest(parsed: ParsedRenderRequest): string { + return computeCacheKey({ + source: parsed.source, + data: parsed.data, + dependencies: parsed.dependencies, + headerCode: parsed.headerCode, + footerCode: parsed.footerCode, + format: parsed.format, + pdfOptions: parsed.pdfOptions, + encryption: parsed.encryption, + signature: parsed.signature, + }); +} + async function doRender( parsed: ParsedRenderRequest, config: ServerConfig, pool: WorkerPool, templates: TemplateInfo[], + cache: RenderCache, s3: S3Uploader | undefined, logger: Logger, ): Promise { + const cacheKey = cacheKeyForRequest(parsed); + const cached = cache.get(cacheKey); + if (cached) { + if (cached.contentType === 'application/pdf') { + return Response.json({ url: `/results/${cacheKey}` }); + } + return new Response(cached.data, { + headers: { + 'content-type': cached.contentType, + 'content-disposition': `inline; filename="render.html"`, + }, + }); + } + let tempDir: string | undefined; let archiveCleanup: (() => void) | undefined; @@ -137,17 +199,29 @@ async function doRender( tempDir = resolved.tempDir; archiveCleanup = resolved.archiveCleanup; - const html = await renderHTML(resolved.consumerRoot, resolved.entryFile, parsed.data, logger, config.sandbox); + let html = await renderHTML(resolved.consumerRoot, resolved.entryFile, parsed.data, logger); + html = await injectHeaderFooter(html, parsed, resolved.consumerRoot, logger); const templateName = resolved.templateName; if (parsed.format === 'html') { + cache.set(cacheKey, 'text/html', Buffer.from(html)); return respondWithOutput(html, 'text/html', 'html', templateName, parsed, s3, logger); } const worker = await pool.acquire(); try { - const pdfBuffer = await generatePDFBuffer(worker.browser, html, parsed.pdfOptions); - return respondWithOutput(pdfBuffer, 'application/pdf', 'pdf', templateName, parsed, s3, logger); + let pdfBuffer = await generatePDFBuffer(worker.browser, html, parsed.pdfOptions); + if (parsed.encryption || parsed.signature) { + pdfBuffer = await applyPDFSecurity(Buffer.from(pdfBuffer), { + encryption: parsed.encryption, + signature: parsed.signature, + }, logger); + } + cache.set(cacheKey, 'application/pdf', Buffer.from(pdfBuffer)); + if (parsed.output === 's3') { + return respondWithOutput(pdfBuffer, 'application/pdf', 'pdf', templateName, parsed, s3, logger); + } + return Response.json({ url: `/results/${cacheKey}` }); } finally { await pool.release(worker); } @@ -176,7 +250,8 @@ async function doRenderStreamed( archiveCleanup = resolved.archiveCleanup; progress.emit('building', 'Building template with Vite SSR...'); - const html = await renderHTMLStreamed(resolved.consumerRoot, resolved.entryFile, parsed.data, logger, progress); + let html = await renderHTMLStreamed(resolved.consumerRoot, resolved.entryFile, parsed.data, logger, progress); + html = await injectHeaderFooter(html, parsed, resolved.consumerRoot, logger, progress); if (parsed.format === 'html') { progress.emit('done', 'HTML render complete'); @@ -187,7 +262,15 @@ async function doRenderStreamed( const worker = await pool.acquire(); try { progress.emit('rendering-pdf', 'Generating PDF with Puppeteer...'); - const pdfBuffer = await generatePDFBuffer(worker.browser, html, parsed.pdfOptions); + let pdfBuffer = await generatePDFBuffer(worker.browser, html, parsed.pdfOptions); + + if (parsed.encryption || parsed.signature) { + progress.emit('securing', 'Applying PDF security...'); + pdfBuffer = await applyPDFSecurity(Buffer.from(pdfBuffer), { + encryption: parsed.encryption, + signature: parsed.signature, + }, logger); + } if (parsed.output === 's3' && s3) { progress.emit('uploading', 'Uploading to S3...'); @@ -340,6 +423,63 @@ async function renderHTMLStreamed( } } +async function buildFragment( + consumerRoot: string, + filename: string, + code: string, + data: Record, + logger: Logger, +): Promise { + await writeFile(join(consumerRoot, filename), code, 'utf-8'); + const result = await buildTemplate({ + templatePath: filename, + data, + consumerRoot, + logger, + }); + try { + return result.html; + } finally { + await result.cleanup(); + } +} + +function extractBodyContent(html: string): string { + const bodyMatch = html.match(/]*>([\s\S]*)<\/body>/i); + return bodyMatch ? bodyMatch[1].trim() : html.replace(/]*>/i, '').trim(); +} + +function insertBeforeBodyClose(mainHtml: string, fragment: string): string { + if (mainHtml.includes('')) { + return mainHtml.replace('', `${fragment}`); + } + return mainHtml + fragment; +} + +async function injectHeaderFooter( + html: string, + parsed: ParsedRenderRequest, + consumerRoot: string, + logger: Logger, + progress?: RenderProgress, +): Promise { + if (!parsed.headerCode && !parsed.footerCode) return html; + + if (parsed.headerCode) { + progress?.emit('building', 'Building header template...'); + const headerHtml = await buildFragment(consumerRoot, '_Header.tsx', parsed.headerCode, parsed.data, logger); + html = insertBeforeBodyClose(html, extractBodyContent(headerHtml)); + } + + if (parsed.footerCode) { + progress?.emit('building', 'Building footer template...'); + const footerHtml = await buildFragment(consumerRoot, '_Footer.tsx', parsed.footerCode, parsed.data, logger); + html = insertBeforeBodyClose(html, extractBodyContent(footerHtml)); + } + + return html; +} + async function applyTailwind( consumerRoot: string, html: string, diff --git a/cli/src/utils/pdf-security.ts b/cli/src/utils/pdf-security.ts new file mode 100644 index 0000000..c29a2ac --- /dev/null +++ b/cli/src/utils/pdf-security.ts @@ -0,0 +1,128 @@ +import { readFile } from 'fs/promises'; +import { PDFDocument } from 'pdf-lib'; + +export interface PDFEncryptionOptions { + userPassword?: string; + ownerPassword: string; + permissions?: { + print?: boolean; + modify?: boolean; + copy?: boolean; + annotate?: boolean; + }; +} + +export interface PDFSignatureOptions { + certPath?: string; + certPassword?: string; + selfSigned?: boolean; + reason?: string; + name?: string; + location?: string; + timestampUrl?: string; +} + +interface PDFSecurityOptions { + encryption?: PDFEncryptionOptions; + signature?: PDFSignatureOptions; +} + +export async function applyPDFSecurity( + buffer: Buffer, + options: PDFSecurityOptions, +): Promise { + let result = buffer; + // Sign before encrypting — the signer needs to parse the xref table, + // which is not possible on an encrypted PDF. + if (options.signature) { + result = await signPDF(result, options.signature); + if (options.signature.timestampUrl) { + result = await timestampPDF(result, options.signature.timestampUrl); + } + } + if (options.encryption) { + result = await encryptPDF(result, options.encryption); + } + return result; +} + +async function encryptPDF(buffer: Buffer, options: PDFEncryptionOptions): Promise { + const { encryptPDF: encrypt } = await import('@pdfsmaller/pdf-encrypt'); + const perms = options.permissions ?? {}; + const encrypted = await encrypt(new Uint8Array(buffer), options.userPassword ?? '', { + ownerPassword: options.ownerPassword, + algorithm: 'AES-256', + allowPrinting: perms.print ?? true, + allowModifying: perms.modify ?? false, + allowCopying: perms.copy ?? false, + allowAnnotating: perms.annotate ?? false, + }); + return Buffer.from(encrypted); +} + +function generateSelfSignedP12(name: string): { buffer: Buffer; password: string } { + const forge = require('node-forge'); + const keys = forge.pki.rsa.generateKeyPair(2048); + const cert = forge.pki.createCertificate(); + cert.publicKey = keys.publicKey; + cert.serialNumber = '01'; + cert.validity.notBefore = new Date(); + cert.validity.notAfter = new Date(); + cert.validity.notAfter.setFullYear(cert.validity.notAfter.getFullYear() + 1); + const attrs = [{ name: 'commonName', value: name || 'Facet Self-Signed' }]; + cert.setSubject(attrs); + cert.setIssuer(attrs); + cert.sign(keys.privateKey, forge.md.sha256.create()); + const password = ''; + const p12Asn1 = forge.pkcs12.toPkcs12Asn1(keys.privateKey, [cert], password); + return { buffer: Buffer.from(forge.asn1.toDer(p12Asn1).getBytes(), 'binary'), password }; +} + +async function signPDF(buffer: Buffer, options: PDFSignatureOptions): Promise { + const { plainAddPlaceholder } = await import('@signpdf/placeholder-plain'); + const { P12Signer } = await import('@signpdf/signer-p12'); + const { SignPdf } = await import('@signpdf/signpdf'); + const signpdf = new SignPdf(); + + let p12Buffer: Buffer; + let passphrase: string; + + if (options.selfSigned) { + const generated = generateSelfSignedP12(options.name ?? ''); + p12Buffer = generated.buffer; + passphrase = generated.password; + } else if (options.certPath) { + p12Buffer = await readFile(options.certPath); + passphrase = options.certPassword ?? ''; + } else { + throw new Error('Either certPath or selfSigned must be provided for signing'); + } + + const signer = new P12Signer(p12Buffer, { passphrase }); + + // Re-serialize through pdf-lib to produce a clean traditional xref table. + // PDFs from the multi-pass pipeline use cross-reference streams that + // @signpdf/placeholder-plain cannot parse. + const doc = await PDFDocument.load(buffer); + const flattenedBytes = await doc.save({ useObjectStreams: false }); + const flatBuffer = Buffer.from(flattenedBytes); + + const withPlaceholder = plainAddPlaceholder({ + pdfBuffer: flatBuffer, + reason: options.reason ?? 'Document signed', + contactInfo: '', + name: options.name ?? '', + location: options.location ?? '', + }); + + return await signpdf.sign(withPlaceholder, signer); +} + +async function timestampPDF(buffer: Buffer, tsaUrl: string): Promise { + const { timestampPdf } = await import('pdf-rfc3161'); + const result = await timestampPdf({ + pdf: new Uint8Array(buffer), + tsa: { url: tsaUrl }, + }); + return Buffer.from(result.pdf); +} diff --git a/cli/test/pdf-security.test.ts b/cli/test/pdf-security.test.ts new file mode 100644 index 0000000..f81c6b6 --- /dev/null +++ b/cli/test/pdf-security.test.ts @@ -0,0 +1,224 @@ +import { join } from 'path'; +import { mkdtemp, writeFile, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import * as forge from 'node-forge'; +import { createServer, type ServerHandle } from '../src/server/preview.js'; + +const exec = promisify(execFile); + +const EXAMPLES_DIR = join(__dirname, '../examples'); +const REPO_ROOT = join(__dirname, '../..'); +process.env['FACET_PACKAGE_PATH'] = REPO_ROOT; + +const SIMPLE_CODE = ` +import React from 'react'; +export default function Template({ data }: { data: any }) { + return

{data.title || 'Test'}

; +}`; + +function generateTestP12(password: string): Buffer { + const keys = forge.pki.rsa.generateKeyPair(2048); + const cert = forge.pki.createCertificate(); + cert.publicKey = keys.publicKey; + cert.serialNumber = '01'; + cert.validity.notBefore = new Date(); + cert.validity.notAfter = new Date(); + cert.validity.notAfter.setFullYear(cert.validity.notAfter.getFullYear() + 1); + const attrs = [{ name: 'commonName', value: 'Test Signer' }]; + cert.setSubject(attrs); + cert.setIssuer(attrs); + cert.sign(keys.privateKey, forge.md.sha256.create()); + const p12Asn1 = forge.pkcs12.toPkcs12Asn1(keys.privateKey, [cert], password); + const p12Der = forge.asn1.toDer(p12Asn1).getBytes(); + return Buffer.from(p12Der, 'binary'); +} + +async function savePdf(pdfBytes: Buffer, dir: string, name: string): Promise { + const path = join(dir, `${name}.pdf`); + await writeFile(path, pdfBytes); + return path; +} + +async function fetchPdfFromRender(serverUrl: string, body: Record, dir: string, name: string): Promise { + const res = await fetch(`${serverUrl}/render`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + expect(res.status).toBe(200); + const json = await res.json() as { url: string }; + expect(json.url).toMatch(/^\/results\//); + + const pdfRes = await fetch(`${serverUrl}${json.url}`); + expect(pdfRes.status).toBe(200); + expect(pdfRes.headers.get('content-type')).toContain('application/pdf'); + return savePdf(Buffer.from(await pdfRes.arrayBuffer()), dir, name); +} + +async function pdfsig(nssDir: string, pdfPath: string, opts?: { opw?: string }): Promise { + const args = ['-nssdir', nssDir, '-nocert', pdfPath]; + if (opts?.opw) args.splice(3, 0, '-opw', opts.opw); + const { stdout, stderr } = await exec('pdfsig', args).catch(e => ({ + stdout: e.stdout ?? '', + stderr: e.stderr ?? '', + })); + return stdout + stderr; +} + +async function pdfinfo(pdfPath: string, opts?: { opw?: string; upw?: string }): Promise { + const args = [pdfPath]; + if (opts?.opw) args.unshift('-opw', opts.opw); + if (opts?.upw) args.unshift('-upw', opts.upw); + const { stdout } = await exec('pdfinfo', args); + return stdout; +} + +describe('PDF Security', () => { + let server: ServerHandle; + let certDir: string; + let certPath: string; + let tmpDir: string; + let nssDir: string; + const certPassword = 'test-pass'; + + beforeAll(async () => { + certDir = await mkdtemp(join(tmpdir(), 'facet-cert-')); + tmpDir = await mkdtemp(join(tmpdir(), 'facet-pdf-')); + nssDir = await mkdtemp(join(tmpdir(), 'facet-nss-')); + certPath = join(certDir, 'test-cert.p12'); + await writeFile(certPath, generateTestP12(certPassword)); + + await exec('certutil', ['-N', '-d', nssDir, '--empty-password']); + + server = await createServer({ + port: 0, + templatesDir: EXAMPLES_DIR, + workers: 1, + renderTimeout: 60000, + maxUploadSize: 52428800, + cacheMaxSize: 104857600, + verbose: false, + }); + }, 30000); + + afterAll(async () => { + await server?.stop(); + await rm(certDir, { recursive: true, force: true }).catch(() => {}); + await rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + await rm(nssDir, { recursive: true, force: true }).catch(() => {}); + }, 15000); + + test('encryption with owner password only', async () => { + const path = await fetchPdfFromRender(server.url, { + code: SIMPLE_CODE, + format: 'pdf', + data: { title: 'Encrypted' }, + encryption: { ownerPassword: 'admin' }, + }, tmpDir, 'enc-owner'); + const info = await pdfinfo(path, { opw: 'admin' }); + expect(info).toMatch(/Encrypted:\s+yes/); + }, 60000); + + test('encryption with user + owner password', async () => { + const path = await fetchPdfFromRender(server.url, { + code: SIMPLE_CODE, + format: 'pdf', + data: { title: 'Locked' }, + encryption: { userPassword: 'open-me', ownerPassword: 'admin' }, + }, tmpDir, 'enc-user'); + const info = await pdfinfo(path, { opw: 'admin' }); + expect(info).toMatch(/Encrypted:\s+yes/); + + // pdfinfo without password should fail + await expect(pdfinfo(path)).rejects.toThrow(); + }, 60000); + + test('encryption with restricted permissions', async () => { + const path = await fetchPdfFromRender(server.url, { + code: SIMPLE_CODE, + format: 'pdf', + data: { title: 'Restricted' }, + encryption: { + ownerPassword: 'admin', + permissions: { print: false, copy: false }, + }, + }, tmpDir, 'enc-restricted'); + const info = await pdfinfo(path, { opw: 'admin' }); + expect(info).toMatch(/Encrypted:\s+yes/); + expect(info).toMatch(/print:no/i); + expect(info).toMatch(/copy:no/i); + }, 60000); + + test('digital signature', async () => { + const path = await fetchPdfFromRender(server.url, { + code: SIMPLE_CODE, + format: 'pdf', + data: { title: 'Signed' }, + signature: { certPath, certPassword }, + }, tmpDir, 'signed'); + const sig = await pdfsig(nssDir, path); + expect(sig).toMatch(/Signature #1/); + expect(sig).toMatch(/Signer Certificate Common Name: Test Signer/); + expect(sig).toMatch(/Total document signed/i); + }, 60000); + + const canNetwork = process.env.CI !== 'true'; + + (canNetwork ? test : test.skip)('timestamp-only signature', async () => { + const path = await fetchPdfFromRender(server.url, { + code: SIMPLE_CODE, + format: 'pdf', + data: { title: 'Timestamp Only' }, + signature: { timestampUrl: 'http://timestamp.digicert.com' }, + }, tmpDir, 'timestamp-only'); + const sig = await pdfsig(nssDir, path); + expect(sig).toMatch(/Signature #1/); + expect(sig).toMatch(/Total document signed/i); + }, 90000); + + (canNetwork ? test : test.skip)('signature with timestamp', async () => { + const path = await fetchPdfFromRender(server.url, { + code: SIMPLE_CODE, + format: 'pdf', + data: { title: 'Timestamped' }, + signature: { + certPath, + certPassword, + timestampUrl: 'http://timestamp.digicert.com', + }, + }, tmpDir, 'timestamped'); + const sig = await pdfsig(nssDir, path); + expect(sig).toMatch(/Signature #1/); + expect(sig).toMatch(/Total document signed/i); + }, 90000); + + test('self-signed digital signature', async () => { + const path = await fetchPdfFromRender(server.url, { + code: SIMPLE_CODE, + format: 'pdf', + data: { title: 'Self-Signed' }, + signature: { selfSigned: true, name: 'Test User' }, + }, tmpDir, 'self-signed'); + const sig = await pdfsig(nssDir, path); + expect(sig).toMatch(/Signature #1/); + expect(sig).toMatch(/Signer Certificate Common Name: Test User/); + expect(sig).toMatch(/Total document signed/i); + }, 60000); + + test('encryption + signature combined', async () => { + const path = await fetchPdfFromRender(server.url, { + code: SIMPLE_CODE, + format: 'pdf', + data: { title: 'Encrypted+Signed' }, + encryption: { ownerPassword: 'admin' }, + signature: { certPath, certPassword }, + }, tmpDir, 'enc-signed'); + const info = await pdfinfo(path, { opw: 'admin' }); + expect(info).toMatch(/Encrypted:\s+yes/); + + const sig = await pdfsig(nssDir, path, { opw: 'admin' }); + expect(sig).toMatch(/Signature #1/); + }, 60000); +}); diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..e22c9a4 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,456 @@ +openapi: 3.1.0 +info: + title: Facet Render API + description: API for rendering React templates to HTML and PDF. + version: 0.1.23 + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0 + +servers: + - url: http://localhost:3010 + description: Local development server + +security: [] + +paths: + /healthz: + get: + operationId: healthz + summary: Health check + description: Returns server health and worker pool status. + responses: + "200": + description: Server is healthy. + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: ok + workers: + type: object + description: Worker pool statistics. + + /templates: + get: + operationId: listTemplates + summary: List available templates + description: Returns templates discovered in the configured templates directory. + security: + - apiKey: [] + - bearerAuth: [] + responses: + "200": + description: List of templates. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/TemplateInfo" + "401": + $ref: "#/components/responses/Unauthorized" + + /render: + post: + operationId: render + summary: Render a template + description: | + Renders a template to HTML or PDF synchronously. The response body is + the rendered output (HTML or PDF binary), or a JSON object with S3 URLs + when `output: "s3"` is used. + + Supports three input modes: + - **JSON** with inline code or template name + - **Multipart** with an uploaded archive + - **Gzip** body with a tar.gz archive + security: + - apiKey: [] + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RenderRequest" + multipart/form-data: + schema: + $ref: "#/components/schemas/MultipartRenderRequest" + application/gzip: + schema: + type: string + format: binary + parameters: + - name: format + in: query + schema: + type: string + enum: [html, pdf] + description: Output format (gzip mode only). + - name: output + in: query + schema: + type: string + enum: [direct, s3] + description: Output destination (gzip mode only). + - name: entryFile + in: query + schema: + type: string + description: Entry file within archive (gzip mode only). + - name: X-Facet-Data + in: header + schema: + type: string + description: Base64-encoded JSON data (gzip mode only). + responses: + "200": + description: | + Rendered output. For HTML format, returns raw HTML. For PDF format, + returns JSON with a `url` field pointing to `/results/{id}` where the + cached PDF can be fetched. When `output: "s3"`, returns S3 upload details. + content: + text/html: + schema: + type: string + application/json: + schema: + oneOf: + - $ref: "#/components/schemas/PDFRenderResult" + - $ref: "#/components/schemas/S3UploadResult" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "504": + $ref: "#/components/responses/Timeout" + + /render/stream: + post: + operationId: renderStream + summary: Render with streaming progress + description: | + Same as `/render` but returns a Server-Sent Events stream with progress + updates followed by the final result. Used by the playground. + + **Event types:** + - `data: {...}` — progress event with `stage`, `message`, `elapsed`, `duration` + - `event: result` — final output with `contentType`, `data`, and `url`. For PDFs, `data` is empty and `url` points to `/results/{id}`. For HTML, `data` contains the rendered HTML. + - `event: error` — render error with `message` + security: + - apiKey: [] + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RenderRequest" + responses: + "200": + description: SSE stream of progress events and final result. + content: + text/event-stream: + schema: + type: string + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + + /results/{id}: + get: + operationId: getResult + summary: Get cached render result + description: | + Serves a previously rendered result from the disk cache. The `id` is a + 16-character hex cache key returned in the `url` field of streaming + render results. + security: + - apiKey: [] + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + pattern: "^[a-f0-9]{16}$" + description: Cache key (16 hex characters). + responses: + "200": + description: Cached render output. + headers: + Cache-Control: + schema: + type: string + example: private, max-age=600 + content: + text/html: + schema: + type: string + application/pdf: + schema: + type: string + format: binary + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + + /types: + get: + operationId: getTypes + summary: TypeScript type definitions + description: Returns the `@flanksource/facet` TypeScript declarations for use in the Monaco editor playground. + responses: + "200": + description: TypeScript declaration file content. + content: + text/plain: + schema: + type: string + +components: + securitySchemes: + apiKey: + type: apiKey + in: header + name: X-API-Key + bearerAuth: + type: http + scheme: bearer + + schemas: + RenderRequest: + type: object + description: JSON render request. Provide either `code` (inline) or `template` (named/remote). + properties: + code: + type: string + description: Inline TSX template code. + template: + type: string + description: Template name (local) or remote ref (e.g. `github.com/org/repo/path`). + data: + type: object + additionalProperties: true + description: Data passed to the template as props. + default: {} + format: + type: string + enum: [html, pdf] + default: pdf + output: + type: string + enum: [direct, s3] + default: direct + s3Key: + type: string + description: "Custom S3 key (when output is s3)." + filename: + type: string + description: Custom filename for Content-Disposition header. + dependencies: + type: object + additionalProperties: + type: string + description: "npm dependencies to install, e.g. react-icons: ^5.4.0." + headerCode: + type: string + description: Inline TSX for a header component. + footerCode: + type: string + description: Inline TSX for a footer component. + pdfOptions: + $ref: "#/components/schemas/PDFOptions" + encryption: + $ref: "#/components/schemas/PDFEncryptionOptions" + signature: + $ref: "#/components/schemas/PDFSignatureOptions" + + PDFOptions: + type: object + properties: + defaultPageSize: + type: string + description: Page size name. + enum: [a4, a3, letter, legal, fhd, qhd, wqhd, 4k, 5k, 16k] + landscape: + type: boolean + default: false + debug: + type: boolean + description: Add debug overlay lines for header/footer zones. + default: false + margins: + type: object + properties: + top: + type: number + description: Top margin in mm. + bottom: + type: number + description: Bottom margin in mm. + left: + type: number + description: Left margin in mm. + right: + type: number + description: Right margin in mm. + + PDFEncryptionOptions: + type: object + required: [ownerPassword] + properties: + userPassword: + type: string + description: Password required to open the PDF. + ownerPassword: + type: string + description: Owner password that controls permissions. + permissions: + type: object + properties: + print: + type: boolean + default: true + modify: + type: boolean + default: false + copy: + type: boolean + default: false + annotate: + type: boolean + default: false + + PDFSignatureOptions: + type: object + properties: + certPath: + type: string + description: Path to PKCS#12 (.p12/.pfx) certificate file. + certPassword: + type: string + description: Certificate password. + selfSigned: + type: boolean + description: Auto-generate a self-signed certificate for signing. + default: false + reason: + type: string + description: Signature reason text. + name: + type: string + description: Signer name. + location: + type: string + description: Signing location. + timestampUrl: + type: string + description: RFC 3161 Timestamp Authority URL. + + MultipartRenderRequest: + type: object + required: [archive] + properties: + archive: + type: string + format: binary + description: Archive file (tar.gz or zip) containing the template. + data: + type: string + description: JSON string of template data. + options: + type: string + description: JSON string with `format`, `output`, `entryFile`, `pdfOptions`, etc. + + TemplateInfo: + type: object + properties: + name: + type: string + entryFile: + type: string + description: + type: string + hasSchema: + type: boolean + + PDFRenderResult: + type: object + required: [url] + properties: + url: + type: string + description: URL path to fetch the cached PDF (e.g. `/results/{id}`). + example: /results/a1b2c3d4e5f67890 + + S3UploadResult: + type: object + properties: + url: + type: string + description: Public S3 URL. + presignedUrl: + type: string + description: Pre-signed URL with expiry. + key: + type: string + description: S3 object key. + + ErrorResponse: + type: object + properties: + error: + type: object + required: [code, message] + properties: + code: + type: string + enum: + - TEMPLATE_NOT_FOUND + - VALIDATION_ERROR + - RENDER_TIMEOUT + - RENDER_FAILED + - S3_UPLOAD_FAILED + - QUEUE_FULL + - AUTH_REQUIRED + - INVALID_REQUEST + - ARCHIVE_ERROR + message: + type: string + details: + description: Additional error context. + + responses: + BadRequest: + description: Invalid request. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + Unauthorized: + description: Missing or invalid API key. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + NotFound: + description: Template or resource not found. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + Timeout: + description: Render timed out. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" From 17b95e9affb0ee648c0463c8d65ecbbe60fea99d Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 16 Mar 2026 14:47:02 +0200 Subject: [PATCH 3/5] feat(api): add render streaming with caching, pdf security, and enhanced playground Implements streaming render responses with real-time progress events, server-side render result caching with LRU eviction, PDF encryption and digital signature support, RFC 3161 timestamping, header/footer templates, configurable margins, and redesigned playground with format selector, margin controls, and log viewer modal. --- .gitignore | 2 + README.md | 49 +- Taskfile.yml | 2 + cli/package-lock.json | 1761 ++++++++++++++++- cli/package.json | 7 + cli/playground.html | 648 +++++- cli/src/bundler/vite-builder.ts | 22 +- cli/src/cli.ts | 61 +- cli/src/generators/pdf.ts | 62 +- cli/src/server/config.ts | 3 + cli/src/server/playground-html.ts | 612 ++++-- cli/src/server/preview.ts | 23 +- cli/src/server/render-cache.ts | 91 + cli/src/server/render-stream.ts | 12 +- cli/src/server/request.ts | 57 +- cli/src/types.ts | 7 +- cli/src/utils/pdf-generator.ts | 55 +- cli/src/utils/pdf-security.ts | 124 +- cli/tsdown.config.ts | 2 +- examples/kitchen-sink/CompactTableExample.tsx | 14 +- examples/kitchen-sink/HeaderDefault.tsx | 21 +- examples/kitchen-sink/HeaderMinimal.tsx | 15 +- examples/kitchen-sink/HeaderNone.tsx | 15 +- examples/kitchen-sink/HeaderSolid.tsx | 21 +- .../kitchen-sink/LogoGridTableExample.tsx | 6 +- .../SecurityChecksTableExample.tsx | 37 +- .../SpecificationTableExample.tsx | 6 +- examples/kitchen-sink/UberKitchenSink.tsx | 54 +- examples/kitchen-sink/data.ts | 46 + package.json | 1 + 30 files changed, 3398 insertions(+), 438 deletions(-) create mode 100644 cli/src/server/render-cache.ts create mode 100644 examples/kitchen-sink/data.ts diff --git a/.gitignore b/.gitignore index 656f383..0cdd7d8 100644 --- a/.gitignore +++ b/.gitignore @@ -162,3 +162,5 @@ cmd/facet/facet-cli.tar.gz facet hack/ *.png +.claude/ +*.html diff --git a/README.md b/README.md index b748245..44dbee1 100644 --- a/README.md +++ b/README.md @@ -256,14 +256,57 @@ Options: facet pdf MyDatasheet.tsx -d data.json -o out.pdf ``` -### `facet serve