diff --git a/README.md b/README.md index 2cc1352..550dffc 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,18 @@ To embed the handler in your own server, import [`gobl.dev/api`](./api) (`api.NewHandler(...)`) and blank-import [`gobl.dev/bundle`](./bundle) to register the addons. +### Editor-private routes + +The browser editor uses a few additional routes that are not part of the GOBL +API surface: + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/_editor/examples` | List curated starter invoices (ID, label, country, addon) | +| `GET` | `/_editor/examples/{id}` | Raw JSON of a curated example | +| `GET` | `/_editor/formats` | List output formats the viewer can render | +| `POST` | `/_editor/convert?format={id}` | Convert a GOBL envelope to the requested output (UBL, CII, FatturaPA, HTML) | + ## WebAssembly [`wasm/`](./wasm) compiles GOBL to WebAssembly so it can run in the browser, and diff --git a/editor/assets/editor-data.js b/editor/assets/editor-data.js index a46b01d..003b99e 100644 --- a/editor/assets/editor-data.js +++ b/editor/assets/editor-data.js @@ -2,6 +2,8 @@ // Loaded as a synchronous script (before Alpine.js which is deferred) // so the alpine:init listener is registered in time. document.addEventListener("alpine:init", () => { + const bootstrap = readBootstrap(); + Alpine.data("editor", () => ({ loading: false, envelop: false, @@ -9,6 +11,18 @@ document.addEventListener("alpine:init", () => { // Counter so each successful build creates a fresh FlashMessage. success: 0, + // Example picker state. + examples: bootstrap.examples || [], + exampleID: bootstrap.initialExampleID || "", + + // Viewer state. + formats: bootstrap.formats || [], + format: localStorage.getItem("editor-format") || "", + viewerMode: "", // "xml" | "html" | "" + viewerHTML: "", + viewerLoading: false, + _lastEnvelope: null, + init() { window.addEventListener("keydown", (e) => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { @@ -16,6 +30,54 @@ document.addEventListener("alpine:init", () => { this.build(); } }); + this.$watch("format", (v) => { + localStorage.setItem("editor-format", v || ""); + if (!v) { + this.viewerMode = ""; + this.viewerHTML = ""; + return; + } + this.updateViewerMode(); + }); + if (this.exampleID) { + // editor.js is loaded as a module and may still be initialising when + // alpine:init fires — wait for CodeMirror to be ready before loading. + (window._cmReady || Promise.resolve()).then(() => + this.loadExample(this.exampleID), + ); + } + }, + + updateViewerMode() { + const f = this.formats.find((x) => x.id === this.format); + this.viewerMode = f && f.mime.startsWith("text/html") ? "html" : "xml"; + }, + + async loadExample(id) { + if (!id) return; + try { + await (window._cmReady || Promise.resolve()); + const res = await fetch("/_editor/examples/" + encodeURIComponent(id)); + if (!res.ok) throw new Error("failed to load example: " + res.status); + const text = await res.text(); + window._cmSetEditorDoc(text); + this.exampleID = id; + this._lastEnvelope = null; + this.viewerHTML = ""; + if (window._cmSetViewerXML) window._cmSetViewerXML(""); + } catch (e) { + this.error = { message: e.message }; + } + }, + + onFormatChange() { + if (!this.format) return; + this.updateViewerMode(); + if (this._lastEnvelope) { + this.convert(this._lastEnvelope); + } else { + this.build(); + } }, async build() { @@ -58,12 +120,53 @@ document.addEventListener("alpine:init", () => { }); ed.focus(); this.success++; + this._lastEnvelope = result; + + if (this.format) { + await this.convert(result); + } } catch (e) { this.error = { message: e.message }; } finally { this.loading = false; } }, + + async convert(envelope) { + if (!this.format) return; + this.viewerLoading = true; + try { + const res = await fetch( + "/_editor/convert?format=" + encodeURIComponent(this.format), + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(envelope), + }, + ); + + if (!res.ok) { + const err = await res.json().catch(() => ({ + message: "conversion failed: " + res.status, + })); + this.error = err; + this.viewerHTML = ""; + if (window._cmSetViewerXML) window._cmSetViewerXML(""); + return; + } + + const text = await res.text(); + if (this.viewerMode === "html") { + this.viewerHTML = text; + } else { + if (window._cmSetViewerXML) window._cmSetViewerXML(text); + } + } catch (e) { + this.error = { message: e.message }; + } finally { + this.viewerLoading = false; + } + }, })); Alpine.data("darkModeToggle", () => ({ @@ -90,3 +193,14 @@ document.addEventListener("alpine:init", () => { }, })); }); + +function readBootstrap() { + const el = document.getElementById("editor-bootstrap"); + if (!el) return {}; + try { + return JSON.parse(el.textContent); + } catch (e) { + console.warn("Failed to parse editor bootstrap:", e); + return {}; + } +} diff --git a/editor/assets/editor.css b/editor/assets/editor.css index 9d1f220..ce033a7 100644 --- a/editor/assets/editor.css +++ b/editor/assets/editor.css @@ -1,8 +1,15 @@ -/* CodeMirror container sizing */ +/* Split container */ #editor-container { flex: 1; overflow: hidden; } +#editor-pane, +#viewer-pane { + overflow: hidden; +} +#viewer-xml { + height: 100%; +} .cm-editor { height: 100%; } diff --git a/editor/assets/editor.js b/editor/assets/editor.js index da062bf..5ec6cce 100644 --- a/editor/assets/editor.js +++ b/editor/assets/editor.js @@ -1,53 +1,23 @@ // editor.js -- ES module for GOBL Editor -// Initializes CodeMirror with JSON schema support. +// Initializes the main editing CodeMirror and a read-only XML viewer. +// window._cmReady + window._cmReadyResolve are created by an inline script +// in ; this module resolves the promise once CodeMirror is mounted. import { basicSetup, EditorView } from "codemirror"; import { EditorState, Compartment } from "@codemirror/state"; import { vsCodeLight } from "@fsegurai/codemirror-theme-vscode-light"; import { vsCodeDark } from "@fsegurai/codemirror-theme-vscode-dark"; -// Theme compartment allows dynamic reconfiguration. -const themeCompartment = new Compartment(); +// Compartments allow dynamic reconfiguration. +const editorThemeCompartment = new Compartment(); +const viewerThemeCompartment = new Compartment(); function isDark() { return document.documentElement.classList.contains("dark"); } -const defaultDoc = JSON.stringify( - { - $schema: "https://gobl.org/draft-0/bill/invoice", - currency: "USD", - issue_date: new Date().toISOString().slice(0, 10), - supplier: { - name: "Acme Inc.", - tax_id: { - country: "US", - }, - }, - customer: { - name: "Sample Customer", - }, - lines: [ - { - quantity: "10", - item: { - name: "Development Services", - price: "100.00", - }, - taxes: [ - { - cat: "ST", - percent: "8.25%", - }, - ], - }, - ], - }, - null, - 2, -); - const { jsonSchema, updateSchema } = await import("codemirror-json-schema"); +const { xml: xmlLang } = await import("@codemirror/lang-xml"); const SCHEMA_PREFIX = "https://gobl.org/draft-0/"; let activeSchemaURL = null; @@ -70,15 +40,16 @@ async function loadSchemaFromDoc(view) { } } -const container = document.getElementById("editor-container"); -container.replaceChildren(); +// Mount the main editor inside #editor-pane. +const editorMount = document.getElementById("editor-pane"); +editorMount.replaceChildren(); const editor = new EditorView({ state: EditorState.create({ - doc: defaultDoc, + doc: "", extensions: [ basicSetup, - themeCompartment.of(isDark() ? vsCodeDark : vsCodeLight), + editorThemeCompartment.of(isDark() ? vsCodeDark : vsCodeLight), EditorView.lineWrapping, jsonSchema(), EditorView.updateListener.of((update) => { @@ -92,14 +63,60 @@ const editor = new EditorView({ }), ], }), - parent: container, + parent: editorMount, }); window._cmEditor = editor; + +// Lazily-created read-only viewer for XML output. +let viewerView = null; + +function ensureViewer() { + if (viewerView) return viewerView; + const parent = document.getElementById("viewer-xml"); + if (!parent) return null; + viewerView = new EditorView({ + state: EditorState.create({ + doc: "", + extensions: [ + basicSetup, + viewerThemeCompartment.of(isDark() ? vsCodeDark : vsCodeLight), + EditorView.lineWrapping, + EditorView.editable.of(false), + EditorState.readOnly.of(true), + xmlLang(), + ], + }), + parent, + }); + return viewerView; +} + +window._cmSetViewerXML = (text) => { + const v = ensureViewer(); + if (!v) return; + v.dispatch({ + changes: { from: 0, to: v.state.doc.length, insert: text }, + }); +}; + window._cmSetDark = (dark) => { + const theme = dark ? vsCodeDark : vsCodeLight; + editor.dispatch({ + effects: editorThemeCompartment.reconfigure(theme), + }); + if (viewerView) { + viewerView.dispatch({ + effects: viewerThemeCompartment.reconfigure(theme), + }); + } +}; + +window._cmSetEditorDoc = (text) => { editor.dispatch({ - effects: themeCompartment.reconfigure(dark ? vsCodeDark : vsCodeLight), + changes: { from: 0, to: editor.state.doc.length, insert: text }, }); + loadSchemaFromDoc(editor); }; -loadSchemaFromDoc(editor); +if (window._cmReadyResolve) window._cmReadyResolve(); diff --git a/editor/convert.go b/editor/convert.go new file mode 100644 index 0000000..aafd0fc --- /dev/null +++ b/editor/convert.go @@ -0,0 +1,302 @@ +package editor + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "github.com/invopop/gobl" + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" + + goblapi "github.com/invopop/gobl.dev/api" + cii "github.com/invopop/gobl.cii" + goblhtml "github.com/invopop/gobl.html" + ubl "github.com/invopop/gobl.ubl" +) + +// Format describes an output format offered by the viewer pane. +type Format struct { + ID string `json:"id"` + Label string `json:"label"` + Group string `json:"group"` + MIME string `json:"mime"` + + convert func(ctx context.Context, env *gobl.Envelope) ([]byte, error) +} + +var formats = []Format{ + { + ID: "ubl", + Label: "UBL — EN 16931", + Group: "UBL", + MIME: "application/xml", + convert: convertUBL(ubl.ContextEN16931), + }, + { + ID: "ubl-peppol", + Label: "UBL — Peppol BIS 3.0", + Group: "UBL", + MIME: "application/xml", + convert: convertUBL(ubl.ContextPeppol), + }, + { + ID: "ubl-xrechnung", + Label: "UBL — XRechnung", + Group: "UBL", + MIME: "application/xml", + convert: convertUBL(ubl.ContextXRechnung), + }, + { + ID: "ubl-peppol-fr-cius", + Label: "UBL — Peppol France CIUS", + Group: "UBL", + MIME: "application/xml", + convert: convertUBL(ubl.ContextPeppolFranceCIUS), + }, + { + ID: "ubl-peppol-fr-ext", + Label: "UBL — Peppol France Extended", + Group: "UBL", + MIME: "application/xml", + convert: convertUBL(ubl.ContextPeppolFranceExtended), + }, + { + ID: "cii", + Label: "CII — EN 16931", + Group: "CII", + MIME: "application/xml", + convert: convertCII(cii.ContextEN16931V2017), + }, + { + ID: "cii-peppol", + Label: "CII — Peppol BIS 3.0", + Group: "CII", + MIME: "application/xml", + convert: convertCII(cii.ContextPeppolV3), + }, + { + ID: "cii-facturx", + Label: "CII — Factur-X", + Group: "CII", + MIME: "application/xml", + convert: convertCII(cii.ContextFacturXV1), + }, + { + ID: "cii-zugferd", + Label: "CII — ZUGFeRD", + Group: "CII", + MIME: "application/xml", + convert: convertCII(cii.ContextZUGFeRDV2), + }, + { + ID: "cii-xrechnung", + Label: "CII — XRechnung", + Group: "CII", + MIME: "application/xml", + convert: convertCII(cii.ContextXRechnungV3), + }, + // TODO: re-enable FatturaPA once gobl.fatturapa is released against + // xmldsig v0.14.0 (WithXAdESConfig). v0.69.0 still calls the removed + // xmldsig.WithXAdES and conflicts with gobl.ubl's xmldsig v0.14.0. + // { + // ID: "fatturapa", + // Label: "FatturaPA", + // Group: "Italy", + // MIME: "application/xml", + // convert: func(_ context.Context, env *gobl.Envelope) ([]byte, error) { + // doc, err := fatturapa.Convert(env) + // if err != nil { + // return nil, err + // } + // return doc.Bytes() + // }, + // }, + { + ID: "html", + Label: "HTML preview", + Group: "Preview", + MIME: "text/html; charset=utf-8", + convert: func(ctx context.Context, env *gobl.Envelope) ([]byte, error) { + return goblhtml.Render(ctx, env) + }, + }, +} + +// convertUBL builds a UBL converter closure bound to a specific context. +// gobl.ubl.Convert handles addon injection internally via ensureAddons. +func convertUBL(cx ubl.Context) func(context.Context, *gobl.Envelope) ([]byte, error) { + return func(_ context.Context, env *gobl.Envelope) ([]byte, error) { + doc, err := ubl.Convert(env, ubl.WithContext(cx)) + if err != nil { + return nil, err + } + return ubl.Bytes(doc) + } +} + +// convertCII builds a CII converter closure that auto-injects any missing +// addons the chosen context requires, recalculates totals, and then serialises +// to XML bytes. +func convertCII(cx cii.Context) func(context.Context, *gobl.Envelope) ([]byte, error) { + return func(_ context.Context, env *gobl.Envelope) ([]byte, error) { + if err := ensureInvoiceAddons(env, cx.Addons); err != nil { + return nil, err + } + raw, err := cii.Convert(env, cii.WithContext(cx)) + if err != nil { + return nil, err + } + doc, ok := raw.(*cii.Invoice) + if !ok { + return nil, fmt.Errorf("cii: unexpected document type %T", raw) + } + return doc.Bytes() + } +} + +// ensureInvoiceAddons appends any missing required addons to the envelope's +// bill.Invoice and recalculates so that scenario-driven extensions are +// populated before conversion. +func ensureInvoiceAddons(env *gobl.Envelope, required []cbc.Key) error { + if len(required) == 0 { + return nil + } + inv, ok := env.Extract().(*bill.Invoice) + if !ok { + return nil + } + existing := inv.GetAddons() + missing := make([]cbc.Key, 0, len(required)) + for _, a := range required { + if !a.In(existing...) { + missing = append(missing, a) + } + } + if len(missing) == 0 { + return nil + } + inv.SetAddons(append(existing, missing...)...) + if err := inv.Calculate(); err != nil { + return fmt.Errorf("calculate with addons %v: %w", missing, err) + } + return nil +} + +// FormatInfo is the public subset of a Format, suitable for rendering or +// serialising to the browser. +type FormatInfo struct { + ID string `json:"id"` + Label string `json:"label"` + Group string `json:"group"` + MIME string `json:"mime"` +} + +// formatList returns the public format metadata in registration order. +func formatList() []FormatInfo { + out := make([]FormatInfo, 0, len(formats)) + for _, f := range formats { + out = append(out, FormatInfo{f.ID, f.Label, f.Group, f.MIME}) + } + return out +} + +// findFormat looks up a format by ID. +func findFormat(id string) (Format, bool) { + for _, f := range formats { + if f.ID == id { + return f, true + } + } + return Format{}, false +} + +// handleFormats returns the list of available output formats. +func handleFormats(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(formatList()) +} + +// handleConvert accepts a GOBL envelope in the body and returns the converted +// output for the requested format. Errors are returned as a JSON payload in +// the same shape as the core /v0/build endpoint — including structured +// rules.Fault entries when the converter fails validation — so the editor's +// error panel can render them identically. +func handleConvert(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("format") + f, ok := findFormat(id) + if !ok { + goblapi.WriteError(w, gobl.ErrInput.WithReason("unknown format: %s", id)) + return + } + + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 2<<20)) + if err != nil { + goblapi.WriteError(w, gobl.ErrInput.WithCause(fmt.Errorf("read body: %w", err))) + return + } + + env, err := parseConvertBody(body) + if err != nil { + goblapi.WriteError(w, asGoblError(err)) + return + } + + out, err := safeConvert(r.Context(), f, env) + if err != nil { + goblapi.WriteError(w, asGoblError(err)) + return + } + + w.Header().Set("Content-Type", f.MIME) + _, _ = w.Write(out) +} + +// parseConvertBody accepts either a full GOBL envelope or a bare document +// (e.g. a bill.Invoice) — the editor sends the latter when "Envelop" is +// unchecked — and returns a calculated envelope ready for conversion. +func parseConvertBody(body []byte) (*gobl.Envelope, error) { + // Try the envelope shape first — it's the output of a built /v0/build + // with envelop=true. + env := new(gobl.Envelope) + if err := json.Unmarshal(body, env); err == nil && env.Document != nil && env.Extract() != nil { + return env, nil + } + // Otherwise parse via the schema registry and wrap. + doc, err := gobl.Parse(body) + if err != nil { + return nil, gobl.ErrInput.WithCause(fmt.Errorf("parse document: %w", err)) + } + wrapped, err := gobl.Envelop(doc) + if err != nil { + return nil, err + } + return wrapped, nil +} + +// asGoblError normalises a converter error into a *gobl.Error so it serialises +// with the {key, faults, message} shape that the editor's error panel expects. +// Errors that already are *gobl.Error pass through untouched, preserving their +// rules.Faults cause for path-level rendering. +func asGoblError(err error) *gobl.Error { + var ge *gobl.Error + if errors.As(err, &ge) { + return ge + } + return gobl.ErrInternal.WithCause(err) +} + +// safeConvert wraps the converter call with panic recovery so upstream bugs +// (e.g. missing regime branches in gobl.html) surface as 4xx errors instead of +// crashing the server. +func safeConvert(ctx context.Context, f Format, env *gobl.Envelope) (out []byte, err error) { + defer func() { + if rec := recover(); rec != nil { + err = fmt.Errorf("converter panicked: %v", rec) + } + }() + return f.convert(ctx, env) +} diff --git a/editor/convert_test.go b/editor/convert_test.go new file mode 100644 index 0000000..30f0a25 --- /dev/null +++ b/editor/convert_test.go @@ -0,0 +1,164 @@ +package editor + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/invopop/gobl.dev/editor/examples" + "github.com/invopop/gobl" +) + +// buildEnvelope runs the curated example through the GOBL build flow to +// produce a calculated envelope ready for conversion. +func buildEnvelope(t *testing.T, exampleID string) *gobl.Envelope { + t.Helper() + data, ok := examples.Get(exampleID) + if !ok { + t.Fatalf("example %q not found", exampleID) + } + doc, err := gobl.Parse(data) + if err != nil { + t.Fatalf("parse example %s: %v", exampleID, err) + } + env, err := gobl.Envelop(doc) + if err != nil { + t.Fatalf("envelop %s: %v", exampleID, err) + } + if err := env.Calculate(); err != nil { + t.Fatalf("calculate envelope for %s: %v", exampleID, err) + } + return env +} + +func TestFormatConversions(t *testing.T) { + cases := []struct { + format string + example string + wantStart string + }{ + {"ubl", "de-xrechnung", " tag and read by the Alpine editor component on boot. +func bootstrap(initialExampleID string) map[string]any { + return map[string]any{ + "initialExampleID": initialExampleID, + "examples": examples.All(), + "formats": formatList(), + } +} + +func handleExamplesList(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(examples.All()) +} + +func handleExampleGet(w http.ResponseWriter, r *http.Request) { + data, ok := examples.Get(r.PathValue("id")) + if !ok { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _, _ = w.Write(data) } diff --git a/editor/editor.templ b/editor/editor.templ index c9e2d2d..d1bc286 100644 --- a/editor/editor.templ +++ b/editor/editor.templ @@ -1,18 +1,21 @@ package editor import ( + "github.com/invopop/gobl.dev/editor/examples" "github.com/invopop/icons" popui "github.com/invopop/popui.go" "github.com/invopop/popui.go/props" "path" ) -templ Page() { +templ Page(initialExampleID string) { @popui.App(props.App{ Title: "GOBL Editor", Importmap: []props.Import{ {Name: "codemirror", URL: "https://esm.sh/codemirror@6.0.1"}, {Name: "@codemirror/state", URL: "https://esm.sh/@codemirror/state@6"}, + {Name: "@codemirror/view", URL: "https://esm.sh/@codemirror/view@6"}, + {Name: "@codemirror/lang-xml", URL: "https://esm.sh/@codemirror/lang-xml@6"}, {Name: "codemirror-json-schema", URL: "https://esm.sh/@invopop/codemirror-json-schema@0.9.3"}, {Name: "@fsegurai/codemirror-theme-vscode-light", URL: "https://esm.sh/@fsegurai/codemirror-theme-vscode-light@6"}, {Name: "@fsegurai/codemirror-theme-vscode-dark", URL: "https://esm.sh/@fsegurai/codemirror-theme-vscode-dark@6"}, @@ -26,12 +29,14 @@ templ Page() { Scripts: []props.Script{ {Src: path.Join(AssetPath, popui.Versioned(editorAssets, "editor-data.js"))}, }, - Head: headExtras(), + Head: headExtras(initialExampleID), Data: "editor", }) { @popui.Header(props.Header{ Title: goblTitle(), }) { + @examplePicker() + @formatPicker() -
- @editorSkeleton() +
+
+ @editorSkeleton() +
+
+
+
Converting…
+ + +
+
} } } +templ examplePicker() { + +} + +templ formatPicker() { + +} + templ editorSkeleton() {
@@ -122,13 +175,23 @@ templ editorSkeleton() {
} -templ headExtras() { +templ headExtras(initialExampleID string) { + @bootstrapData(initialExampleID) +} + +templ bootstrapData(initialExampleID string) { + @templ.JSONScript("editor-bootstrap", bootstrap(initialExampleID)) } templ darkModeToggle() { diff --git a/editor/editor_templ.go b/editor/editor_templ.go index 8c2e668..d4bae81 100644 --- a/editor/editor_templ.go +++ b/editor/editor_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package editor //lint:file-ignore SA4006 This context is only used if a nested component is present. @@ -9,13 +9,14 @@ import "github.com/a-h/templ" import templruntime "github.com/a-h/templ/runtime" import ( + "github.com/invopop/gobl.dev/editor/examples" "github.com/invopop/icons" popui "github.com/invopop/popui.go" "github.com/invopop/popui.go/props" "path" ) -func Page() templ.Component { +func Page(initialExampleID string) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -60,7 +61,19 @@ func Page() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -102,7 +115,7 @@ func Page() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " Envelop") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " Envelop") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -116,7 +129,7 @@ func Page() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -132,7 +145,7 @@ func Page() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "Build") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "Build") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -149,7 +162,7 @@ func Page() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -165,7 +178,7 @@ func Page() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -181,7 +194,7 @@ func Page() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -189,7 +202,7 @@ func Page() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
Converting…
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -206,6 +219,8 @@ func Page() templ.Component { Importmap: []props.Import{ {Name: "codemirror", URL: "https://esm.sh/codemirror@6.0.1"}, {Name: "@codemirror/state", URL: "https://esm.sh/@codemirror/state@6"}, + {Name: "@codemirror/view", URL: "https://esm.sh/@codemirror/view@6"}, + {Name: "@codemirror/lang-xml", URL: "https://esm.sh/@codemirror/lang-xml@6"}, {Name: "codemirror-json-schema", URL: "https://esm.sh/@invopop/codemirror-json-schema@0.9.3"}, {Name: "@fsegurai/codemirror-theme-vscode-light", URL: "https://esm.sh/@fsegurai/codemirror-theme-vscode-light@6"}, {Name: "@fsegurai/codemirror-theme-vscode-dark", URL: "https://esm.sh/@fsegurai/codemirror-theme-vscode-dark@6"}, @@ -219,7 +234,7 @@ func Page() templ.Component { Scripts: []props.Script{ {Src: path.Join(AssetPath, popui.Versioned(editorAssets, "editor-data.js"))}, }, - Head: headExtras(), + Head: headExtras(initialExampleID), Data: "editor", }).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { @@ -229,7 +244,7 @@ func Page() templ.Component { }) } -func editorSkeleton() templ.Component { +func examplePicker() templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -250,19 +265,205 @@ func editorSkeleton() templ.Component { templ_7745c5c3_Var7 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func formatPicker() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var11 := templ.GetChildren(ctx) + if templ_7745c5c3_Var11 == nil { + templ_7745c5c3_Var11 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func editorSkeleton() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var14 := templ.GetChildren(ctx) + if templ_7745c5c3_Var14 == nil { + templ_7745c5c3_Var14 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for i := range 20 { if i < 15 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func headExtras(initialExampleID string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var15 := templ.GetChildren(ctx) + if templ_7745c5c3_Var15 == nil { + templ_7745c5c3_Var15 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = bootstrapData(initialExampleID).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -270,7 +471,7 @@ func editorSkeleton() templ.Component { }) } -func headExtras() templ.Component { +func bootstrapData(initialExampleID string) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -286,12 +487,12 @@ func headExtras() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var8 := templ.GetChildren(ctx) - if templ_7745c5c3_Var8 == nil { - templ_7745c5c3_Var8 = templ.NopComponent + templ_7745c5c3_Var16 := templ.GetChildren(ctx) + if templ_7745c5c3_Var16 == nil { + templ_7745c5c3_Var16 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") + templ_7745c5c3_Err = templ.JSONScript("editor-bootstrap", bootstrap(initialExampleID)).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -315,12 +516,12 @@ func darkModeToggle() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var9 := templ.GetChildren(ctx) - if templ_7745c5c3_Var9 == nil { - templ_7745c5c3_Var9 = templ.NopComponent + templ_7745c5c3_Var17 := templ.GetChildren(ctx) + if templ_7745c5c3_Var17 == nil { + templ_7745c5c3_Var17 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -352,12 +553,12 @@ func goblTitle() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var10 := templ.GetChildren(ctx) - if templ_7745c5c3_Var10 == nil { - templ_7745c5c3_Var10 = templ.NopComponent + templ_7745c5c3_Var18 := templ.GetChildren(ctx) + if templ_7745c5c3_Var18 == nil { + templ_7745c5c3_Var18 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -365,7 +566,7 @@ func goblTitle() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "GOBL Editor
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "GOBL Editor
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/editor/examples/catalog.go b/editor/examples/catalog.go new file mode 100644 index 0000000..bce7b17 --- /dev/null +++ b/editor/examples/catalog.go @@ -0,0 +1,116 @@ +// Package examples holds curated starter invoices exposed through the editor. +package examples + +import ( + "embed" + "fmt" +) + +// Example describes a curated starter document. +type Example struct { + ID string `json:"id"` + Label string `json:"label"` + Type string `json:"type"` + Country string `json:"country"` + Addon string `json:"addon,omitempty"` + Description string `json:"description,omitempty"` +} + +// Group is a set of examples that share the same document Type. Returned by +// Grouped in the order the types first appear in the catalog. +type Group struct { + Type string `json:"type"` + Items []Example `json:"items"` +} + +//go:embed *.json +var files embed.FS + +// Document types. More may appear alongside Invoice as the catalog grows +// (e.g. "Credit Note", "Delivery", "Payment"). +const ( + TypeInvoice = "Invoice" +) + +// catalog is the ordered list of available examples. Ordering: plain country +// variant first, then addon variants, grouped by country alpha-code. +var catalog = []Example{ + {ID: "de", Label: "DE — Germany", Type: TypeInvoice, Country: "DE", Description: "Standard German VAT invoice."}, + {ID: "de-xrechnung", Label: "DE — XRechnung", Type: TypeInvoice, Country: "DE", Addon: "de-xrechnung-v3", Description: "German public-sector e-invoice."}, + {ID: "es", Label: "ES — Spain", Type: TypeInvoice, Country: "ES", Description: "Standard Spanish VAT invoice."}, + {ID: "es-verifactu", Label: "ES — VERI*FACTU", Type: TypeInvoice, Country: "ES", Addon: "es-verifactu-v1", Description: "Spanish real-time VAT reporting (AEAT)."}, + {ID: "es-tbai", Label: "ES — TicketBAI", Type: TypeInvoice, Country: "ES", Addon: "es-tbai-v1", Description: "Basque Country e-invoicing."}, + {ID: "fr", Label: "FR — France", Type: TypeInvoice, Country: "FR", Description: "Standard French VAT invoice."}, + {ID: "fr-facturx", Label: "FR — Factur-X", Type: TypeInvoice, Country: "FR", Addon: "fr-facturx-v1", Description: "French hybrid PDF/XML e-invoice."}, + {ID: "gb", Label: "GB — United Kingdom", Type: TypeInvoice, Country: "GB", Description: "UK VAT invoice."}, + {ID: "it-fatturapa", Label: "IT — FatturaPA", Type: TypeInvoice, Country: "IT", Addon: "it-sdi-v1", Description: "Italian SdI electronic invoice."}, + {ID: "nl", Label: "NL — Netherlands", Type: TypeInvoice, Country: "NL", Description: "Dutch VAT invoice."}, + {ID: "pt", Label: "PT — Portugal", Type: TypeInvoice, Country: "PT", Description: "Portuguese VAT invoice."}, + {ID: "us", Label: "US — United States", Type: TypeInvoice, Country: "US", Description: "Basic sales-tax invoice."}, +} + +// All returns the curated examples in display order. +func All() []Example { + return catalog +} + +// Grouped returns the examples bucketed by Type, in the order each type +// first appears in the catalog. Items within each group preserve their +// catalog ordering. +func Grouped() []Group { + groups := make([]Group, 0) + index := map[string]int{} + for _, e := range catalog { + i, ok := index[e.Type] + if !ok { + index[e.Type] = len(groups) + groups = append(groups, Group{Type: e.Type, Items: []Example{e}}) + continue + } + groups[i].Items = append(groups[i].Items, e) + } + return groups +} + +// Get returns the raw JSON for a given example ID. +func Get(id string) ([]byte, bool) { + for _, e := range catalog { + if e.ID == id { + data, err := files.ReadFile(e.ID + ".json") + if err != nil { + return nil, false + } + return data, true + } + } + return nil, false +} + +// Find returns the example metadata for a given ID. +func Find(id string) (Example, bool) { + for _, e := range catalog { + if e.ID == id { + return e, true + } + } + return Example{}, false +} + +// DefaultFor returns the preferred example for the given ISO-3166-1 alpha-2 +// country code (upper-case). It picks the first entry whose Country matches, +// falling back to the US example if no match is found. The plain-country +// variant (no addon) wins when both a plain and addon variant exist, because +// of the ordering inside catalog. +func DefaultFor(country string) Example { + for _, e := range catalog { + if e.Country == country { + return e + } + } + for _, e := range catalog { + if e.ID == "us" { + return e + } + } + panic(fmt.Errorf("examples: us fallback not found")) +} diff --git a/editor/examples/de-xrechnung.json b/editor/examples/de-xrechnung.json new file mode 100644 index 0000000..d435b8c --- /dev/null +++ b/editor/examples/de-xrechnung.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$addons": ["de-xrechnung-v3"], + "currency": "EUR", + "issue_date": "2026-01-15", + "series": "SAMPLE", + "code": "001", + "supplier": { + "name": "Provide One GmbH", + "tax_id": { + "country": "DE", + "code": "111111125" + }, + "inboxes": [ + {"email": "billing@example.com"} + ], + "people": [ + { + "name": {"given": "John", "surname": "Doe"}, + "emails": [{"addr": "billing@example.com"}] + } + ], + "addresses": [ + { + "num": "16", + "street": "Dietmar-Hopp-Allee", + "locality": "Walldorf", + "code": "69190", + "country": "DE" + } + ], + "telephones": [{"num": "+49100200300"}] + }, + "customer": { + "name": "Sample Consumer", + "tax_id": { + "country": "DE", + "code": "282741168" + }, + "inboxes": [ + {"email": "billing@example.com"} + ], + "emails": [{"addr": "email@sample.com"}], + "addresses": [ + { + "num": "25", + "street": "Werner-Heisenberg-Allee", + "locality": "München", + "code": "80939", + "country": "DE" + } + ], + "telephones": [{"num": "+49100200300"}] + }, + "lines": [ + { + "quantity": "20", + "item": { + "name": "Development services", + "price": "90.00", + "unit": "h" + }, + "taxes": [ + {"cat": "VAT", "rate": "standard"} + ] + } + ], + "ordering": {"code": "12345"}, + "payment": { + "instructions": { + "key": "credit-transfer+sepa", + "credit_transfer": [ + {"iban": "DE89370400440532013000", "name": "Random Bank Co."} + ] + }, + "terms": {"detail": "Please pay within 10 days"} + } +} diff --git a/editor/examples/de.json b/editor/examples/de.json new file mode 100644 index 0000000..5d01af7 --- /dev/null +++ b/editor/examples/de.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "currency": "EUR", + "issue_date": "2026-01-15", + "series": "SAMPLE", + "code": "001", + "supplier": { + "name": "Provide One GmbH", + "tax_id": { + "country": "DE", + "code": "111111125" + }, + "addresses": [ + { + "num": "16", + "street": "Dietmar-Hopp-Allee", + "locality": "Walldorf", + "code": "69190", + "country": "DE" + } + ] + }, + "customer": { + "name": "Sample Consumer", + "tax_id": { + "country": "DE", + "code": "282741168" + }, + "addresses": [ + { + "num": "25", + "street": "Werner-Heisenberg-Allee", + "locality": "München", + "code": "80939", + "country": "DE" + } + ] + }, + "lines": [ + { + "quantity": "20", + "item": { + "name": "Development services", + "price": "90.00", + "unit": "h" + }, + "taxes": [ + { + "cat": "VAT", + "rate": "standard" + } + ] + } + ] +} diff --git a/editor/examples/es-tbai.json b/editor/examples/es-tbai.json new file mode 100644 index 0000000..ceb8619 --- /dev/null +++ b/editor/examples/es-tbai.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$addons": ["es-tbai-v1"], + "currency": "EUR", + "issue_date": "2026-01-15", + "series": "SAMPLE", + "code": "001", + "supplier": { + "name": "Provide One S.L.", + "tax_id": { + "country": "ES", + "code": "B98602642" + }, + "emails": [{"addr": "billing@example.com"}], + "addresses": [ + { + "num": "42", + "street": "San Frantzisko", + "locality": "Bilbo", + "region": "Bizkaia", + "code": "48003", + "country": "ES" + } + ] + }, + "customer": { + "name": "Sample Consumer", + "tax_id": { + "country": "ES", + "code": "54387763P" + } + }, + "lines": [ + { + "quantity": "20", + "item": { + "key": "services", + "name": "Development services", + "price": "90.00", + "unit": "h" + }, + "taxes": [ + {"cat": "VAT", "rate": "standard"} + ] + } + ], + "notes": [ + {"key": "general", "text": "Sample TicketBAI invoice"} + ] +} diff --git a/editor/examples/es-verifactu.json b/editor/examples/es-verifactu.json new file mode 100644 index 0000000..f6bb21b --- /dev/null +++ b/editor/examples/es-verifactu.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$addons": ["es-verifactu-v1"], + "currency": "EUR", + "issue_date": "2026-01-15", + "series": "SAMPLE", + "code": "001", + "supplier": { + "name": "Provide One S.L.", + "tax_id": { + "country": "ES", + "code": "B98602642" + }, + "emails": [{"addr": "billing@example.com"}], + "addresses": [ + { + "num": "42", + "street": "Calle Pradillo", + "locality": "Madrid", + "region": "Madrid", + "code": "28002", + "country": "ES" + } + ] + }, + "customer": { + "name": "Sample Consumer", + "tax_id": { + "country": "ES", + "code": "54387763P" + } + }, + "lines": [ + { + "quantity": "20", + "item": { + "name": "Development services", + "price": "90.00", + "unit": "h" + }, + "taxes": [ + {"cat": "VAT", "rate": "standard"} + ] + } + ], + "notes": [ + {"key": "general", "text": "Sample VERI*FACTU invoice"} + ] +} diff --git a/editor/examples/es.json b/editor/examples/es.json new file mode 100644 index 0000000..338a369 --- /dev/null +++ b/editor/examples/es.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "currency": "EUR", + "issue_date": "2026-01-15", + "series": "SAMPLE", + "code": "001", + "supplier": { + "name": "Provide One S.L.", + "tax_id": { + "country": "ES", + "code": "B98602642" + }, + "addresses": [ + { + "num": "42", + "street": "Calle Pradillo", + "locality": "Madrid", + "region": "Madrid", + "code": "28002", + "country": "ES" + } + ] + }, + "customer": { + "name": "Sample Consumer", + "tax_id": { + "country": "ES", + "code": "54387763P" + } + }, + "lines": [ + { + "quantity": "20", + "item": { + "name": "Development services", + "price": "90.00", + "unit": "h" + }, + "taxes": [ + {"cat": "VAT", "rate": "standard"} + ] + } + ] +} diff --git a/editor/examples/fr-facturx.json b/editor/examples/fr-facturx.json new file mode 100644 index 0000000..bc76a3c --- /dev/null +++ b/editor/examples/fr-facturx.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$addons": ["fr-facturx-v1"], + "currency": "EUR", + "issue_date": "2026-01-15", + "series": "SAMPLE", + "code": "001", + "supplier": { + "name": "Provide One SAS", + "tax_id": { + "country": "FR", + "code": "44732829320" + }, + "emails": [{"addr": "billing@example.com"}], + "addresses": [ + { + "num": "1", + "street": "Rue de Rivoli", + "locality": "Paris", + "code": "75001", + "country": "FR" + } + ] + }, + "customer": { + "name": "Sample Consumer", + "tax_id": { + "country": "FR", + "code": "356000000" + }, + "emails": [{"addr": "email@sample.com"}], + "addresses": [ + { + "num": "1", + "street": "Rue Sundacsakn", + "locality": "Saint-Germain-En-Laye", + "code": "75050", + "country": "FR" + } + ] + }, + "lines": [ + { + "quantity": "20", + "item": { + "name": "Development services", + "price": "90.00" + }, + "discounts": [ + {"percent": "10%", "reason": "Special discount"} + ], + "taxes": [ + {"cat": "VAT", "percent": "20%"} + ] + } + ], + "payment": { + "instructions": {"key": "card"}, + "terms": {"detail": "Please pay within 10 days"} + } +} diff --git a/editor/examples/fr.json b/editor/examples/fr.json new file mode 100644 index 0000000..db07664 --- /dev/null +++ b/editor/examples/fr.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "currency": "EUR", + "issue_date": "2026-01-15", + "series": "SAMPLE", + "code": "001", + "supplier": { + "name": "Provide One SAS", + "tax_id": { + "country": "FR", + "code": "44732829320" + }, + "addresses": [ + { + "num": "1", + "street": "Rue de Rivoli", + "locality": "Paris", + "code": "75001", + "country": "FR" + } + ] + }, + "customer": { + "name": "Sample Consumer", + "tax_id": { + "country": "FR", + "code": "356000000" + }, + "addresses": [ + { + "num": "1", + "street": "Rue Sundacsakn", + "locality": "Saint-Germain-En-Laye", + "code": "75050", + "country": "FR" + } + ] + }, + "lines": [ + { + "quantity": "20", + "item": { + "name": "Development services", + "price": "90.00" + }, + "taxes": [ + {"cat": "VAT", "percent": "20%"} + ] + } + ] +} diff --git a/editor/examples/gb.json b/editor/examples/gb.json new file mode 100644 index 0000000..9c1e074 --- /dev/null +++ b/editor/examples/gb.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "currency": "GBP", + "issue_date": "2026-01-15", + "series": "SAMPLE", + "code": "001", + "supplier": { + "name": "Provide One Ltd.", + "tax_id": { + "country": "GB", + "code": "844281425" + }, + "addresses": [ + { + "num": "10", + "street": "King's Road", + "locality": "London", + "code": "SW3 4UD", + "country": "GB" + } + ] + }, + "customer": { + "name": "Sample Customer", + "tax_id": { + "country": "GB" + } + }, + "lines": [ + { + "quantity": "20", + "item": { + "name": "Development services", + "price": "90.00", + "unit": "h" + }, + "taxes": [ + { + "cat": "VAT", + "percent": "20%" + } + ] + } + ] +} diff --git a/editor/examples/it-fatturapa.json b/editor/examples/it-fatturapa.json new file mode 100644 index 0000000..76d3032 --- /dev/null +++ b/editor/examples/it-fatturapa.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$addons": ["it-sdi-v1"], + "series": "FT", + "code": "001", + "currency": "EUR", + "issue_date": "2026-01-15", + "tax": { + "prices_include": "VAT" + }, + "type": "standard", + "supplier": { + "name": "Company Name S.r.l.", + "tax_id": { + "country": "IT", + "code": "12345678903" + }, + "registration": { + "capital": "50000.00", + "currency": "EUR", + "entry": "123456", + "office": "RM" + }, + "addresses": [ + { + "num": "102", + "street": "Via California", + "locality": "Palermo", + "region": "PA", + "code": "33213", + "country": "IT" + } + ] + }, + "customer": { + "name": "Monica Bellucci", + "tax_id": {"country": "IT"}, + "identities": [ + {"key": "it-fiscal-code", "code": "RSSGNN60R30H501U"} + ], + "addresses": [ + { + "num": "23", + "street": "Via dei Mille", + "locality": "Firenze", + "region": "FI", + "code": "00100", + "country": "IT" + } + ] + }, + "lines": [ + { + "quantity": "1", + "item": { + "name": "Cleaning services", + "price": "125.00" + }, + "taxes": [ + {"cat": "VAT", "rate": "standard"} + ] + } + ] +} diff --git a/editor/examples/nl.json b/editor/examples/nl.json new file mode 100644 index 0000000..a3d438b --- /dev/null +++ b/editor/examples/nl.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "currency": "EUR", + "issue_date": "2026-01-15", + "series": "SAMPLE", + "code": "001", + "supplier": { + "name": "Provide One B.V.", + "tax_id": { + "country": "NL", + "code": "000099995B57" + }, + "addresses": [ + { + "num": "55", + "street": "Herengracht", + "locality": "Amsterdam", + "code": "1015 BN", + "country": "NL" + } + ] + }, + "customer": { + "name": "Sample Consumer", + "tax_id": {"country": "NL"} + }, + "lines": [ + { + "quantity": "20", + "item": { + "name": "Development services", + "price": "90.00", + "unit": "h" + }, + "taxes": [ + {"cat": "VAT", "rate": "standard"} + ] + } + ] +} diff --git a/editor/examples/pt.json b/editor/examples/pt.json new file mode 100644 index 0000000..bbd3fe3 --- /dev/null +++ b/editor/examples/pt.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "currency": "EUR", + "issue_date": "2026-01-15", + "series": "SAMPLE", + "code": "001", + "supplier": { + "name": "Provide One Lda.", + "tax_id": { + "country": "PT", + "code": "503504924" + }, + "addresses": [ + { + "num": "12", + "street": "Rua Augusta", + "locality": "Lisboa", + "code": "1100-053", + "country": "PT" + } + ] + }, + "customer": { + "name": "Sample Consumer", + "tax_id": {"country": "PT"} + }, + "lines": [ + { + "quantity": "20", + "item": { + "name": "Development services", + "price": "90.00", + "unit": "h" + }, + "taxes": [ + {"cat": "VAT", "rate": "standard"} + ] + } + ] +} diff --git a/editor/examples/us.json b/editor/examples/us.json new file mode 100644 index 0000000..9474bf3 --- /dev/null +++ b/editor/examples/us.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "currency": "USD", + "issue_date": "2026-01-15", + "series": "SAMPLE", + "code": "001", + "supplier": { + "name": "Acme Inc.", + "tax_id": { + "country": "US" + }, + "addresses": [ + { + "street": "123 Market Street", + "locality": "San Francisco", + "region": "CA", + "code": "94103", + "country": "US" + } + ] + }, + "customer": { + "name": "Sample Customer" + }, + "lines": [ + { + "quantity": "10", + "item": { + "name": "Development Services", + "price": "100.00" + }, + "taxes": [ + { + "cat": "ST", + "percent": "8.25%" + } + ] + } + ] +} diff --git a/editor/geo.go b/editor/geo.go new file mode 100644 index 0000000..f27d53d --- /dev/null +++ b/editor/geo.go @@ -0,0 +1,52 @@ +package editor + +import ( + "strings" + + "github.com/invopop/gobl.dev/editor/examples" +) + +// pickExampleFromAcceptLanguage walks an Accept-Language header in order of +// appearance (ignoring q-weights, close enough for a default picker) and +// returns the first curated example whose country matches a tag's region. +// Falls back to the US example if no tag yields a match. +func pickExampleFromAcceptLanguage(header string) examples.Example { + for _, raw := range strings.Split(header, ",") { + tag := strings.TrimSpace(raw) + if i := strings.Index(tag, ";"); i >= 0 { + tag = tag[:i] + } + region := regionFromTag(tag) + if region == "" { + continue + } + for _, e := range examples.All() { + if e.Country == region { + return e + } + } + } + return examples.DefaultFor("US") +} + +// regionFromTag extracts the ISO-3166-1 alpha-2 region code from a BCP-47 tag +// like "en-GB" or "es-419". Returns the region in upper-case, or an empty +// string if no two-letter region is present. +func regionFromTag(tag string) string { + parts := strings.Split(tag, "-") + for _, p := range parts[1:] { + if len(p) == 2 && isAlpha(p) { + return strings.ToUpper(p) + } + } + return "" +} + +func isAlpha(s string) bool { + for _, r := range s { + if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')) { + return false + } + } + return true +} diff --git a/editor/geo_test.go b/editor/geo_test.go new file mode 100644 index 0000000..f13c220 --- /dev/null +++ b/editor/geo_test.go @@ -0,0 +1,31 @@ +package editor + +import "testing" + +func TestPickExampleFromAcceptLanguage(t *testing.T) { + cases := []struct { + name string + header string + wantID string + }{ + {"empty falls back to US", "", "us"}, + {"nonsense falls back to US", "xx,yy", "us"}, + {"tag without region", "en", "us"}, + {"en-US", "en-US", "us"}, + {"it-IT maps to IT example", "it-IT", "it-fatturapa"}, + {"es-ES maps to first ES variant", "es-ES", "es"}, + {"de-DE maps to DE plain", "de-DE", "de"}, + {"fr-FR maps to FR plain", "fr-FR", "fr"}, + {"fallback through list", "xx,de-DE,fr-FR", "de"}, + {"q-suffix stripped", "en-GB;q=0.9,fr-FR;q=0.8", "gb"}, + {"region in third position", "zh-Hans-NL", "nl"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := pickExampleFromAcceptLanguage(c.header) + if got.ID != c.wantID { + t.Fatalf("got %q, want %q", got.ID, c.wantID) + } + }) + } +} diff --git a/go.mod b/go.mod index 8d44145..80d5650 100644 --- a/go.mod +++ b/go.mod @@ -9,10 +9,13 @@ require ( github.com/invopop/gobl v0.502.2 github.com/invopop/gobl.br.nfe v0.0.1 github.com/invopop/gobl.br.nfse v0.0.1 + github.com/invopop/gobl.cii v0.39.0 github.com/invopop/gobl.fr.ctc v0.0.4 - github.com/invopop/gobl.mx.cfdi v0.61.0 - github.com/invopop/gobl.pt.saft v0.0.1 + github.com/invopop/gobl.html v0.101.0 + github.com/invopop/gobl.mx.cfdi v0.62.0 + github.com/invopop/gobl.pt.saft v0.0.5 github.com/invopop/gobl.sa.zatca v0.0.2 + github.com/invopop/gobl.ubl v0.56.0 github.com/invopop/icons v0.14.0 github.com/invopop/popui.go v0.30.0 github.com/invopop/yaml v0.3.1 @@ -20,16 +23,17 @@ require ( github.com/mark3labs/mcp-go v0.46.0 github.com/spf13/cobra v1.9.1 github.com/stretchr/testify v1.11.1 - gitlab.com/flimzy/testy v0.14.0 + gitlab.com/flimzy/testy v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - cloud.google.com/go v0.118.0 // indirect + cloud.google.com/go v0.119.0 // indirect github.com/Masterminds/semver/v3 v3.3.1 // indirect github.com/Oudwins/tailwind-merge-go v0.2.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/beevik/etree v1.6.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/expr-lang/expr v1.17.8 // indirect @@ -37,22 +41,31 @@ require ( github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/invopop/ctxi18n v0.9.0 // indirect github.com/invopop/jsonschema v0.14.0 // indirect + github.com/invopop/validation v0.8.0 // indirect + github.com/invopop/xmlctx v0.13.0 // indirect + github.com/invopop/xmldsig v0.14.0 // indirect + github.com/jonboulle/clockwork v0.5.0 // indirect github.com/labstack/echo/v4 v4.15.0 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/pb33f/ordered-map/v2 v2.3.1 // indirect + github.com/piglig/go-qr v0.2.4 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/russellhaering/goxmldsig v1.6.0 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + github.com/yuin/goldmark v1.4.13 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect golang.org/x/crypto v0.53.0 // indirect golang.org/x/net v0.56.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect + software.sslmate.com/src/go-pkcs12 v0.7.0 // indirect ) diff --git a/go.sum b/go.sum index c023849..6127302 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -cloud.google.com/go v0.118.0 h1:tvZe1mgqRxpiVa3XlIGMiPcEUbP1gNXELgD4y/IXmeQ= -cloud.google.com/go v0.118.0/go.mod h1:zIt2pkedt/mo+DQjcT4/L3NDxzHPR29j5HcclNH+9PM= +cloud.google.com/go v0.119.0 h1:tw7OjErMzJKbbjaEHkrt60KQrK5Wus/boCZ7tm5/RNE= +cloud.google.com/go v0.119.0/go.mod h1:fwB8QLzTcNevxqi8dcpR+hoMIs3jBherGS9VUBDAW08= github.com/LastPossum/kamino v0.0.2 h1:Zry5lS7x7TTU1hzzk3Utnp+rX8kk/wWhuW52Ha9As+U= github.com/LastPossum/kamino v0.0.2/go.mod h1:H8Qm+6DGeNOoXk9hHIOEAQWS9nbo0YwK32pC/7REsOE= github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= @@ -12,6 +12,8 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/beevik/etree v1.6.0 h1:u8Kwy8pp9D9XeITj2Z0XtA5qqZEmtJtuXZRQi+j03eE= +github.com/beevik/etree v1.6.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sLc0Gc= github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -33,28 +35,46 @@ github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/invopop/ctxi18n v0.9.0 h1:BIia4u4OngaHVn/7gvK0w6lccOXVtad8xU0KgJ+mnVA= +github.com/invopop/ctxi18n v0.9.0/go.mod h1:1Osw+JGYA+anHt0Z4reF36r5FtGHYjGQ+m1X7keIhPc= github.com/invopop/gobl v0.502.2 h1:guH++uYsy5RjCjn7qGNwFq1xWLceNx0Pmjz/Fm2KCRw= github.com/invopop/gobl v0.502.2/go.mod h1:HmiEdQreTSQYyNbhs81VKTmI7BAJKYC/6enh9RDwnE0= github.com/invopop/gobl.br.nfe v0.0.1 h1:ywJycz2wiyeNygbRhuRJg52gTnIECsbZ5+pGFqh3eyE= github.com/invopop/gobl.br.nfe v0.0.1/go.mod h1:ZIJSVz5257xciD+RkBhyvqjtS46Pgw23idTca6A8ZIk= github.com/invopop/gobl.br.nfse v0.0.1 h1:BdwNiG7vk7bPMgE4m102P+UysOxmYWhOKIDfFI6RKEA= github.com/invopop/gobl.br.nfse v0.0.1/go.mod h1:m22voo72ZScRiwIS49wXhzGRahzBO+IEAN0M0Kij3QI= +github.com/invopop/gobl.cii v0.39.0 h1:1fT1xvUisSPXwi5wdBVSEwpJo4wgix6ADJ0jXhQpc2Q= +github.com/invopop/gobl.cii v0.39.0/go.mod h1:/uL78fZ5KjE3DcSFsmvNb+mZ6i0m8U1b2Rx14d+AXbc= github.com/invopop/gobl.fr.ctc v0.0.4 h1:x4eJ3hp9Y9lTCzWFhNqZYm9kCXyuC34EytOuaU03faE= github.com/invopop/gobl.fr.ctc v0.0.4/go.mod h1:YXJ0G7lCWxUehI4C2xBPL1y6rTNC29t/kOul7wY+3Xs= -github.com/invopop/gobl.mx.cfdi v0.61.0 h1:f/rtRl5mIgePWQxjLB1UfsU45dQp/4/ZxJjsHGGtjiA= -github.com/invopop/gobl.mx.cfdi v0.61.0/go.mod h1:2ag6z2QhCMltl5YcxL9yL8IzY+mXuOJe39yhk9YWTsM= -github.com/invopop/gobl.pt.saft v0.0.1 h1:sXG+HsiN8rZVAxSvv33i/5+8QM8UJbadklmZZUkA5dM= -github.com/invopop/gobl.pt.saft v0.0.1/go.mod h1:0itJS6OpV+2BcaD4xRwEf4gEOcVeGdrfoghwxB+UWrg= +github.com/invopop/gobl.html v0.101.0 h1:gD+7RsWhwA9avdgsJ21jr99PJXGxKAKAiXriYOaAfY8= +github.com/invopop/gobl.html v0.101.0/go.mod h1:1OjBWuOZcMlui6ufbdP7yz43LHaithVMgxKAMllgg+M= +github.com/invopop/gobl.mx.cfdi v0.62.0 h1:Uwh5wbwiTNHJ9fWOK72ma/xVoJMdPvdMf3VI57PjKRw= +github.com/invopop/gobl.mx.cfdi v0.62.0/go.mod h1:sXy7JIXauSKRqT0HgHsFICIV9YXuSHK3Quq9uZWZ9L0= +github.com/invopop/gobl.pt.saft v0.0.5 h1:Hc2Sbd280fDgPi9FznI/Xi95lAMzG8A4YEp6SW4H6uY= +github.com/invopop/gobl.pt.saft v0.0.5/go.mod h1:z5L+dLFBx6PmEdacnFWehYWgiYZSM2HaPzx6ld+DnQs= github.com/invopop/gobl.sa.zatca v0.0.2 h1:qU2Y0LP+4mjvZkuG1TOUp/Zs0XxRAzkzQFHy3WOVFEU= github.com/invopop/gobl.sa.zatca v0.0.2/go.mod h1:tmLCoaKb4X7sZ2zRU8LalA1sgxM2kQv/uqMzZslhE6A= +github.com/invopop/gobl.ubl v0.56.0 h1:v3nF0HAysOLCDnDMcTS3qnZCFdibeWq4cpZEZZ5+WAs= +github.com/invopop/gobl.ubl v0.56.0/go.mod h1:R6+ENuOExqbwayTHblx+xwZg2gnjHpLiIytX5BIvW2o= github.com/invopop/icons v0.14.0 h1:Bk0hF+EI/x3XwWX1IqMeFDMdu766zY3zFBqTkImCdhU= github.com/invopop/icons v0.14.0/go.mod h1:rXrrY2Rz7Z7KINyQIPMaauqXTZNhVMDI2MYIiux436Y= github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg= github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I= +github.com/invopop/phive v0.6.0 h1:wtk5+ieD/muViF6SJXGGVA/vYPzed9NoaCl63TjmWU4= +github.com/invopop/phive v0.6.0/go.mod h1:2Njf8Ci6tjfZkvq7VfdX5Esjx4Q3lzETciSFZ2afKFA= github.com/invopop/popui.go v0.30.0 h1:Yx91iSDm7HW2xvGUQCdrRm+HmK1RuA7jxn26+EDbCTg= github.com/invopop/popui.go v0.30.0/go.mod h1:2YoksZOz2hkSuSF+F2FUJVlnviPlAwmvu/UpD2hJww8= +github.com/invopop/validation v0.8.0 h1:e5hXHGnONHImgJdonIpNbctg1hlWy1ncaHoVIQ0JWuw= +github.com/invopop/validation v0.8.0/go.mod h1:nLLeXYPGwUNfdCdJo7/q3yaHO62LSx/3ri7JvgKR9vg= +github.com/invopop/xmlctx v0.13.0 h1:ZNRMC0O/A5h8InoLVSA7tIjjrhJn/NDBYfByBUpSb+g= +github.com/invopop/xmlctx v0.13.0/go.mod h1:xZ3Bdf0jq2GcjN6QNroUa+l37kyXDXmDEEKiBj9NAl0= +github.com/invopop/xmldsig v0.14.0 h1:ROwf32DZtX2EekrrOSjLLiY0s2kPEcqHZculITlZfAw= +github.com/invopop/xmldsig v0.14.0/go.mod h1:oWDotOqdKbbwmfA1B057TVsNZW/V1XgoXsRgrW5by18= github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso= github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -73,12 +93,16 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY= github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= +github.com/piglig/go-qr v0.2.4 h1:G/fY3/Oq0NI1oc0lEhBv75QXUtIW/FmcL9l8D1jIo1M= +github.com/piglig/go-qr v0.2.4/go.mod h1:funyXL4IdgMPcbICoVm1XweMtZy7Px3kyITTENkmA5w= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russellhaering/goxmldsig v1.6.0 h1:8fdWXEPh2k/NZNQBPFNoVfS3JmzS4ZprY/sAOpKQLks= +github.com/russellhaering/goxmldsig v1.6.0/go.mod h1:TrnaquDcYxWXfJrOjeMBTX4mLBeYAqaHEyUeWPxZlBM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= @@ -94,8 +118,12 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= -gitlab.com/flimzy/testy v0.14.0 h1:2nZV4Wa1OSJb3rOKHh0GJqvvhtE03zT+sKnPCI0owfQ= -gitlab.com/flimzy/testy v0.14.0/go.mod h1:m3aGuwdXc+N3QgnH+2Ar2zf1yg0UxNdIaXKvC5SlfMk= +github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4 h1:0sw0nJM544SpsihWx1bkXdYLQDlzRflMgFJQ4Yih9ts= +github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4/go.mod h1:+ccdNT0xMY1dtc5XBxumbYfOUhmduiGudqaDgD2rVRE= +github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +gitlab.com/flimzy/testy v0.15.0 h1:69TL12IpxqGUyL8NuRV3Z5OhIDszXLNqLtfBDhOV3ys= +gitlab.com/flimzy/testy v0.15.0/go.mod h1:KbAJWCwB++0hEFzeeQRbC7vdZYP/yEha94s4X1wVFrw= go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= @@ -107,8 +135,17 @@ golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +software.sslmate.com/src/go-pkcs12 v0.7.0 h1:Db8W44cB54TWD7stUFFSWxdfpdn6fZVcDl0w3R4RVM0= +software.sslmate.com/src/go-pkcs12 v0.7.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=