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
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ jobs:
- run: go vet ./...
- run: go test ./...
- run: go build .
# The web build (cmd/epcii-wasm, tagged js&&wasm, so `go vet ./...`
# never sees it) must compile, and its SVG/PNG must equal the CLI's byte
# for byte. One runner: the wasm target does not vary by host OS.
- name: Web build + smoke
if: runner.os == 'Linux'
run: |
GOOS=js GOARCH=wasm go vet ./cmd/epcii-wasm
scripts/build-web.sh
node scripts/web-smoke.mjs
# The upstream fixture must stay reproducible from the pinned
# piglig/go-qr, so the generator (its own module, outside ./...) is
# built and its output compared byte for byte on every run.
Expand Down
57 changes: 57 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: pages

# Builds the static web version (web/ + cmd/epcii-wasm) and publishes it to
# GitHub Pages. The site is fully static: no server code, no storage; see
# README "Web". The same build and smoke gate also run in ci.yml on PRs.

on:
push:
branches: [main]
workflow_dispatch:

permissions: {}

# Never abort a running deployment: a cancelled deploy-pages can leave the
# site in a failed state until the next run finishes.
concurrency:
group: pages
cancel-in-progress: false

jobs:
build:
# Runs repo code (build script, smoke test), so it gets read access only;
# the deploy job alone holds the Pages/OIDC rights.
permissions:
contents: read
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0 # git describe needs the tags for the version stamp
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
- name: Build site and prove it matches the CLI
run: |
go build -o epcii .
scripts/build-web.sh
node scripts/web-smoke.mjs
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: web/dist

deploy:
needs: build
permissions:
pages: write
id-token: write
runs-on: ubuntu-latest
timeout-minutes: 10
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ go vet ./... && test -z "$(gofmt -l .)" # must both pass before commit
- `internal/epc` — payload builder + ALL validation
- `internal/qr` — QR encoder (byte mode, level M, versions 1–13)
- `internal/render` — SVG / PNG / terminal renderers
- `internal/webapi` + `cmd/epcii-wasm` + `web/` — browser version (WASM);
`scripts/build-web.sh` builds `web/dist/`, `scripts/web-smoke.mjs` proves
it equals the CLI byte for byte. Zero-storage page: never add storage,
external resources, or writes to the address bar.

## Traps

Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ what to check.

## [Unreleased]

### Added

- Web version at <https://bmmmm.github.io/epcii/>: the CLI pipeline compiled
to WebAssembly (`cmd/epcii-wasm`, `internal/webapi`) behind a static page
(`web/`) deployed by `pages.yml`. No server, no storage, no external
resources; share links carry the fields only in the URL fragment. SVG and
PNG downloads are byte-identical to the CLI, enforced by
`scripts/web-smoke.mjs` in CI. See README "Web".

## [0.2.0] - 2026-09-04

### Changed
Expand Down
9 changes: 9 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ asks for and why.
| `internal/epc` | EPC069-12 payload builder and all validation: IBAN (SEPA country, length, mod-97), amount normalization, field limits, UTF-8 enforcement |
| `internal/qr` | QR encoder core — byte mode, ECC level M, versions 1–13, mask selection. Derived from [piglig/go-qr](https://github.com/piglig/go-qr) (MIT, see `NOTICE`) |
| `internal/render` | SVG / PNG / ANSI-terminal renderers over the module matrix |
| `internal/webapi` | The CLI pipeline as one pure function for the browser build; native tests |
| `cmd/epcii-wasm` | `js && wasm` entry point exposing `internal/webapi` as `globalThis.epcii` |
| `web/` | Static page (HTML/JS/CSS, no framework, no external resources); `web/dist/` is the gitignored build output |
| `scripts/build-web.sh` | Assembles `web/dist/` (page files, Go's `wasm_exec.js`, the wasm build) |
| `scripts/web-smoke.mjs` | Node gate: the wasm build's SVG/PNG must equal the CLI's byte for byte |
| `scripts/gen_segno_fixtures.py` | One-shot generator for the segno golden fixtures in `internal/epc/testdata/` |
| `scripts/qrfixtures/` | Separate Go module: regenerates the upstream matrix fingerprints in `internal/qr/testdata/` from piglig/go-qr |

Expand Down Expand Up @@ -54,6 +59,10 @@ differently — not the commits.
fields trimmed together with their separators.
- **Zero runtime dependencies.** `go.mod` may only grow test-only entries,
each with a one-line justification.
- **The web version is the CLI.** Its SVG and PNG stay byte-identical to
`epcii` / `epcii --png` (`scripts/web-smoke.mjs`), and the page stores and
sends nothing: no cookies, no web storage, no service worker, no external
resource, no automatic write to the address bar. See README "Web".

`internal/` is not a public Go API — its shape may change freely.

Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,37 @@ input fails with a named-field error before anything is encoded.
For print, keep the symbol at least ~40 mm wide (EPC recommendation); the
SVG scales losslessly and already includes the 4-module quiet zone.

## Web

The same generator runs in the browser at
<https://bmmmm.github.io/epcii/>: the Go code is compiled to WebAssembly
(`cmd/epcii-wasm`), so validation, encoder and renderers are the ones above,
and the SVG and PNG you download are byte-identical to the CLI's output —
`scripts/web-smoke.mjs` proves that in CI on every change.

What the page does not do:

- **No server, no storage.** GitHub Pages serves a handful of static files;
there is no backend, no cookie, no local storage, no service worker, no
analytics, and no external resource. After the initial load the page makes
no further request. (GitHub keeps ordinary access logs for the page load
itself, like any web host.)
- **Share links stay in the fragment.** "Copy link" and "Share" build a URL
of the form `…/epcii/#v=1&name=…&iban=…&amount=…`; browsers never send the
`#fragment` to a server, so payment data does not reach GitHub's logs or a
`Referer`. The address bar is never written automatically — a link exists
only when you ask for one, and opening one fills the form and renders.
Whoever opens such a link has it in their own browser history, as with any
URL; the page cannot prevent that.
- **Content Security Policy.** Pages cannot send HTTP headers, so the policy
is a `<meta>` tag: `default-src 'none'`, scripts and styles only from the
page's own origin, no inline script. Directives that a meta CSP cannot
carry (`frame-ancestors`, `sandbox`, `report-uri`) are therefore absent.

Build it locally with `scripts/build-web.sh` (output in `web/dist/`, served
by any static file server) and check it with `node scripts/web-smoke.mjs`
after `go build -o epcii .`.

## Design

- EPC069-12 version 002 payload, UTF-8, LF separators, ≤331 bytes,
Expand Down
67 changes: 67 additions & 0 deletions cmd/epcii-wasm/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
//go:build js && wasm

// epcii-wasm is the browser build of epcii. It exposes the CLI pipeline to
// the page as globalThis.epcii.generate(fields) and stays resident; the
// static page lives in web/ and is assembled by scripts/build-web.sh.
package main

import (
"runtime/debug"
"syscall/js"

"github.com/bmmmm/epcii/internal/webapi"
)

var version = "dev"

// generate maps a JS object with the eight string fields to webapi.Generate
// and returns a plain JS object. Missing or non-string properties read as "",
// so a partial form is a validation error from epc, never a JS exception.
func generate(_ js.Value, args []js.Value) any {
var in webapi.Input
if len(args) > 0 && args[0].Type() == js.TypeObject {
get := func(key string) string {
v := args[0].Get(key)
if v.Type() != js.TypeString {
return ""
}
return v.String()
}
in = webapi.Input{
Name: get("name"), IBAN: get("iban"), BIC: get("bic"), Amount: get("amount"),
Purpose: get("purpose"), Ref: get("ref"), Text: get("text"), Info: get("info"),
}
}
out := webapi.Generate(in)
res := map[string]any{
"error": out.Error,
"payload": out.Payload,
"version": out.Version,
"size": out.Size,
"svg": out.SVG,
}
png := js.Global().Get("Uint8Array").New(len(out.PNG))
js.CopyBytesToJS(png, out.PNG)
res["png"] = png
return js.ValueOf(res)
}

// versionString mirrors main.go: an -ldflags override wins, then the module
// version from the build info, then "dev".
func versionString() string {
if version != "dev" {
return version
}
if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Version != "" && bi.Main.Version != "(devel)" {
return bi.Main.Version
}
return version
}

func main() {
js.Global().Set("epcii", js.ValueOf(map[string]any{
"generate": js.FuncOf(generate),
"version": versionString(),
}))
select {}
}
61 changes: 61 additions & 0 deletions internal/webapi/webapi.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Package webapi is the browser-facing entry point: one pure function that
// runs the same pipeline as the CLI (validate → encode → render) and returns
// every artefact the page shows. It has no syscall/js dependency, so it is
// built and tested natively; cmd/epcii-wasm is the thin JS bridge on top.
package webapi

import (
"bytes"

"github.com/bmmmm/epcii/internal/epc"
"github.com/bmmmm/epcii/internal/qr"
"github.com/bmmmm/epcii/internal/render"
)

// pngScale is pixels per module for the PNG download. Keep equal to
// pngScale in main.go so the web PNG matches `epcii --png` byte for byte.
const pngScale = 8

// Input mirrors the CLI flags (see the flag table in main.go).
type Input struct {
Name, IBAN, BIC, Amount, Purpose, Ref, Text, Info string
}

// Output is everything the page renders. On a validation or encoding error
// only Error is set; the other fields stay at their zero value.
type Output struct {
Payload string // exact encoded EPC069-12 payload (LF-separated)
Version int // QR symbol version
Size int // modules per side, without quiet zone
SVG string // identical to the CLI's stdout
PNG []byte // identical to the CLI's --png file
Error string // validation/encoding error text, without the "epcii:" prefix
}

// Generate runs the CLI pipeline (main.go run()) on in.
func Generate(in Input) Output {
p := epc.Payment{
Name: in.Name, IBAN: in.IBAN, BIC: in.BIC, Amount: in.Amount,
Purpose: in.Purpose, Ref: in.Ref, Text: in.Text, Info: in.Info,
}
payload, err := p.Payload()
if err != nil {
return Output{Error: err.Error()}
}
code, err := qr.EncodeM([]byte(payload))
if err != nil {
return Output{Error: err.Error()}
}
matrix := code.Matrix()
var png bytes.Buffer
if err := render.PNG(&png, matrix, pngScale); err != nil {
return Output{Error: err.Error()}
}
return Output{
Payload: payload,
Version: code.Version(),
Size: code.Size(),
SVG: string(render.SVG(matrix)),
PNG: png.Bytes(),
}
}
70 changes: 70 additions & 0 deletions internal/webapi/webapi_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package webapi

import (
"bytes"
"strings"
"testing"

"github.com/bmmmm/epcii/internal/qr"
"github.com/bmmmm/epcii/internal/render"
)

const testIBAN = "DE02120300000000202051"

func TestGenerateMatchesCLIPipeline(t *testing.T) {
in := Input{Name: "Test Persona", IBAN: testIBAN, Amount: "12,5", Text: "invoice 42"}
out := Generate(in)
if out.Error != "" {
t.Fatalf("unexpected error: %q", out.Error)
}
want := "BCD\n002\n1\nSCT\n\nTest Persona\n" + testIBAN + "\nEUR12.50\n\n\ninvoice 42"
if out.Payload != want {
t.Errorf("payload:\n got %q\nwant %q", out.Payload, want)
}
code, err := qr.EncodeM([]byte(want))
if err != nil {
t.Fatal(err)
}
if out.Version != code.Version() || out.Size != code.Size() {
t.Errorf("geometry: got v%d %dx%d, want v%d %dx%d",
out.Version, out.Size, out.Size, code.Version(), code.Size(), code.Size())
}
if out.SVG != string(render.SVG(code.Matrix())) {
t.Error("SVG differs from render.SVG")
}
var png bytes.Buffer
if err := render.PNG(&png, code.Matrix(), 8); err != nil {
t.Fatal(err)
}
if !bytes.Equal(out.PNG, png.Bytes()) {
t.Error("PNG differs from render.PNG at scale 8")
}
}

func TestGenerateErrors(t *testing.T) {
cases := []struct {
name string
in Input
want string // substring of the error, case-insensitive
}{
{"missing iban", Input{Name: "Test Persona"}, "iban"},
{"ref and text", Input{Name: "Test Persona", IBAN: testIBAN, Ref: "RF18539007547034", Text: "x"}, "ref"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out := Generate(tc.in)
if out.Error == "" {
t.Fatal("expected an error")
}
if !strings.Contains(strings.ToLower(out.Error), tc.want) {
t.Errorf("error %q does not mention %q", out.Error, tc.want)
}
if strings.HasPrefix(out.Error, "epcii:") {
t.Errorf("error carries the CLI prefix: %q", out.Error)
}
if out.SVG != "" || out.PNG != nil || out.Payload != "" {
t.Errorf("error output must be otherwise empty: %+v", out)
}
})
}
}
23 changes: 23 additions & 0 deletions scripts/build-web.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Assemble the static site for GitHub Pages into web/dist: the page files,
# Go's wasm_exec.js (must match the compiling toolchain), and the wasm build
# of cmd/epcii-wasm. Run from the repo root; the output dir is gitignored.
set -euo pipefail
cd "$(dirname "$0")/.."

OUT=web/dist
rm -rf "$OUT"
mkdir -p "$OUT"

cp web/index.html web/app.js web/style.css "$OUT"/
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" "$OUT"/ # Go >= 1.24 ships it under lib/wasm

VERSION="$(git describe --tags --always --dirty 2>/dev/null || echo dev)"
GOOS=js GOARCH=wasm go build -trimpath \
-ldflags "-s -w -X main.version=${VERSION}" \
-o "$OUT/epcii.wasm" ./cmd/epcii-wasm

touch "$OUT/.nojekyll" # Pages must not run Jekyll over the output

echo "built $OUT (version ${VERSION}):"
ls -l "$OUT"
Loading
Loading