Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,19 +68,23 @@ jobs:
run: nix --accept-flake-config profile add nixpkgs#omnix

- name: Configure permissions
# Enable unprivileged user namespaces required by Chromium/mermaid-filter
# on Linux. The sysctl does not exist on macOS so || true makes it safe.
# Enable unprivileged user namespaces, required by bubblewrap: zig2nix's
# zig-hook runs the compiler inside bwrap for network isolation, so any
# from-source build of zig2nix tooling fails without this on Ubuntu
# 24.04 runners ("bwrap: setting up uid map: Permission denied").
# The sysctl does not exist on macOS so || true makes it safe.
run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true

- name: Run CI
run: |
if [[ "$RUNNER_OS" == "macOS" ]]; then
# mermaid-filter/Chromium not available on macOS; run semantic
# analysis only until a macOS-compatible renderer is available.
# Use just the pinned Zig (prebuilt) rather than the full devShell:
# zig2nix's source-built inputs currently pull glibc into the
# aarch64-darwin closure, which nix refuses to evaluate.
nix shell .#zig --command zig build check
# Full test suite on macOS: the typst engine renders mermaid
# in-process (pozeiden), so the old mermaid-filter/Chromium
# limitation is gone. Use just the pinned Zig (prebuilt) plus
# typst rather than the full devShell: zig2nix's source-built
# inputs currently pull glibc into the aarch64-darwin closure,
# which nix refuses to evaluate.
nix shell .#zig nixpkgs#typst --command zig build test
else
om ci run
fi
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/deploy-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: Configure unprivileged user namespaces
- name: Configure permissions
# Unprivileged user namespaces for bubblewrap (zig2nix's zig-hook);
# see the comment in ci.yml.
run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true

- name: Build site and PDFs
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: Configure unprivileged user namespaces
- name: Configure permissions
# Unprivileged user namespaces for bubblewrap (zig2nix's zig-hook);
# see the comment in ci.yml.
run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true

- name: Compute preview URL
Expand Down
3 changes: 0 additions & 3 deletions .puppeteer.json

This file was deleted.

41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,47 @@ keys) is considered stable.

## [Unreleased]

### Changed

- **PDF engine migrated from pandoc/XeLaTeX/Eisvogel to Typst** (#58). Markdown
is rendered to Typst markup in-process by zigmark and compiled by the `typst`
CLI; the layout (title page, colored rule, logo, headers/footers, zebra
tables, TOC, version-history table) reproduces the Eisvogel look. CLI flags,
config options, and output filename patterns are unchanged.
- The GitHub Action's build shell is now pinned to the action's own version
(previously it floated on `main`, so flake changes could break consumers
running older release binaries).
- `-v/--verbose` now shows typst invocations instead of pandoc arguments.

### Added

- **Native mermaid rendering via pozeiden** - diagrams compile to inline SVG
in-process with no browser, Node.js, or Chromium. Diagram-bearing policies
now build inside the Nix sandbox and on macOS, and diagrams render at their
natural size (capped at the text width) instead of being scaled to full page
width.
- Full test suite now runs on macOS CI (previously only semantic analysis,
blocked by mermaid-filter's Chromium dependency).

### Fixed

- `redact_web` no longer drives PDF redaction (#115). It was documented as
web-only but seeded the PDF pipeline's redact default, so an org hiding
content (e.g. phone numbers) on the website could not keep it in the PDFs
without passing `--no-redact`. PDF redaction is now controlled solely by
`--redact`/`--no-redact` and the action's `redact_mode` input (which always
passes an explicit flag, so action consumers are unaffected). If you ran
the bare CLI and relied on `redact_web = true` redacting PDFs, pass
`--redact` explicitly.

### Removed

- pandoc, texlive/eisvogel-tex, and mermaid-filter (Node.js + Puppeteer +
Chromium) from the toolchain, along with the `MERMAID_FILTER_CMD_MMDC`
sandbox workaround, fontconfig plumbing, and the embedded `eisvogel.latex`
template. The Nix CI closure shrinks from multiple GB to a few hundred MB.
- `Config.data_dir` (was only used to stage the LaTeX template).

## [1.3.0] - 2026-07-02

### Changed
Expand Down
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

A compliance policy management platform for small and mid-size businesses. Write policies in Markdown, version them in Git, publish a branded static site, and generate audit-ready PDFs — all from a single GitHub Action.

PolicyPress is built on [Zola](https://www.getzola.org/) and [Pandoc](https://pandoc.org/). It is designed to be hosted by your customers in their own repositories; PolicyPress itself is the theme and toolchain, not the content.
PolicyPress is built on [Zola](https://www.getzola.org/) and [Typst](https://typst.app/). It is designed to be hosted by your customers in their own repositories; PolicyPress itself is the theme and toolchain, not the content.

## Who this is for

Expand Down Expand Up @@ -89,7 +89,7 @@ Internal notes - stripped from redacted PDFs.

PDFs are named `{Title}_-_v{version}.pdf`. With `redact_mode: true`, the name becomes `{Title}_(Redacted)_-_v{version}.pdf`. With `draft_mode: true`, it becomes `{Title}_(Draft)_-_v{version}.pdf`.

PDFs are generated using the [Eisvogel](https://github.com/Wandmalfarbe/pandoc-latex-template) Pandoc LaTeX template via XeLaTeX.
PDFs are generated with [Typst](https://typst.app/): markdown is rendered to Typst markup in-process ([zigmark](https://github.com/sc2in/zigmark)), mermaid diagrams become inline SVG via [pozeiden](https://github.com/sc2in/pozeiden), and the layout matches the classic [Eisvogel](https://github.com/Wandmalfarbe/pandoc-latex-template) look. The only external tool is the `typst` binary (bundled in the devshell and GitHub Action; on Windows install it with `winget install Typst.Typst` - without the Source Sans 3 font typst falls back to its embedded fonts, a cosmetic difference only).

## Compliance reports

Expand Down Expand Up @@ -119,7 +119,7 @@ nix run github:sc2in/policypress -- -c config.toml -o public
# Generate redacted PDFs
nix run github:sc2in/policypress -- -c config.toml -o public/redacted --redact

# Verbose output (shows pandoc args)
# Verbose output (shows typst invocations)
nix run github:sc2in/policypress -- -v -c config.toml -o public

# CI-friendly JSON log output
Expand All @@ -141,9 +141,9 @@ zig build test
| Dependency | Purpose |
| --- | --- |
| [Zola](https://www.getzola.org/) | Static site generator |
| [Pandoc](https://pandoc.org/) | PDF generation |
| [Eisvogel](https://github.com/Wandmalfarbe/pandoc-latex-template) | PDF template |
| [zigmark](https://github.com/sc2in/zigmark) | YAML/TOML frontmatter parsing |
| [Typst](https://typst.app/) | PDF compilation |
| [zigmark](https://github.com/sc2in/zigmark) | Markdown parsing + Typst rendering |
| [pozeiden](https://github.com/sc2in/pozeiden) | Mermaid diagrams to SVG |
| [tomlz](https://github.com/tsunaminoai/tomlz) | TOML config parsing |
| [clap](https://github.com/Hejsil/zig-clap) | CLI argument parsing |
| [mvzr](https://github.com/mnemnion/mvzr) | Regex for markdown transforms |
Expand All @@ -166,7 +166,8 @@ PolicyPress is developed and maintained by [Star City Security Consulting, LLC (
**Built on:**

- [Zola](https://www.getzola.org/) - static site generator
- [Eisvogel](https://github.com/Wandmalfarbe/pandoc-latex-template) - PDF template by Pascal Wagler
- [Typst](https://typst.app/) - PDF typesetting
- [Eisvogel](https://github.com/Wandmalfarbe/pandoc-latex-template) by Pascal Wagler - the PDF layout PolicyPress reproduces
- [Secure Controls Framework (SCF)](https://securecontrolsframework.com/) - control taxonomy
- [AICPA Trust Services Criteria (TSC)](https://www.aicpa-cima.com/resources/landing/2017-trust-services-criteria) - SOC 2 control framework

Expand Down
68 changes: 38 additions & 30 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,44 +76,49 @@ runs:
uses: actions/checkout@v4
with:
repository: sc2in/policypress
ref: ${{ steps.get-ref.outputs.ref }}
# action_ref for remote consumers (uses: sc2in/policypress@vX); for
# in-repo `uses: ./` runs it is empty, so fall back to the workflow's
# own commit — previews then use the PR's theme, not main's.
ref: ${{ steps.get-ref.outputs.ref || github.sha }}
path: themes/policypress

- name: Install policypress binary
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
case "$(uname -s)-$(uname -m)" in
Linux-x86_64) BINARY="policypress-x86_64-linux" ;;
Linux-aarch64) BINARY="policypress-aarch64-linux" ;;
Darwin-arm64) BINARY="policypress-aarch64-macos" ;;
Darwin-x86_64) BINARY="policypress-x86_64-macos" ;;
*)
echo "Unsupported platform: $(uname -s)-$(uname -m)"
exit 1
;;
esac

mkdir -p "${RUNNER_TEMP}/policypress-bin"
gh release download \
--repo sc2in/policypress \
--pattern "${BINARY}" \
--dir "${RUNNER_TEMP}/policypress-bin"
mv "${RUNNER_TEMP}/policypress-bin/${BINARY}" \
"${RUNNER_TEMP}/policypress-bin/policypress"
chmod +x "${RUNNER_TEMP}/policypress-bin/policypress"
echo "${RUNNER_TEMP}/policypress-bin" >> "${GITHUB_PATH}"

- name: Copy pandoc templates
shell: bash
run: |
# pandoc looks for eisvogel.latex in {data-dir}/templates/ (where
# data-dir defaults to the site root). Copy it from the theme so
# consumers don't need to vendor it.
mkdir -p templates
cp -n themes/policypress/templates/eisvogel.latex templates/
cp -n themes/policypress/templates/eisvogel.pdf templates/ 2>/dev/null || true
if [ -z "${{ steps.get-ref.outputs.ref }}" ]; then
# Running via `uses: ./` from a checkout of this repo (e.g. the PR
# preview workflow). Build the binary from the action's own flake so
# previews exercise the code under review — the latest release binary
# may not even match this branch's toolchain.
echo "No action ref (local action) - building policypress from the flake…"
out="$(nix build "path:${{ github.action_path }}#policypress-safe" --print-out-paths --no-link)"
install -m755 "${out}/bin/policypress" "${RUNNER_TEMP}/policypress-bin/policypress"
else
case "$(uname -s)-$(uname -m)" in
Linux-x86_64) BINARY="policypress-x86_64-linux" ;;
Linux-aarch64) BINARY="policypress-aarch64-linux" ;;
Darwin-arm64) BINARY="policypress-aarch64-macos" ;;
Darwin-x86_64) BINARY="policypress-x86_64-macos" ;;
*)
echo "Unsupported platform: $(uname -s)-$(uname -m)"
exit 1
;;
esac

gh release download \
--repo sc2in/policypress \
--pattern "${BINARY}" \
--dir "${RUNNER_TEMP}/policypress-bin"
mv "${RUNNER_TEMP}/policypress-bin/${BINARY}" \
"${RUNNER_TEMP}/policypress-bin/policypress"
chmod +x "${RUNNER_TEMP}/policypress-bin/policypress"
fi

echo "${RUNNER_TEMP}/policypress-bin" >> "${GITHUB_PATH}"

- name: Copy theme shortcodes
shell: bash
Expand Down Expand Up @@ -146,7 +151,10 @@ runs:

- name: Build site and PDFs
id: build
shell: nix develop "github:sc2in/policypress#ci" --command bash -e {0}
# The ci shell comes from the action's own checkout, so the toolchain
# (typst, zola, fonts) always matches the action version in use instead
# of floating on main. Also works for in-repo `uses: ./` workflows.
shell: nix develop "path:${{ github.action_path }}#ci" --command bash -e {0}
run: |
set -euo pipefail

Expand Down
72 changes: 29 additions & 43 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ pub fn build(b: *std.Build) !void {
});
const zigmark_mod = zigmark_dep.module("zigmark");

const pozeiden_dep = b.dependency("pozeiden", .{
.target = target,
.optimize = optimize,
});
const pozeiden_mod = pozeiden_dep.module("pozeiden");

const config_mod = b.addModule("config_parser", .{
.root_source_file = b.path("src/config.zig"),
.target = target,
Expand All @@ -62,8 +68,6 @@ pub fn build(b: *std.Build) !void {
// config uses utils for Date/today and readAllAlloc helpers.
config_mod.addImport("utils", utils_mod);

var pandoc_sh: *std.Build.Step.Compile = undefined;

// the executable from your call to exe_mod.addExecutable
if (target.result.os.tag != .windows) {
const zap = b.dependency("zap", .{
Expand Down Expand Up @@ -94,43 +98,29 @@ pub fn build(b: *std.Build) !void {
_ = b.step("preview", "Serve the zola output (Not available on Windows. run `zola preview` instead.)");
}

// Inject the eisvogel template into the pandoc module via an anonymous
// module. build.zig can @embedFile at the project root level, bypassing
// Zig's module package-path restriction that applies inside src/pandoc.zig.
// A WriteFile step generates a tiny Zig wrapper that @embedFile the real
// template; the wrapper lives in the Zig cache, not src/, so paths resolve.
const write_eisvogel = b.addWriteFiles();
_ = write_eisvogel.addCopyFile(b.path("templates/eisvogel.latex"), "eisvogel.latex");
const eisvogel_wrapper = write_eisvogel.add("eisvogel_wrapper.zig",
\\pub const eisvogel_latex: []const u8 = @embedFile("eisvogel.latex");
);
const pandoc_opts_mod = b.addModule("pandoc_options", .{
.root_source_file = eisvogel_wrapper,
});

const pandoc_sh_mod = b.addModule("pandocsh", .{
.root_source_file = b.path("src/pandoc.zig"),
// Typst PDF engine: markdown → zigmark → Typst markup (mermaid via
// pozeiden) → `typst compile`.
const typst_mod = b.addModule("typst_engine", .{
.root_source_file = b.path("src/typst.zig"),
.target = target,
.optimize = optimize,
});
// pandoc_sh_mod.addImport("zetta", zetta_mod);
pandoc_sh_mod.addImport("clap", clap.module("clap"));
pandoc_sh_mod.addImport("mvzr", mvzr.module("mvzr"));
pandoc_sh_mod.addImport("config", config_mod);
pandoc_sh_mod.addImport("utils", utils_mod);
pandoc_sh_mod.addImport("pandoc_options", pandoc_opts_mod);
pandoc_sh = b.addExecutable(.{
.root_module = pandoc_sh_mod,
.name = "pandoc_sh",
typst_mod.addImport("clap", clap.module("clap"));
typst_mod.addImport("config", config_mod);
typst_mod.addImport("utils", utils_mod);
typst_mod.addImport("zigmark", zigmark_mod);
typst_mod.addImport("pozeiden", pozeiden_mod);
const typst_exe = b.addExecutable(.{
.root_module = typst_mod,
.name = "pdf_engine",
});

var pandoc_step = b.step("pdf", "run pandoc.sh");
const pandoc_exe = b.addRunArtifact(pandoc_sh);
const pdf_step = b.step("pdf", "Compile a single policy to PDF via the typst engine");
const pdf_run = b.addRunArtifact(typst_exe);
if (b.args) |args| {
pandoc_exe.addArgs(args);
pdf_run.addArgs(args);
}

pandoc_step.dependOn(&pandoc_exe.step);
pdf_step.dependOn(&pdf_run.step);

const reports_mod = b.addModule("policy_report", .{
.target = target,
Expand All @@ -152,7 +142,7 @@ pub fn build(b: *std.Build) !void {
policypress_mod.addImport("build_options", build_opts.createModule());
policypress_mod.addImport("clap", clap.module("clap"));
policypress_mod.addImport("config", config_mod);
policypress_mod.addImport("pandoc", pandoc_sh_mod);
policypress_mod.addImport("typst", typst_mod);
policypress_mod.addImport("reports", reports_mod);
policypress_mod.addImport("utils", utils_mod);
const policypress_exe = b.addExecutable(.{
Expand Down Expand Up @@ -188,8 +178,7 @@ pub fn build(b: *std.Build) !void {
});
test_module.addImport("zigmark", zigmark_mod);
test_module.addImport("utils", utils_mod);
test_module.addImport("pandoc", pandoc_sh_mod);
test_module.addImport("pandoc_options", pandoc_opts_mod);
test_module.addImport("typst", typst_mod);
test_module.addImport("config", config_mod);
test_module.addImport("reports", reports_mod);
test_module.addImport("mvzr", mvzr.module("mvzr"));
Expand All @@ -206,21 +195,18 @@ pub fn build(b: *std.Build) !void {
// Used by ZLS for IDE diagnostics and as a fast CI sanity check.
const check_step = b.step("check", "Semantic analysis (no binary emitted, used by ZLS)");
check_step.dependOn(&policypress_exe.step);
check_step.dependOn(&pandoc_sh.step);
check_step.dependOn(&typst_exe.step);
}
{
// E2E test: run policypress against the starter/ template, mirroring
// what action.yml does for real consumers. Requires pandoc + XeLaTeX
// in PATH (provided by the devshell / Nix flake check environment).
// what action.yml does for real consumers. Requires typst in PATH
// (provided by the devshell / Nix flake check environment).
const e2e_step = b.step("e2e", "Run end-to-end test against starter/");

// Step 1: copy eisvogel.latex into starter/templates/ (the action does
// this via "Copy pandoc templates"; consumers don't vendor it).
// Also ensure starter/static/logo.png exists (required by config.toml).
// Step 1: ensure starter/static/logo.png exists (required by config.toml).
const copy_template = b.addSystemCommand(&.{
"bash", "-c",
"mkdir -p starter/templates starter/static && " ++
"cp -f templates/eisvogel.latex starter/templates/ && " ++
"mkdir -p starter/static && " ++
"{ cp -f static/logo.png starter/static/logo.png 2>/dev/null || " ++
"touch starter/static/logo.png; }",
});
Expand Down
Loading
Loading