Skip to content

Commit 8ccd403

Browse files
committed
Add a Playground page that formats Java in the browser
The page has the code on the left and the result on the right, and formats with the formatter compiled to WebAssembly, so the code never leaves the browser and the site still needs no server (openjavaformat/open-java-format#21). The result is what the command line writes for the same input. The module is not in this repository. scripts/fetch-playground.sh downloads open-java-format-wasm-<version>.zip from the GitHub release that ojf_version names, checks it against the release's checksums and unpacks it next to the page. docs.yml runs it before the build, and so do mise run serve and build. A release without the zip is a warning, not an error: the page then says that the playground comes with the next release, and follow-releases.yml turns it on with the redeploy it already runs for that release. A download that fails its checksum fails the build. The script comes through extra_javascript and sets the page up through document$, since the site navigates without reloading. The module, 8 MB over the wire, loads on this page only, once per visit. A browser without WebAssembly GC gets a sentence and a link to the command line instead of an empty pane.
1 parent 0fa5d71 commit 8ccd403

9 files changed

Lines changed: 399 additions & 4 deletions

File tree

‎.github/workflows/docs.yml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ jobs:
3030
- name: Install Zensical
3131
run: pip install -r requirements.txt
3232

33+
# The playground's formatter: the WebAssembly module of the release that ojf_version names. A release
34+
# without one leaves the page saying that the playground comes with the next release.
35+
- name: Fetch the playground's formatter
36+
run: ./scripts/fetch-playground.sh
37+
3338
# Strict mode fails the build on a broken link or anchor. That check is what makes a pull
3439
# request opened from the "Edit this page" button safe to merge from the browser.
3540
- name: Build the site

‎.gitignore‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,7 @@
66
/.venv/
77

88
.DS_Store
9+
10+
# The playground's formatter, which scripts/fetch-playground.sh downloads from the release
11+
/docs/playground/open-java-format.js
12+
/docs/playground/open-java-format.js.wasm

‎README.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,21 @@ zensical serve
3535
The preview runs on <http://localhost:8000> and reloads on save. `mise run build` runs the same
3636
clean, strict build as CI.
3737

38+
## The playground
39+
40+
The Playground page formats code in the browser with the formatter compiled to WebAssembly. The module
41+
is not in this repository: `scripts/fetch-playground.sh` downloads it from the GitHub release that
42+
`ojf_version` names, checks it against that release's checksums, and unpacks it next to the page.
43+
`docs.yml` runs it before every build, and `mise run serve` and `mise run build` run it through
44+
`mise run playground`. A release without the module is a warning, not an error: the page then says
45+
that the playground comes with the next release.
46+
47+
To try a module built locally, point `OJF_WASM_ZIP` at its zip:
48+
49+
```bash
50+
OJF_WASM_ZIP=../open-java-format/open-java-format-wasm/build/distributions/open-java-format-wasm-<version>.zip mise run serve
51+
```
52+
3853
## Publishing
3954

4055
A push to `main` runs `.github/workflows/docs.yml`, which builds the site and deploys it to GitHub

‎docs/javascripts/playground.js‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// The Playground page, docs/playground/index.md: formats the code on the left into the pane on the right, in the
2+
// browser. The formatter is a WebAssembly module, open-java-format.js and open-java-format.js.wasm, which the build
3+
// puts next to the page; it is the one the release named by ojf_version carries.
4+
//
5+
// Loaded on every page through extra_javascript, and it does nothing on a page without #playground. The site
6+
// navigates without reloading (navigation.instant), so the page is set up on every arrival through document$,
7+
// while the module, which defines globals, is loaded once per visit.
8+
9+
(function () {
10+
"use strict";
11+
12+
// The smallest module with a WebAssembly GC type, a struct of one i8: what browsers without GC reject.
13+
const WASM_GC_PROBE = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 95, 1, 120, 0]);
14+
15+
// Settles with globalThis.openJavaFormat, or rejects with "missing" when the site has no module, or the error.
16+
let module = null;
17+
18+
function supportsWasmGc() {
19+
try {
20+
return typeof WebAssembly === "object" && WebAssembly.validate(WASM_GC_PROBE);
21+
} catch {
22+
return false;
23+
}
24+
}
25+
26+
function loadModule(wrapperUrl) {
27+
if (module === null) {
28+
module = new Promise((resolve, reject) => {
29+
const script = document.createElement("script");
30+
script.src = wrapperUrl;
31+
script.onload = resolve;
32+
script.onerror = () => {
33+
// Not there at all is the release without a module; anything else is a failed download.
34+
fetch(wrapperUrl, { method: "HEAD" }).then(
35+
(response) => reject(response.status === 404 ? "missing" : new Error(`HTTP ${response.status}`)),
36+
reject,
37+
);
38+
};
39+
document.head.append(script);
40+
})
41+
.then(() => {
42+
// The wrapper does not start the module by itself (-H:-AutoRunVM): GraalVM.run settles once it has.
43+
const config = new GraalVM.Config();
44+
config.wasm_path = `${wrapperUrl}.wasm`;
45+
return GraalVM.run([], config);
46+
})
47+
.then(() => globalThis.openJavaFormat);
48+
// A failed load is tried again on the next visit to the page; "missing" stays missing until a reload.
49+
module.catch((error) => {
50+
if (error !== "missing") {
51+
module = null;
52+
}
53+
});
54+
}
55+
return module;
56+
}
57+
58+
function setUp(root) {
59+
const source = root.querySelector("#ojf-source");
60+
const output = root.querySelector("#ojf-output");
61+
const button = root.querySelector("#ojf-format");
62+
const copy = root.querySelector("#ojf-copy");
63+
64+
const show = (state) => {
65+
root.dataset.state = state;
66+
for (const message of root.querySelectorAll(".ojf-playground__status [data-state]")) {
67+
message.hidden = message.dataset.state !== state;
68+
}
69+
};
70+
71+
// "macOS" from userAgentData, "MacIntel" or "iPhone" from the older navigator.platform.
72+
if (/mac|iphone|ipad/i.test(navigator.userAgentData?.platform ?? navigator.platform)) {
73+
root.querySelector("#ojf-modifier").textContent = "⌘";
74+
}
75+
76+
if (!supportsWasmGc()) {
77+
show("unsupported");
78+
return;
79+
}
80+
81+
show("loading");
82+
loadModule(new URL("open-java-format.js", document.location.href).href).then(
83+
(openJavaFormat) => {
84+
const format = () => {
85+
const started = performance.now();
86+
const result = openJavaFormat.format(source.value);
87+
const millis = performance.now() - started;
88+
output.value = result.ok ? result.output : result.error;
89+
output.classList.toggle("ojf-playground__output--error", !result.ok);
90+
copy.hidden = !result.ok;
91+
if (result.ok) {
92+
root.querySelector("[data-millis]").textContent = Math.max(1, Math.round(millis)).toString();
93+
show("formatted");
94+
} else {
95+
// A diagnostic starts with line:column; anything else is an exception from the formatter.
96+
show(/^\d+:\d+: /.test(result.error) ? "error" : "bug");
97+
}
98+
};
99+
button.addEventListener("click", format);
100+
source.addEventListener("keydown", (event) => {
101+
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
102+
event.preventDefault();
103+
format();
104+
}
105+
});
106+
copy.addEventListener("click", () => {
107+
navigator.clipboard.writeText(output.value).then(() => {
108+
copy.textContent = "Copied";
109+
setTimeout(() => (copy.textContent = "Copy"), 1500);
110+
});
111+
});
112+
button.disabled = false;
113+
show("ready");
114+
},
115+
(error) => {
116+
if (error !== "missing") {
117+
console.error("open-java-format did not load", error);
118+
}
119+
show(error === "missing" ? "missing" : "failed");
120+
},
121+
);
122+
}
123+
124+
document$.subscribe(() => {
125+
const root = document.getElementById("playground");
126+
if (root !== null) {
127+
setUp(root);
128+
}
129+
});
130+
})();

‎docs/playground/index.md‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
---
2+
title: Playground
3+
description: Paste Java code and see how open-java-format lays it out. It runs in your browser, nothing is sent anywhere.
4+
hide:
5+
- navigation
6+
- toc
7+
---
8+
9+
# Playground
10+
11+
Paste Java code on the left and press **Format**. The formatter runs in your browser, compiled to
12+
WebAssembly from the same release that [the other pages](../get-started/index.md) install: the code is
13+
not sent anywhere, and the result is what the command line would write.
14+
15+
<div id="playground" class="ojf-playground" markdown>
16+
17+
<div class="ojf-playground__pane">
18+
<label for="ojf-source">Your code</label>
19+
<textarea id="ojf-source" spellcheck="false" autocapitalize="off" autocomplete="off" wrap="off">
20+
class Example {
21+
private static void configureResolvedVersionsWithVersionMapping(Project project) {
22+
project.getPluginManager()
23+
.withPlugin(
24+
"maven-publish",
25+
plugin -> {
26+
project.getExtensions()
27+
.getByType(PublishingExtension.class)
28+
.getPublications()
29+
.withType(MavenPublication.class)
30+
.configureEach(
31+
publication ->
32+
publication.versionMapping(
33+
mapping -> {
34+
mapping.allVariants(
35+
VariantVersionMappingStrategy
36+
::fromResolutionResult);
37+
}));
38+
});
39+
}
40+
}
41+
</textarea>
42+
</div>
43+
44+
<div class="ojf-playground__pane">
45+
<label for="ojf-output">open-java-format {{ ojf_version }}</label>
46+
<textarea id="ojf-output" spellcheck="false" wrap="off" readonly></textarea>
47+
<button id="ojf-copy" class="ojf-playground__copy" type="button" hidden>Copy</button>
48+
</div>
49+
50+
<div class="ojf-playground__bar" markdown>
51+
52+
<button id="ojf-format" class="md-button md-button--primary" type="button" disabled>Format</button>
53+
<span class="ojf-playground__keys"><kbd id="ojf-modifier">Ctrl</kbd>+<kbd>Enter</kbd></span>
54+
55+
<div class="ojf-playground__status" role="status" aria-live="polite" markdown>
56+
57+
<p data-state="loading">Loading the formatter…</p>
58+
<p data-state="ready" hidden>Ready.</p>
59+
<p data-state="formatted" hidden>Formatted in <span data-millis></span> ms.</p>
60+
<p data-state="error" hidden>The code does not parse; the result pane says where.</p>
61+
<p data-state="bug" hidden>The formatter failed on this code. That is a bug: please
62+
<a href="https://github.com/openjavaformat/open-java-format/issues/new">report it</a> with the code.</p>
63+
64+
<div data-state="missing" hidden markdown>
65+
The playground comes with the next release of open-java-format: {{ ojf_version }}, the release this
66+
site installs, has no WebAssembly build yet. Until then the [command line](../get-started/command-line.md)
67+
gives the same result.
68+
</div>
69+
70+
<div data-state="unsupported" hidden markdown>
71+
This browser cannot run the formatter: it needs WebAssembly garbage collection, which current Chrome,
72+
Edge, Firefox and Safari have. The [command line](../get-started/command-line.md) gives the same result.
73+
</div>
74+
75+
<div data-state="failed" hidden markdown>
76+
The formatter did not load. Reload the page to try again, or use the
77+
[command line](../get-started/command-line.md), which gives the same result.
78+
</div>
79+
80+
</div>
81+
82+
</div>
83+
84+
</div>

‎docs/stylesheets/playground.css‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/* The Playground page, docs/playground/index.md: the code and the result side by side, stacked on a narrow screen.
2+
Colours and fonts are the theme's, so both palettes work. */
3+
4+
.ojf-playground {
5+
display: grid;
6+
grid-template-columns: 1fr;
7+
gap: 1rem;
8+
}
9+
10+
/* The theme's own breakpoint for a wide screen. */
11+
@media screen and (min-width: 60em) {
12+
.ojf-playground {
13+
grid-template-columns: 1fr 1fr;
14+
}
15+
}
16+
17+
.ojf-playground__pane {
18+
position: relative;
19+
display: flex;
20+
flex-direction: column;
21+
min-width: 0;
22+
}
23+
24+
.ojf-playground__pane label {
25+
font-weight: 700;
26+
margin-bottom: 0.4rem;
27+
}
28+
29+
.ojf-playground textarea {
30+
box-sizing: border-box;
31+
width: 100%;
32+
height: 45vh;
33+
min-height: 16rem;
34+
padding: 0.8em 1.2em;
35+
border: 1px solid var(--md-default-fg-color--lightest);
36+
border-radius: 0.1rem;
37+
background-color: var(--md-code-bg-color);
38+
color: var(--md-code-fg-color);
39+
font-family: var(--md-code-font-family);
40+
font-size: 0.75rem;
41+
line-height: 1.4;
42+
tab-size: 4;
43+
white-space: pre;
44+
overflow: auto;
45+
resize: none;
46+
}
47+
48+
/* Side by side, the panes can take most of the window; stacked, two of them have to fit. */
49+
@media screen and (min-width: 60em) {
50+
.ojf-playground textarea {
51+
height: min(70vh, 40rem);
52+
}
53+
}
54+
55+
.ojf-playground textarea:focus {
56+
outline: 2px solid var(--md-accent-fg-color);
57+
outline-offset: -1px;
58+
}
59+
60+
.ojf-playground .ojf-playground__output--error {
61+
color: var(--md-typeset-del-color, #d32f2f);
62+
}
63+
64+
.ojf-playground__copy {
65+
position: absolute;
66+
top: 2.2rem;
67+
right: 0.6rem;
68+
padding: 0.1rem 0.5rem;
69+
border: 1px solid var(--md-default-fg-color--lightest);
70+
border-radius: 0.1rem;
71+
background-color: var(--md-default-bg-color);
72+
color: var(--md-default-fg-color--light);
73+
font-size: 0.7rem;
74+
cursor: pointer;
75+
}
76+
77+
.ojf-playground__bar {
78+
grid-column: 1 / -1;
79+
display: flex;
80+
flex-wrap: wrap;
81+
align-items: center;
82+
gap: 0.6rem 1rem;
83+
}
84+
85+
.ojf-playground__bar .md-button {
86+
margin: 0;
87+
}
88+
89+
.ojf-playground__bar .md-button:disabled {
90+
opacity: 0.5;
91+
cursor: progress;
92+
}
93+
94+
.ojf-playground__keys {
95+
color: var(--md-default-fg-color--light);
96+
font-size: 0.75rem;
97+
}
98+
99+
.ojf-playground__status {
100+
flex: 1 1 20rem;
101+
}
102+
103+
.ojf-playground__status > * {
104+
margin: 0;
105+
}

‎mise.toml‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,18 @@ _.python.venv = { path = ".venv", create = true }
1111
description = "Install the Zensical version pinned in requirements.txt into .venv"
1212
run = "pip install --quiet --disable-pip-version-check -r requirements.txt"
1313

14+
# The playground's formatter, the WebAssembly module of ojf_version, into docs/playground/. OJF_WASM_ZIP=<zip>
15+
# takes a module built locally instead: ./gradlew -Pwasm=true :open-java-format-wasm:wasmZip in the formatter.
16+
[tasks.playground]
17+
description = "Put the playground's formatter into docs/playground/, from the release or from $OJF_WASM_ZIP"
18+
run = "./scripts/fetch-playground.sh"
19+
1420
[tasks.serve]
1521
description = "Preview the site on http://localhost:8000 with live reload"
16-
depends = ["install"]
22+
depends = ["install", "playground"]
1723
run = "zensical serve"
1824

1925
[tasks.build]
2026
description = "Build the site into site/ the same way CI does: clean and strict"
21-
depends = ["install"]
27+
depends = ["install", "playground"]
2228
run = "zensical build --clean --strict"

0 commit comments

Comments
 (0)