Skip to content

Commit 6a8a413

Browse files
committed
use <codeblock>, refactor
1 parent 5004774 commit 6a8a413

21 files changed

Lines changed: 1391 additions & 570 deletions

‎README.md‎

Lines changed: 94 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -1,164 +1,145 @@
1-
# clangd browser runtime
1+
# clangd in the browser
22

3-
This project packages Monaco and the WebAssembly build of clangd as a reusable browser component. It creates an editor only inside the element supplied by the host page; it does not use an iframe, register a service worker, replace page-level DOM, or own Run/output UI.
3+
This package turns ordinary `<codeblock>` tags into editable, runnable C++ examples. It starts with a small textarea editor, upgrades to Monaco when the main bundle is ready, and runs a WebAssembly build of clangd for code help. Multiple blocks on one page share the same runtime, worker, and clangd download.
44

5-
## Use the hosted runtime
5+
## Add a code block
66

7-
The preferred deployment uses separate origins:
7+
Copy the complete contents of `dist/` to one public directory, then include one stylesheet and one classic script:
88

9-
```text
10-
https://www.example.com/ host page
11-
https://clangd.example.com/ contents of dist/
9+
```html
10+
<link rel="stylesheet" href="./codeblocks.css" />
11+
<script src="./codeblocks.js"></script>
12+
13+
<codeblock compiler="gsnapshot" args="-std=c++26 -freflection">
14+
#include &lt;print&gt;
15+
int main() {
16+
std::println("foo {} bar", 42);
17+
}
18+
</codeblock>
1219
```
1320

14-
The host can be ordinary HTML and JavaScript:
21+
Angle brackets in source code must use normal HTML escaping, such as `&lt;print&gt;`, because the code is embedded in an HTML document.
1522

16-
```html
17-
<div class="editor-shell">
18-
<textarea id="fallback">int main() { return 0; }</textarea>
19-
<div id="monaco-host"></div>
20-
</div>
21-
22-
<script type="module">
23-
const fallback = document.querySelector("#fallback");
24-
const host = document.querySelector("#monaco-host");
25-
const { createCppEditor } =
26-
await import("https://clangd.example.com/editor.js");
27-
28-
const editor = await createCppEditor({
29-
element: host,
30-
value: fallback.value,
31-
theme: "dark",
32-
onStatus(status) {
33-
console.log(status);
34-
},
35-
});
23+
Add as many `<codeblock>` elements as needed. Tags inserted later are upgraded automatically. Monaco, clangd, and the WebAssembly file are initialized once per page.
3624

37-
// Capture edits made while Monaco loaded, then swap only the editor nodes.
38-
editor.setValue(fallback.value);
39-
host.style.visibility = "visible";
40-
editor.layout();
41-
fallback.remove();
25+
A block can also contain multiple tabbed sources:
4226

43-
await editor.clangdReady;
44-
</script>
27+
```html
28+
<codeblock compiler="clang2110" args="-std=c++23" height="360px">
29+
<codeblock-tab name="first.cpp">
30+
int main() { return 1; }
31+
</codeblock-tab>
32+
<codeblock-tab name="second.cpp">
33+
int main() { return 2; }
34+
</codeblock-tab>
35+
</codeblock>
4536
```
4637

47-
`createCppEditor()` resolves as soon as Monaco is usable. clangd downloads and starts independently; `editor.clangdReady` resolves after the language client connects and rejects if clangd is unavailable. A missing cross-origin-isolation policy affects clangd only; Monaco remains usable.
48-
49-
The returned component API is:
38+
Each tab keeps its own source. Run compiles the active tab. Use separate `<codeblock>` elements when examples need different compiler settings.
5039

51-
```js
52-
editor.getValue();
53-
editor.setValue(source);
54-
editor.focus();
55-
editor.layout();
56-
editor.dispose();
57-
const unsubscribe = editor.onDidChange((source) => {});
58-
await editor.clangdReady;
59-
```
40+
Supported attributes are:
6041

61-
### Lightweight fallback
42+
- `compiler`: Compiler Explorer compiler ID, defaulting to `clang2110`.
43+
- `args`: arguments sent unchanged to Compiler Explorer.
44+
- `theme`: `auto`, `light`, or `dark`.
45+
- `debug`: show the basic/full editor and light/dark switches. These are hidden by default.
46+
- `status`: show the optional user-facing code-help status.
47+
- `width`: any valid CSS width for the complete block.
48+
- `height`: any valid CSS height for the editor area, such as `280px`, `40vh`, or `clamp(240px, 50vh, 600px)`.
49+
- `min-height`: any valid CSS minimum height for the editor area.
6250

63-
The fallback editor is a separate, small public module and does not load Monaco:
51+
## JavaScript API
6452

65-
```js
66-
const { createCppFallbackEditor } =
67-
await import("https://clangd.example.com/fallback.js");
53+
`codeblocks.js` creates `globalThis.CodeBlocks` immediately. Its `ready` promise resolves after the module API has loaded:
6854

69-
const fallback = createCppFallbackEditor({
70-
element: document.querySelector(".fallback-host"),
71-
value: "int main() { return 0; }",
72-
});
55+
```html
56+
<script>
57+
CodeBlocks.configure({
58+
theme: "dark",
59+
editorOptions: {
60+
fontSize: 15,
61+
wordWrap: "on",
62+
},
63+
styles: {
64+
"editor-height": "360px",
65+
"accent": "#7c3aed",
66+
},
67+
});
7368
74-
fallback.getValue();
75-
fallback.setValue("int main() { return 1; }");
76-
fallback.focus();
77-
const unsubscribe = fallback.onDidChange((source) => {});
78-
fallback.dispose();
69+
CodeBlocks.ready.then(() => {
70+
const element = document.querySelector("codeblock");
71+
const block = CodeBlocks.get(element);
72+
block.setValue("int main() { return 0; }");
73+
});
74+
</script>
7975
```
8076

81-
`fallback.js` contains its scoped styles, native textarea overlay, line numbers, scrolling synchronization, and lightweight C++ syntax highlighter. It can also enhance an existing textarea inside the supplied element, preserving source included in the initial HTML.
82-
83-
### ANSI output
77+
Call `CodeBlocks.configure(options)` before a block is upgraded to set defaults for later blocks. The available options are `theme`, `showDebugControls`, `showStatus`, `compiler`, `args`, `compilerExplorerUrl`, `editorOptions`, `styles`, and `onStatus`.
8478

85-
ANSI SGR rendering is available from the main entry and as a lightweight standalone module:
79+
`CodeBlocks.get(element)` returns the upgraded block instance. It exposes `getValue`, `setValue`, `getTabs`, `selectTab`, `focus`, `run`, `setTheme`, `dispose`, `onDidChange`, `editorReady`, `monacoReady`, and `clangdReady`. `getValue` and `setValue` act on the active tab. `monacoReady` resolves to the underlying Monaco standalone editor for integrations that need the native editor API.
8680

87-
```js
88-
const { appendAnsi, ansiToFragment, stripAnsi } =
89-
await import("https://clangd.example.com/ansi.js");
81+
Styling uses CSS custom properties. Common properties include:
9082

91-
appendAnsi(outputElement, compilerOutput);
92-
appendAnsi(outputElement, stderr, { className: "stderr" });
83+
```css
84+
codeblock {
85+
width: min(100%, 900px);
86+
--codeblocks-editor-height: 360px;
87+
--codeblocks-editor-min-height: 120px;
88+
--codeblocks-editor-height-mobile: 300px;
89+
--codeblocks-accent: #7c3aed;
90+
--codeblocks-border: #475569;
91+
}
9392
```
9493

95-
The renderer creates text nodes and styled spans rather than injecting HTML. The same functions are also exported by `editor.js`.
94+
The editor observes its container and relayouts whenever its width or height changes, including flexbox, grid, responsive, and script-driven resizing.
9695

97-
Status events have a `type` of `monaco-loading`, `monaco-ready`, `clangd-downloading`, `clangd-starting`, `clangd-ready`, or `clangd-error`. Download events also contain `loaded` and, when the server provides it, `total` bytes.
96+
The lower-level ES module entries remain available as `editor.js`, `fallback.js`, and `ansi.js`.
9897

99-
## Headers
98+
## Cross-origin isolation
10099

101-
The host document must be cross-origin isolated for clangd's SharedArrayBuffer/pthread build:
100+
clangd uses WebAssembly threads and therefore requires `crossOriginIsolated === true`. The preferred deployment sends these response headers for the document and same-origin runtime files:
102101

103-
```http
102+
```text
104103
Cross-Origin-Opener-Policy: same-origin
105104
Cross-Origin-Embedder-Policy: require-corp
106105
```
107106

108-
The runtime asset server must make its public modules, workers, CSS, and WASM available cross-origin:
109-
110-
```http
111-
Access-Control-Allow-Origin: *
112-
Cross-Origin-Resource-Policy: cross-origin
113-
```
114-
115-
Serve `.wasm` as `application/wasm`. [public/_headers](public/_headers) is an example configuration for static hosts that support Netlify-style header files.
116-
117-
The cross-origin worker bootstrap uses Blob module workers. A restrictive host Content Security Policy therefore needs at least:
118-
119-
```http
120-
Content-Security-Policy: worker-src blob:; script-src 'self' https://clangd.example.com blob:
121-
```
122-
123-
Adjust the runtime origin and the rest of the policy for the site. The runtime never registers a service worker or attempts to change the host's COOP/COEP policy. GitHub Pages users need a fronting service capable of setting these headers, or a COI service-worker solution managed by the host site itself.
124-
125-
## Self-host the same package
107+
The reusable loader does not register a service worker by default. Hosts such as GitHub Pages that cannot set these headers can explicitly opt into the included helper:
126108

127-
Extract the archive without rebuilding:
128-
129-
```bash
130-
mkdir -p assets/clangd
131-
tar xf clangd-browser-runtime.tar.gz -C assets/clangd
109+
```html
110+
<script
111+
src="./codeblocks.js"
112+
data-coi-serviceworker="./coi-serviceworker.js"
113+
></script>
132114
```
133115

134-
Then import the exact same distribution from the host origin:
135-
136-
```js
137-
const { createCppEditor } = await import("/assets/clangd/editor.js");
138-
```
116+
That opt-in registers a host-owned service worker and reloads once. Do not use the attribute when the server already sends the headers.
139117

140-
All resource URLs are relative to emitted modules, so the distribution works at the origin root or any directory such as `/assets/clangd/`.
118+
If assets are hosted on another origin, that origin must allow CORS and send `Cross-Origin-Resource-Policy: cross-origin` or an equivalent policy accepted by the embedding page.
141119

142-
## Build and package
120+
## Build and test
143121

144-
Normal frontend builds use prebuilt artifacts and never clone or compile LLVM:
122+
The repository already contains the prebuilt clangd artifacts in `public/wasm`. Front-end changes do not rebuild LLVM or clangd.
145123

146124
```bash
147125
pnpm install
148126
pnpm build
149-
pnpm pack:runtime
127+
pnpm demo:origins
150128
```
151129

152-
The build produces stable `dist/editor.js`, `dist/fallback.js`, `dist/ansi.js`, `dist/editor.css`, internal chunks in `dist/assets/`, and the complete Emscripten artifact set in `dist/wasm/`. Packaging creates `clangd-browser-runtime.tar.gz`.
130+
Open `http://localhost:4173/`. The example includes `debug`, so both visual editor and theme switches are shown.
153131

154-
Install an existing artifact directory or archive with:
132+
Run the browser suite in another terminal:
155133

156134
```bash
157-
./scripts/install-clangd-artifacts path/to/clangd-artifacts.tar.gz
135+
pnpm exec playwright install chromium
136+
pnpm test:browser
158137
```
159138

160-
The input must contain `clangd.js`, `clangd.wasm`, and any other `clangd*` files produced by Emscripten. `build.sh` remains a separate manual reference workflow and is not invoked by `pnpm build` or CI.
139+
Create a deployable archive with:
161140

162-
## Compiler Explorer Run UI
141+
```bash
142+
pnpm pack:runtime
143+
```
163144

164-
Compilation is intentionally a host concern. The demo keeps an `activeEditor.getValue()` abstraction that initially reads the textarea and later points to Monaco, then sends that source directly to the Compiler Explorer API. This makes Run independent of both Monaco and clangd readiness.
145+
The output is `clangd-browser-runtime.tar.gz`. It includes the main code-block files, lower-level entries, internal chunks, and prebuilt WebAssembly artifacts.

‎clangd-browser-runtime.tar.gz‎

27.6 MB
Binary file not shown.

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"watch": "tsc -w",
1010
"build": "pnpm run clean && tsc && vite build && node scripts/copy-runtime-assets.mjs",
1111
"pack:runtime": "pnpm build && bash scripts/package-runtime.sh",
12+
"demo:origins": "node scripts/serve-test.mjs",
1213
"test:browser": "playwright test"
1314
},
1415
"devDependencies": {

‎playwright.config.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { defineConfig } from "@playwright/test";
2+
3+
export default defineConfig({
4+
testDir: "tests",
5+
timeout: 180_000,
6+
expect: { timeout: 20_000 },
7+
workers: 1,
8+
use: { headless: true },
9+
webServer: {
10+
command: "pnpm demo:origins",
11+
url: "http://localhost:4173",
12+
reuseExistingServer: true,
13+
timeout: 30_000,
14+
},
15+
});

‎public/codeblocks.js‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
(function () {
2+
"use strict";
3+
4+
var script = document.currentScript;
5+
if (!script || !script.src) throw new Error("codeblocks.js requires a script URL");
6+
var queuedConfigurations = [];
7+
var loadedModule;
8+
var api = {
9+
configure: function (options) {
10+
if (loadedModule) loadedModule.configureCodeBlocks(options);
11+
else queuedConfigurations.push(options);
12+
},
13+
};
14+
globalThis.CodeBlocks = api;
15+
16+
api.ready = (async function () {
17+
var serviceWorker = script.getAttribute("data-coi-serviceworker");
18+
if (serviceWorker !== null && !globalThis.crossOriginIsolated && "serviceWorker" in navigator) {
19+
await navigator.serviceWorker.register(
20+
new URL(serviceWorker || "./coi-serviceworker.js", script.src),
21+
{ scope: "./" },
22+
);
23+
await navigator.serviceWorker.ready;
24+
if (!globalThis.crossOriginIsolated) {
25+
location.reload();
26+
await new Promise(function () {});
27+
}
28+
}
29+
30+
loadedModule = await import(new URL("./codeblocks-module.js", script.src).href);
31+
queuedConfigurations.forEach(loadedModule.configureCodeBlocks);
32+
api.configure = loadedModule.configureCodeBlocks;
33+
api.create = loadedModule.createCodeBlock;
34+
api.get = loadedModule.getCodeBlock;
35+
api.start = loadedModule.startCodeBlocks;
36+
loadedModule.startCodeBlocks();
37+
return api;
38+
})();
39+
})();

‎public/coi-serviceworker.js‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/* Enabled only when the host opts in with data-coi-serviceworker. */
2+
self.addEventListener("install", () => self.skipWaiting());
3+
self.addEventListener("activate", (event) => {
4+
event.waitUntil(self.clients.claim());
5+
});
6+
self.addEventListener("fetch", (event) => {
7+
if (new URL(event.request.url).origin !== self.location.origin) return;
8+
if (event.request.cache === "only-if-cached" && event.request.mode !== "same-origin") return;
9+
event.respondWith((async () => {
10+
const response = await fetch(event.request);
11+
if (response.status === 0) return response;
12+
const headers = new Headers(response.headers);
13+
headers.set("Cross-Origin-Embedder-Policy", "require-corp");
14+
headers.set("Cross-Origin-Opener-Policy", "same-origin");
15+
return new Response(response.body, {
16+
status: response.status,
17+
statusText: response.statusText,
18+
headers,
19+
});
20+
})());
21+
});

‎public/demo.css‎

Lines changed: 0 additions & 37 deletions
This file was deleted.

0 commit comments

Comments
 (0)