Skip to content

Latest commit

 

History

264 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@flanksource/facet

Build beautiful, print-ready datasheets and PDFs from React templates.

@flanksource/facet is a framework for creating professional datasheets, reports, and documentation using React components. It provides a rich component library optimized for print and browser rendering, along with a powerful CLI for building HTML, PDF, and PNG outputs.

Features

  • 📄 Print-optimized components - 47+ components designed for professional datasheets
  • 🎨 React & TypeScript - Full type safety and modern React patterns
  • 🔧 Zero-config CLI - Build HTML, PDF, and PNG with a single command
  • 🔗 Component imports - import { StatCard } from '@flanksource/facet'
  • Fast builds - Powered by Vite with smart caching
  • 📦 Isolated builds - .facet/ build directory (like .next in Next.js)
  • 🔀 Live diagrams - Box-and-arrow diagrams baked to static SVG via // @live

Installation

Option 1: npm (recommended)

npm install -g @flanksource/facet-cli
# or: pnpm add -g @flanksource/facet-cli

This installs the facet command, which runs on your Node.js (>=20.19). Rendering uses pnpm to populate the shared module cache and reconcile project-specific dependencies. PDF output also needs a system Chrome/Chromium. Run facet doctor to check the environment. For a Node-free environment, use the standalone binary below.

Option 2: Standalone binary

Download the binary for your platform from GitHub Releases:

  • Linux (x64): facet-linux-x64
  • Linux (arm64): facet-linux-arm64
  • macOS (Apple Silicon): facet-macos-arm64
  • Windows (x64): facet-windows-x64.exe

Make it executable and put it on your PATH (substitute the file you downloaded):

chmod +x facet-linux-x64
sudo mv facet-linux-x64 /usr/local/bin/facet

Quick Start

1. Create a Template

Create a file MyDatasheet.tsx in your project:

import React from 'react';
import {
  Document,
  Header,
  Page,
  StatCard,
  Section,
  BulletList
} from '@flanksource/facet';

export default function MyDatasheet() {
  return (
    <Document>
      <Header
        title="Mission Control Platform"
        subtitle="Cloud-Native Observability & Incident Management"
      />

      <Page>
        <Section title="Key Metrics">
          <div className="grid grid-cols-3 gap-4">
            <StatCard label="Response Time" value="< 2min" />
            <StatCard label="Uptime" value="99.99%" />
            <StatCard label="Incidents Resolved" value="1,247" />
          </div>
        </Section>

        <Section title="Key Features">
          <BulletList items={[
            'Real-time incident detection and alerting',
            'Automated runbook execution',
            'Multi-cloud observability',
            'Integrated ChatOps workflows'
          ]} />
        </Section>
      </Page>
    </Document>
  );
}

2. Build HTML Output

facet html MyDatasheet.tsx -o ./dist

This creates:

  • Print-ready HTML with embedded styles
  • Scoped HTML for embedding in docs (use --css-scope for a custom prefix)
  • .facet/ - Build cache directory (can be gitignored)

3. Generate PDF

facet pdf MyDatasheet.tsx

Performance benchmarks

Facet includes a repeatable end-to-end benchmark for cold and warm HTML/PDF generation:

cd cli
pnpm build
pnpm bench:render
pnpm bench:server

# Optional fixture and warm-iteration count
FACET_BENCH_ITERATIONS=5 pnpm bench:render -- \
  examples/SimpleReport.tsx examples/simple-data.json

# Large server document, including streamed PDF download
FACET_BENCH_SECTIONS=250 pnpm bench:server

# Select a benchmark template
FACET_BENCH_TEMPLATE=BenchmarkMixedReport pnpm bench:server

The benchmark uses the release SEA binary, clears .facet/ before each cold run, keeps caches for warm runs, enables FACET_PROFILE, and samples aggregate process-tree RSS (Facet, pnpm/Vite, and Chromium). RSS is the sum of process resident sizes and may count shared pages more than once; use it for comparisons rather than as an exact physical-memory measurement.

Performance checkpoints

Fixture: cli/examples/SimpleReport.tsx with simple-data.json. The first three checkpoints report the mean of three warm runs. Stabilized checkpoints use the median of five runs. Server benchmarks report per-request RSS and include downloading the result PDF; FACET_BENCH_SECTIONS controls document size.

Checkpoint HTML cold HTML warm PDF cold PDF warm HTML warm peak RSS PDF warm peak RSS
2026-07-12 — SSR/Tailwind caches, minimal PDF passes, isolated contexts 8.19 s 1.05 s 7.20 s 1.57 s 380 MB 947 MB
2026-07-12 — persistent server workspaces 10.17 s 1.10 s 8.08 s 1.97 s 385 MB 945 MB
2026-07-12 — streamed cache responses and early PDF buffer release 9.34 s 1.41 s 9.86 s 2.39 s 384 MB 947 MB
2026-07-12 — stabilized 5-run median, no metadata-only PDF rewrite 11.30 s 1.39 s 9.83 s 2.28 s 379 MB 961 MB

The persistent-workspace phase targets server throughput, not one-shot CLI latency. Its server benchmark sends unique data with every request to bypass final-output caching:

Server checkpoint First HTML Subsequent HTML First PDF Subsequent PDF Peak process-tree RSS
Before persistent workspaces 6.46 s 3.86 s 4.17 s 4.64 s 1,547 MB
After persistent workspaces 8.04 s 0.62 s 0.95 s 0.75 s 1,544 MB
After streamed cache responses/buffer release 5.89 s 0.58 s 0.75 s 0.72 s 1,568 MB

The buffer-release checkpoint improved subsequent server HTML by approximately 6% and PDF by approximately 4% in this run. Its primary goal is lower memory retention for large documents and downloads; the small fixture and cold-install-dominated aggregate RSS measurement do not demonstrate that benefit. The CLI timings regressed while RSS was unchanged, despite this phase not changing the CLI rendering path materially; this illustrates the current machine/load variance and should not be interpreted as a causal regression without repeated controlled runs.

Subsequent HTML and PDF request latency improved by approximately 84% while peak RSS remained effectively unchanged. First-request values are dominated by dependency installation and global pnpm/filesystem cache state, so they are substantially noisier. The CLI checkpoint shows no expected improvement and some timing variance; its warm RSS remained stable.

For the latest CLI checkpoint, warm PDF stages averaged approximately 567 ms for HTML and 546 ms for Chromium/PDF. Cold runs remain dominated by dependency installation.

Mixed-page multi-pass checkpoint

The BenchmarkMixedReport fixture exercises three page types, three page sizes, headers, footers, content grouping, and pdf-lib compositing:

FACET_BENCH_TEMPLATE=BenchmarkMixedReport \
FACET_BENCH_FORMATS=pdf \
FACET_BENCH_SECTIONS=250 \
pnpm --dir cli bench:server
Multi-pass checkpoint Median PDF latency Median peak RSS Output size
Cartesian type × size overlays 3.82 s 1,876 MB 305 KB
Only overlays used by actual pages 2.06 s 1,891 MB 208 KB

Rendering only page type/size combinations that occur reduced latency by 46% and output size by 32%. Peak RSS was effectively unchanged. This was retained because it removes 12 unnecessary Chromium overlay renders for this fixture; broader benchmark work was deferred until another concrete bottleneck justifies it. A bounded overlay concurrency experiment was rejected: median latency regressed to 2.86 s (39% slower) with no meaningful RSS improvement, confirming that Chromium overlay work contends more than it parallelizes within one browser.

Stabilized large-document baseline

The five-run median baseline using FACET_BENCH_SECTIONS=250 includes fetching the file-backed PDF result and records peak RSS separately for every request:

Format Median latency Median request peak RSS Output size
HTML baseline 476 ms 986 MB 189 KB
PDF baseline, including download 1.37 s 1,074 MB 76 KB
PDF without metadata-only pdf-lib pass 1.05 s 1,049 MB 193 KB
HTML after fragment concurrency safety 526 ms 988 MB 189 KB
PDF after fragment concurrency safety 878 ms 1,047 MB 193 KB
HTML with persistent SSR loader 15 ms 1,061 MB 189 KB
PDF with persistent SSR loader 363 ms 1,327 MB 193 KB

Content-addressed header/footer fragments and workspace-wide build serialization fix concurrent rendering correctness. They do not affect sequential benchmark architecture; the lower latency in that checkpoint should be treated as run-to-run variance.

Persistent SSR loaders remove the per-request Node/Vite process startup. In this checkpoint warm HTML became approximately 97% faster and PDF approximately 59% faster, while median process-tree RSS increased by about 73 MB for HTML and 280 MB for PDF because the Vite/React loader remains resident. Loaders are limited to four by default (FACET_MAX_SSR_LOADERS), close after five idle minutes (FACET_SSR_LOADER_IDLE_MS), and can be disabled with --no-persistent-ssr or FACET_PERSISTENT_SSR=false.

Removing the metadata-only full-document load/save made the large PDF path approximately 24% faster and reduced median request peak RSS by approximately 2%. Direct Chromium PDFs are larger because they are no longer rewritten by pdf-lib; PDFs that already require header/footer compositing still receive Facet Creator/Producer metadata.

The benchmark now also reports Chromium RSS and Node RSS/heap/external memory after every request. A 30-request PDF-only stress run measured Chromium between 549–552 MB while Node RSS rose from 194 MB to 254 MB and showed repeated heap collection cycles. This points to V8 heap/allocator high-water retention rather than Chromium leakage. An aggressive 545 MB Chromium recycle threshold increased median PDF latency from 1.05 s to 1.51 s while changing median peak RSS from 1,049 MB to 1,046 MB, so RSS recycling remains opt-in.

Benchmark environment: Intel Core i7-1260P (12 cores/16 threads), 16 GiB RAM, Linux x86-64, Node.js 24.11.0, pnpm 9.15.9, and Chrome for Testing. Results from other machines or Chromium versions should not be directly compared.

Add a new row after each performance change rather than replacing previous results. Keep the fixture, iteration count, worker settings, and environment unchanged.

How It Works

Build Process

CLI mode — without a Facet server URL, facet html and facet pdf run the full pipeline locally:

  1. Setup .facet/ — Creates an isolated build directory with symlinks to your sources
  2. Generate configs — Auto-generates vite.config.ts, tsconfig.json, entry.tsx
  3. Vite SSR compile — Compiles React + TypeScript + MDX via Vite
  4. React SSR render — Renders components to static HTML with ReactDOMServer
  5. Tailwind CSS — Extracts only the styles your template uses
  6. HTML output — Combines markup + inlined CSS into a self-contained HTML file
  7. PDF output (optional) — Puppeteer prints the HTML to PDF, with optional encryption and digital signatures

Server modefacet serve wraps the same pipeline behind an HTTP API with a worker pool, LRU cache, optional S3 upload, and an interactive playground at localhost:3010.

When FACET_URL or --facet-url is set, the CLI loads and validates data locally, uploads the template project to /render, downloads the result, and writes it to the normal local output path. The upload excludes Git metadata, dependencies, Facet caches, temporary files, and build output.

Page Component API

The Page component is the primary layout container for multi-page PDF documents.

Headers and footers are not Page props. They are declared once as siblings inside <Document> and matched to pages by type, then composited onto every physical page by the multi-pass pipeline described below.

<Document title="Quarterly Report">
  {/* Chrome — declared once, applied to every page of the matching type */}
  <Header type="first" variant="solid" height={30} />
  <Header type="default" variant="minimal" height={14} />
  <Footer type="default" height={8}>
    <PageNo format="Page ${page} of ${total}" />
  </Footer>

  <Page
    type="first"
    title="Section Title"
    product="Mission Control"
    margins={{ top: 5, right: 0, bottom: 0, left: 0 }}
    watermark="DRAFT"
  >
    {/* page content */}
  </Page>
</Document>
Prop Type Default Description
children ReactNode Page content
id string Stable target used by automatic TableOfContents entries
title string Section title bar text (renders a blue bar below the header)
titleClassName string Extra CSS class applied to the title bar
product string Sub-label shown in the title bar
margins PageMargins inherited Content margins { top, right, bottom, left } in mm; merged field-by-field over <Document> defaults
pageSize PageSize 'a4' Page size — a4, a3, letter, legal, fhd, qhd, wqhd, 4k, 5k, 16k, any -landscape variant, or custom WxH in mm
type PageType 'default' 'first' | 'default' | 'last' — selects which Header/Footer this page gets
watermark string Diagonal watermark text (e.g. "DRAFT", "CONFIDENTIAL")
className string Extra CSS class applied to the <main> element

Note

<Page> clips rather than reflows — content that overflows a single <Page> is dropped. Long tables must be the direct overflow path (see examples/kitchen-sink/MultiPageTable.tsx).

To debug header/footer zones and inspect computed font, line-height, and block spacing, use the facet pdf --debug CLI flag; there is no debug prop.

Cover page and table of contents

CoverPage is an opinionated traditional first page. Compose DocumentFields into its lower section for ordered metadata; both labels and values remain on one line without truncation. TableOfContents accepts either a target matching a Page id or an element id inside a Page for automatic physical PDF numbering, or an explicit page label.

<CoverPage title="Annual report" subtitle="Year ended 2026" id="cover">
  <DocumentFields fields={[
    { label: 'Prepared for', value: 'Board of Directors' },
    { label: 'Document number', value: 'ACME-2026-001' },
  ]} />
</CoverPage>

<Page id="contents">
  <TableOfContents items={[
    { title: 'Executive summary', target: 'summary' },
    { title: 'Appendix', page: 'A-1', level: 2 },
  ]} />
</Page>

<Page id="summary" title="Executive summary">
  {/* content */}
</Page>

For a continuously flowing Page, place the TOC and sections together without forcing page breaks. Markdown firstHeadingId anchors its first parsed heading while leaving raw HTML id attributes sanitized:

<Page>
  <TableOfContents items={[{ title: '1. Introduction', target: 'section-1' }]} />
  <Markdown firstHeadingId="section-1">{'## 1. Introduction\n\nReport text.'}</Markdown>
</Page>

Automatic labels count from the first physical PDF sheet, including the cover and any logical Page that spans multiple sheets. Missing or duplicate targets fail PDF generation. HTML and PNG show an em dash for automatic entries because those formats have no physical PDF page number; explicit labels render unchanged in every format.

Multi-page PDF layout

A single React document with mixed page sizes (e.g. <Page pageSize="a4" type="first">, <Page pageSize="a4">, <Page pageSize="a4-landscape">) is compiled into a final PDF via a 4-phase multi-pass pipeline:

  1. DOM Scan — Puppeteer renders to DOM, measures header/footer heights per type×size group (e.g. first:a4, default:a4, default:a4-landscape)
  2. Extract — Each unique type×size group's header and footer are rendered as isolated PDFs using a dedicated Puppeteer pass, loaded into pdf-lib
  3. Content Render — Decorators are stripped from the DOM; @page margins are set to the measured header/footer heights; Puppeteer prints content-only PDFs per group
  4. Composite — pdf-lib page.drawPage() overlays the correct header at the top and footer at the bottom of every physical page, producing a single merged PDF
<Document>
  {/* Chrome is declared once per type, as a sibling of the pages */}
  <Header type="first" height={20} />
  <Footer type="first" height={10} />
  <Header type="default" height={18} />
  <Footer type="default" height={8} />

  {/* Cover page — picks up the type="first" header/footer */}
  <Page pageSize="a4" type="first">
    <CoverContent />
  </Page>

  {/* Standard A4 page — content starts after the header automatically */}
  <Page pageSize="a4">
    <BodyContent />
  </Page>

  {/* Landscape page for wide content — same default chrome, new size group */}
  <Page pageSize="a4-landscape">
    <WideTableContent />
  </Page>
</Document>

Diagrams

Facet ships box-and-arrow diagram primitives for data-flow and architecture diagrams. Boxes are pure CSS (server-rendered), while arrows measure the rendered DOM positions of their endpoints at runtime.

Component Description
Diagram Render-prop container. Yields an id(name) helper for stable, per-instance element ids and defers arrows until after mount.
DiagreDiagram Auto-layout container with the same id(name) helper. Infers nodes and edges from direct BoxNode and Arrow children, then lays them out left-to-right or top-to-bottom.
BoxNode Pure-CSS box with optional header/body, plus ports — chips docked on its border. Connected to other boxes via its id, or a port's.
Arrow Connector between two box ids (from / to), with primary / secondary presets.
NodeSection Labeled vertical column of boxes (e.g. "Sources", "Outputs").
COLORS Shared 5-color diagram palette.
import { Diagram, BoxNode, Arrow, NodeSection, COLORS } from '@flanksource/facet';

<Diagram className="flex items-center justify-between gap-8">
  {(id) => (
    <>
      <NodeSection label="Sources">
        <BoxNode id={id('db')} title="PostgreSQL" />
      </NodeSection>
      <BoxNode id={id('engine')} title="Facet Engine"
        headerColor={COLORS.primary} borderColor={COLORS.primary} />
      <NodeSection label="Outputs">
        <BoxNode id={id('pdf')} title="PDF" />
      </NodeSection>

      <Arrow from={id('db')} to={id('engine')} variant="secondary" />
      <Arrow from={id('engine')} to={id('pdf')} />
    </>
  )}
</Diagram>

Use DiagreDiagram when the component should position its nodes. gapX and gapY set minimum and maximum pixel spacing; available container space determines the spacing within those bounds. Keep BoxNode and Arrow as direct children (fragments are allowed), and use labelPosition to place arrow labels on the line, above/left of it (top), or below/right of it (bottom).

import { Arrow, BoxNode, DiagreDiagram } from '@flanksource/facet';

<DiagreDiagram direction="LR" gapX={{ min: 40, max: 100 }} gapY={{ min: 24, max: 60 }}>
  {(id) => <>
    <BoxNode id={id('source')} title="Source" />
    <BoxNode id={id('target')} title="Target" />
    <Arrow from={id('source')} to={id('target')} labelPosition="top" labels={{ middle: 'events' }} />
  </>}
</DiagreDiagram>

// @live — hydrate and bake

Because arrows are measured from the DOM, they can't be produced by server-side rendering alone. Mark a template live by making its first line the // @live directive:

// @live
import React from 'react';
import { Diagram, BoxNode, Arrow } from '@flanksource/facet';
// ...

For live templates, facet runs one extra headless-browser pass: it hydrates the SSR HTML, lets Arrow draw the connectors into the DOM, then captures the now-static HTML with arrows baked as plain SVG. That baked HTML flows through the unchanged HTML/PDF/PNG pipeline. The bake fails loudly if hydration never completes — there is no silent arrow-less fallback.

This works in facet html, facet pdf, and facet serve (including the playground). In the playground, pick Live Diagram from the Example dropdown to try it.

CLI Commands

Remote rendering

Set FACET_URL or pass the global --facet-url option to submit html, pdf, and png jobs to a Facet server. The explicit flag takes precedence over the environment variable.

FACET_URL=https://facet.example.com facet pdf MyDatasheet.tsx -d data.json -o report.pdf
facet --facet-url https://facet.example.com html MyDatasheet.tsx -o ./dist/
facet --facet-url https://facet.example.com png MyDatasheet.tsx --width 1200 --height 630

Remote mode requires tar locally but does not require local Chromium or pnpm. Data loaders and schema validation still run locally. The server controls sandboxing, module mode, and cache lifecycle, so --sandbox, --skip-modules, and --clear-cache fail when combined with a Facet URL. A server or network error stops the command without falling back to local rendering.

Shared modules

--skip-modules is a global option and may appear before or after any subcommand:

facet --skip-modules html MyDatasheet.tsx -d data.json
facet pdf MyDatasheet.tsx --skip-modules -o out.pdf
facet serve --skip-modules --templates-dir ./templates

The first use installs a Facet-only module set pinned to the CLI version under ${FACET_CACHE_DIR:-~/.facet/cache}/modules/<facet-version>/<platform>-<arch>-node<abi>. Later invocations link .facet/node_modules directly to that immutable entry, so they do not read consumer or nested package.json files, package-manager pins, lockfiles, overrides, .npmrc, or directory-based FACET_PACKAGE_PATH overrides. Templates that import additional packages must run without --skip-modules. Server requests containing dependencies receive HTTP 400 while the server uses this mode. facet doctor --skip-modules --fix verifies or rebuilds the exact shared entry selected by the current Facet and Node versions.

Without --skip-modules, a new .facet install is seeded by cloning the shared node_modules directory on APFS, then reconciled against the generated project manifest with pnpm install. Facet logs and uses a fresh install when cloning is unavailable, including on non-macOS filesystems. --clear-cache clears only the project .facet scaffold; it does not remove the versioned shared module cache.

facet html <template>

Generate HTML from a React template.

facet html [options] <template>

Options:
  --css-scope <prefix>         CSS scope prefix for scoped HTML generation
  -s, --schema <file>          Path to JSON Schema file for data validation
  --no-validate                Skip data validation
  -d, --data <file>            Path to JSON or YAML data file
  -l, --data-loader <file>     Path to data loader module (.ts or .js)
  -o, --output <path>          Output file path or directory (default: "dist")
  --output-name-field <field>  Data field to use for output filename
  -v, --verbose                Enable verbose logging

Facet automatically starts render-related child processes at below-normal scheduling priority so Vite, pnpm, data loaders, archive extraction, and Chromium yield resources to interactive OS workloads. It uses background task policy with niceness +10 on macOS, niceness +10 on Linux, and below-normal process priority on Windows.

Example:

facet html MyDatasheet.tsx -o ./dist --verbose
facet html MyDatasheet.tsx -d data.json -o report.html

facet pdf <template>

Generate PDF from a React template.

facet pdf [options] <template>

Options:
  -s, --schema <file>          Path to JSON Schema file for data validation
  --no-validate                Skip data validation
  -d, --data <file>            Path to JSON or YAML data file
  -l, --data-loader <file>     Path to data loader module (.ts or .js)
  -o, --output <path>          Output file path or directory (default: "dist")
  --output-name-field <field>  Data field to use for output filename
  -v, --verbose                Enable verbose logging

Example:

facet pdf IncidentReport.tsx -d incident.yaml -o incident.pdf

facet png <template>

Generate a PNG from a React template. Facet captures the selected element at its natural rendered size — the DOM is never resized or reflowed to fit an output box. The selector must match exactly one HTML or SVG element.

--width / --height set the rasterization scale, not a canvas: Facet computes a scale factor from the element's natural size and renders the same layout at that pixel density, so text and vectors stay crisp. Passing both uses the smaller of the two ratios, preserving aspect ratio — the output is never padded to exactly width × height.

Page layout is controlled separately by --viewport, which sets the browser viewport the template is laid out against.

--autocrop trims the uniform background border off the capture, measured from painted pixels rather than DOM geometry — so it also removes whitespace inside the target, such as SVG viewBox padding or flex centering that no selector can exclude. Cropping happens before scaling, so --width sizes the cropped result, and the final image is still rasterized by the browser at that scale. --autocrop-padding re-adds a margin of background, clamped to the original capture: content already flush against an edge had no border to trim there, so none is invented. A capture that is a single flat colour is rejected — that almost always means the template rendered empty (a Diagram captured without --live, say).

facet png [options] <template>

Options:
  --width <pixels>             Scale the capture to this output width (default: natural size)
  --height <pixels>            Scale the capture to this output height (default: natural size)
  --selector <selector>        CSS selector for the capture target (default: "body")
  --viewport <WxH>             Browser viewport used for layout (default: 1280x800)
  --autocrop                   Trim the uniform background border off the capture
  --autocrop-padding <pixels>  Background margin left around autocropped content (default: 0)
  -s, --schema <file>          Path to JSON Schema file for data validation
  --no-validate                Skip data validation
  -d, --data <file>            Path to JSON or YAML data file
  -l, --data-loader <file>     Path to data loader module (.ts or .js)
  -o, --output <path>          Output file path or directory

Examples:

# Capture the diagram exactly as it renders
facet png Diagram.tsx --selector '[data-facet-diagram]' -o preview.png

# Same layout, rendered at 2000px wide (height follows the aspect ratio)
facet png Diagram.tsx --selector '[data-facet-diagram]' --width 2000 -o preview@2x.png

# Lay out at 1920x1080, then scale the capture up to 3840px wide
facet png MyDatasheet.tsx --viewport 1920x1080 --width 3840 -o hero.png

# Trim the surrounding whitespace away, leaving a 24px background margin
facet png Diagram.tsx --live --autocrop --autocrop-padding 24 -o tight.png

# Crop first, then scale the cropped content to 2000px wide
facet png Diagram.tsx --live --autocrop --width 2000 -o hero@2x.png

facet serve

Start an API server with a built-in playground for interactive template development.

facet serve [options]

Options:
  -p, --port <number>          Server port (default: 3010)
  --templates-dir <dir>        Directory containing templates (default: ".")
  --workers <count>            Number of browser workers (default: 2)
  --max-renders-per-worker <n> Recycle Chromium after N renders (default: 50)
  --max-queue-depth <count>    Maximum queued browser requests (default: 20)
  --max-worker-age <ms>        Maximum Chromium worker age (default: 1800000)
  --max-worker-rss <mb>        Linux Chromium RSS recycle threshold (default: 0/off)
  --worker-acquire-timeout <ms> Browser queue wait timeout (default: 30000)
  --no-persistent-ssr          Disable persistent SSR loader processes
  --timeout <ms>               Render timeout in milliseconds (default: 300000)
  --api-key <key>              API key for authentication
  --max-upload <bytes>         Max upload size in bytes (default: 52428800)
  --cache-max-size <bytes>     Max render cache size in bytes (default: 104857600)
  --s3-endpoint <url>          S3 endpoint URL
  --s3-bucket <name>           S3 bucket name
  --s3-region <region>         S3 region (default: us-east-1)
  --s3-prefix <prefix>         S3 key prefix
  -v, --verbose                Enable verbose logging

Example:

facet serve --templates-dir ./templates --port 3010

# With authentication
facet serve --api-key my-secret-key

# With S3 upload
facet serve --s3-endpoint https://s3.amazonaws.com --s3-bucket my-bucket

# Reuse the immutable Facet-only modules for every request
facet serve --skip-modules --templates-dir ./templates

The playground is available at http://localhost:3010/ with a Monaco editor, live preview, and render logs. Use the Example dropdown to load a starting point:

  • Datasheet — a multi-component TSX template
  • Live Diagram — a // @live box-and-arrow diagram (see Diagrams)
  • Markdown — a plain .md document auto-wrapped in a printable page
  • MDX — Markdown prose mixed with Facet React components

Switching examples sets the editor language and the file extension sent to the server (.tsx, .md, or .mdx).

See openapi.yaml for the full API specification.

Docker

docker run -p 3000:3000 -v ./templates:/templates ghcr.io/flanksource/facet

Helm

helm install facet ./chart

See chart/values.yaml for configuration options.

Why .facet/?

Similar to Next.js (.next/) or Nuxt (.nuxt/), the .facet/ directory:

  • Isolates build artifacts - Keeps your project clean
  • Enables fast rebuilds - Symlinks avoid file copying
  • Supports incremental builds - Only rebuilds what changed
  • Simplifies debugging - All build files in one place

Add to .gitignore:

.facet/
dist/

Import Patterns

Named Imports (Recommended)

import { StatCard, Header, Page } from '@flanksource/facet';

TypeScript Support

All components include full TypeScript definitions:

import { StatCard } from '@flanksource/facet';

<StatCard
  label="Response Time"  // string
  value="< 2min"         // string | number
  trend="up"             // 'up' | 'down' | 'neutral' (optional)
  icon="clock"           // string (optional)
/>

Styling

Components use Tailwind CSS for styling. Include Tailwind in your project:

pnpm add -D tailwindcss autoprefixer postcss

tailwind.config.js:

module.exports = {
  content: [
    './MyDatasheet.tsx',
    './node_modules/@facet/core/src/components/**/*.tsx'
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

Markdown & MDX

Markdown (.md) and MDX (.mdx) files are first-class templates — facet html, facet pdf, facet serve, and inline renders all accept them directly. A Markdown/MDX template is auto-wrapped in a printable <Page> with prose styling, so you can render a plain document with zero React boilerplate:

facet pdf Report.md

MDX additionally lets you mix prose with Facet React components, and exposes the loaded data as props:

import { StatCard } from '@flanksource/facet';

# {props.title}

This is **MDX content** with a React component:

<StatCard label="Users" value="10,000+" />

Admonitions

GitHub-style alerts work in both .md and .mdx. A blockquote whose first line is one of five labels renders as a coloured callout with its own icon and title:

> [!NOTE]
> Context worth noticing while skimming.

> [!CAUTION]
> Never include credentials or other secrets in generated reports.
Syntax Tone Use for
> [!NOTE] blue Context worth noticing while skimming
> [!TIP] emerald A better way to do the thing
> [!IMPORTANT] purple Necessary to get the result
> [!WARNING] amber Needs attention to avoid a problem
> [!CAUTION] red Risk of data loss or a security hole

A plain > blockquote — or > with a .info class in TSX — stays the untinted zinc aside.

In MDX (and TSX) the same five tones are available as a component, which adds an identifier chip, a label override and an attribution for annotated documents:

import { CalloutBox } from '@flanksource/facet';

<CalloutBox variant="caution" badge="N14" label="Correction" source="Jonno">
  The two enforcement checks cannot be implemented as written.
</CalloutBox>

<CalloutBox variant="caution"> and > [!CAUTION] render the same box, so a document can move between markdown and MDX without a visual seam. Pass emphasis for a full border instead of the left rule, for callouts that block rather than inform.

label and icon are set independently of variant, which is what lets one tone carry several meanings. A review document can run amber "TODO", blue "Assumption" and purple "Open question" callouts off the five built-in tones without inventing new ones:

<CalloutBox variant="warning" label="TODO" icon="important" badge="BCR-08">
  Run the first tabletop exercise and retain the record.
</CalloutBox>

icon names any tone's glyph and defaults to the variant's own. It also gives the untinted default callout an icon, which it otherwise never draws.

Classified regions

A Markdown document can carry regions that only some outputs are permitted to include. Wrap them in <Classified> and declare, per build, which values are allowed:

<Classified tier="Internal">

Review notes that must not reach a customer.

</Classified>
facet pdf policy.md --allow tier=Public                     # region removed
facet pdf policy.md --allow tier=Public,Internal            # region kept

--allow is repeatable and takes <attribute>=<value[,value]>. A region is kept only when every attribute it declares is permitted.

Three properties are deliberate:

  • Removal, not concealment. Redaction runs on the Markdown AST before the bundle is built, so the text is absent from the output and from the build cache. Hiding a region with CSS leaves it in the file for anyone who looks.
  • Membership, not ranking. Permitted values are an explicit set, never a threshold. A classification scheme need not be a total order — a tier can be less sensitive than another yet reach a wider audience — and level <= clearance quietly misfiles exactly those cases.
  • Fail closed. A region declaring an attribute with no --allow fails the render. Forgetting the flag must never publish the content it was meant to govern.

Redaction applies to .md and .mdx. The policy is part of the build-cache key, so two audiences of one document never share a cached bundle, and it travels with --facet-url remote renders.

The header row — badge, label and source — has no plain-markdown form. > [!TYPE] carries the tone and nothing else: its label is fixed to the tone name, and there is no slot for an identifier or an attribution. An annotated document that needs to say which note a callout is, and who raised it, has to use .mdx (or raw HTML in .md, which rehype-raw passes through).

Note that plain .md is compiled with format: 'md', which has no JSX — in a .md file the blockquote syntax is the only route to an admonition. Rename to .mdx to use components.

MDX can also be imported into a TSX template when you need a custom layout:

// MyDatasheet.tsx
import Content from './content.mdx';

export default function MyDatasheet() {
  return (
    <Document>
      <Content />
    </Document>
  );
}

Try the Markdown and MDX entries in the playground's Example dropdown.

Development

Component Development

Use Storybook for component development:

pnpm run storybook

Building the CLI

pnpm run build:cli

To build the npm CLI package from the current checkout and replace any existing global facet command managed through npm's prefix:

task install

Publishing

pnpm run prepublishOnly  # Builds CLI automatically
npm publish

Architecture

  • src/components/ - React component library
  • src/styles.css - Global styles and Tailwind
  • cli/ - CLI package source
    • cli/src/builders/ - Build orchestration
    • cli/src/generators/ - HTML/PDF/PNG generators
    • cli/src/utils/ - Shared utilities
    • cli/src/plugins/ - Vite plugins
  • assets/ - Static assets (logos, icons)

Examples

See examples/ for complete working examples:

  • Basic Datasheet - Simple single-page datasheet
  • Multi-page Report - Complex multi-page document
  • Security Report - Security-focused datasheet
  • POC Evaluation - POC evaluation template
  • Document Front Matter - Traditional cover, document fields, and table of contents (DocumentFrontMatter.tsx)

Claude Code plugin

This repo doubles as a Claude Code plugin marketplace. Installing it gives agents three skills covering the whole surface: authoring templates, driving the CLI, and designing diagrams.

/plugin marketplace add flanksource/facet
/plugin install facet-skills@flanksource-facet
Skill Covers
facet-documents Document/Page/Header/Footer, page sizes and margins, the component library, the pt type scale, Theme, Markdown/MDX
facet-cli html/pdf/png/fill-pdf/serve/lint/doctor, data loading, remote templates, troubleshooting
diagram-designer Node-and-arrow architecture and ER diagrams, with five runnable layout examples

Source and layout: .agents/skills/.

Contributing

This is an internal Flanksource package. For issues or feature requests, contact the platform team.

License

Proprietary - Flanksource Inc.


Built with ❤️ by Flanksource

About

No description or website provided.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages