Skip to content
Draft
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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
114 changes: 114 additions & 0 deletions editor/assets/editor-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,82 @@
// 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,
error: null,
// 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") {
e.preventDefault();
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() {
Expand Down Expand Up @@ -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", () => ({
Expand All @@ -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 {};
}
}
9 changes: 8 additions & 1 deletion editor/assets/editor.css
Original file line number Diff line number Diff line change
@@ -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%;
}
Expand Down
105 changes: 61 additions & 44 deletions editor/assets/editor.js
Original file line number Diff line number Diff line change
@@ -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 <head>; 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;
Expand All @@ -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) => {
Expand All @@ -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();
Loading