1
0
mirror of https://github.com/alecthomas/chroma.git synced 2025-10-30 23:57:49 +02:00

feat: WASM playground

This commit is contained in:
Alec Thomas
2025-06-30 15:31:55 +10:00
parent 484750a96f
commit adeac8f5db
28 changed files with 261 additions and 46 deletions

View File

@@ -9,19 +9,13 @@ jobs:
name: Test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Init Hermit
run: ./bin/hermit env -r >> $GITHUB_ENV
- name: Test
run: go test ./...
- uses: actions/checkout@v4
- uses: cashapp/activate-hermit@v1
- run: go test ./...
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Init Hermit
run: ./bin/hermit env -r >> $GITHUB_ENV
- name: golangci-lint
run: golangci-lint run
- uses: actions/checkout@v4
- uses: cashapp/activate-hermit@v1
- run: golangci-lint run

3
.gitignore vendored
View File

@@ -23,3 +23,6 @@ _models/
_examples/
*.min.*
build/
cmd/chromad/static/chroma.wasm
cmd/chromad/static/wasm_exec.js

View File

@@ -15,12 +15,20 @@ tokentype_string.go: types.go
.PHONY: chromad
chromad: build/chromad
build/chromad: $(shell find cmd/chromad -name '*.go' -o -name '*.html' -o -name '*.css' -o -name '*.js')
build/chromad: $(shell find cmd/chromad -name '*.go' -o -name '*.html' -o -name '*.css' -o -name '*.js') \
cmd/chromad/static/wasm_exec.js \
cmd/chromad/static/chroma.wasm
rm -rf build
esbuild --bundle cmd/chromad/static/index.js --minify --outfile=cmd/chromad/static/index.min.js
esbuild --platform=node --bundle cmd/chromad/static/index.js --minify --outfile=cmd/chromad/static/index.min.js
esbuild --bundle cmd/chromad/static/index.css --minify --outfile=cmd/chromad/static/index.min.css
(export CGOENABLED=0 ; go build -C cmd/chromad -ldflags="-X 'main.version=$(VERSION)'" -o ../../build/chromad .)
cmd/chromad/static/wasm_exec.js: $(shell tinygo env TINYGOROOT)/targets/wasm_exec.js
install -m644 $< $@
cmd/chromad/static/chroma.wasm: cmd/libchromawasm/main.go
tinygo build -no-debug -target wasm -o $@ $<
upload: build/chromad
scp build/chromad root@swapoff.org: && \
ssh root@swapoff.org 'install -m755 ./chromad /srv/http/swapoff.org/bin && service chromad restart'

1
bin/.binaryen-123.pkg Symbolic link
View File

@@ -0,0 +1 @@
hermit

1
bin/.caddy-2.10.0.pkg Symbolic link
View File

@@ -0,0 +1 @@
hermit

1
bin/.tinygo-0.38.0.pkg Symbolic link
View File

@@ -0,0 +1 @@
hermit

View File

@@ -0,0 +1 @@
hermit

1
bin/binaryen-unittests Symbolic link
View File

@@ -0,0 +1 @@
.binaryen-123.pkg

1
bin/caddy Symbolic link
View File

@@ -0,0 +1 @@
.caddy-2.10.0.pkg

1
bin/tinygo Symbolic link
View File

@@ -0,0 +1 @@
.tinygo-0.38.0.pkg

1
bin/tsc Symbolic link
View File

@@ -0,0 +1 @@
.typescript-7.0.0-dev.20250629.1.pkg

1
bin/wasm-as Symbolic link
View File

@@ -0,0 +1 @@
.binaryen-123.pkg

1
bin/wasm-ctor-eval Symbolic link
View File

@@ -0,0 +1 @@
.binaryen-123.pkg

1
bin/wasm-dis Symbolic link
View File

@@ -0,0 +1 @@
.binaryen-123.pkg

View File

@@ -0,0 +1 @@
.binaryen-123.pkg

1
bin/wasm-fuzz-lattices Symbolic link
View File

@@ -0,0 +1 @@
.binaryen-123.pkg

1
bin/wasm-fuzz-types Symbolic link
View File

@@ -0,0 +1 @@
.binaryen-123.pkg

1
bin/wasm-merge Symbolic link
View File

@@ -0,0 +1 @@
.binaryen-123.pkg

1
bin/wasm-metadce Symbolic link
View File

@@ -0,0 +1 @@
.binaryen-123.pkg

1
bin/wasm-opt Symbolic link
View File

@@ -0,0 +1 @@
.binaryen-123.pkg

1
bin/wasm-reduce Symbolic link
View File

@@ -0,0 +1 @@
.binaryen-123.pkg

1
bin/wasm-shell Symbolic link
View File

@@ -0,0 +1 @@
.binaryen-123.pkg

1
bin/wasm-split Symbolic link
View File

@@ -0,0 +1 @@
.binaryen-123.pkg

1
bin/wasm2js Symbolic link
View File

@@ -0,0 +1 @@
.binaryen-123.pkg

View File

@@ -0,0 +1,83 @@
// chroma.js - TinyGo WASM runtime initialization for Chroma syntax highlighter
// Import wasm_exec.js so that it initialises the Go WASM runtime.
import './wasm_exec.js';
class ChromaWASM {
constructor() {
this.go = null;
this.wasm = null;
this.ready = false;
this.readyPromise = this.init();
}
async init() {
try {
// Create a new Go instance
this.go = new Go();
// Load the WASM module
const wasmResponse = await fetch('./static/chroma.wasm');
if (!wasmResponse.ok) {
throw new Error(`Failed to fetch chroma.wasm: ${wasmResponse.status}`);
}
const wasmBytes = await wasmResponse.arrayBuffer();
const wasmModule = await WebAssembly.instantiate(wasmBytes, this.go.importObject);
this.wasm = wasmModule.instance;
// Run the Go program
this.go.run(this.wasm);
this.ready = true;
console.log('Chroma WASM module initialized successfully');
} catch (error) {
console.error('Failed to initialize Chroma WASM module:', error);
throw error;
}
}
async waitForReady() {
await this.readyPromise;
if (!this.ready) {
throw new Error('Chroma WASM module failed to initialize');
}
}
async highlight(source, lexer, formatter, withClasses) {
await this.waitForReady();
if (typeof window.highlight !== 'function') {
throw new Error('highlight function not available from WASM module');
}
try {
return window.highlight(source, lexer, formatter, withClasses);
} catch (error) {
console.error('Error calling highlight function:', error);
throw error;
}
}
}
export function isWasmSupported() {
try {
if (typeof WebAssembly === "object" && typeof WebAssembly.instantiate === "function") {
// The smallest possible WebAssembly module (magic number + version)
const module = new WebAssembly.Module(Uint8Array.of(0x0, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00));
if (module instanceof WebAssembly.Module) {
// Try to instantiate the module to ensure it's truly runnable
return new WebAssembly.Instance(module) instanceof WebAssembly.Instance;
}
}
} catch (e) {
// An error occurred (e.g., due to CSP or other restrictions)
}
return false;
}
// Create global instance, null if WASM is not supported.
export const chroma = isWasmSupported() ? new ChromaWASM() : null;

View File

@@ -1,4 +1,5 @@
import * as Base64 from "./base64.js";
import { chroma } from "./chroma.js";
document.addEventListener("DOMContentLoaded", function () {
var darkMode = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
@@ -22,6 +23,38 @@ document.addEventListener("DOMContentLoaded", function () {
});
});
async function renderServer(formData) {
return (await fetch("api/render", {
method: 'POST',
mode: 'cors',
cache: 'no-cache',
credentials: 'same-origin',
headers: {
'X-CSRF-Token': csrfToken,
'Content-Type': 'application/json',
},
redirect: 'follow',
referrer: 'no-referrer',
body: JSON.stringify(formData),
})).json();
}
async function renderWasm(formData) {
return await chroma.highlight(
formData.text,
formData.language,
formData.style,
formData.classes,
);
}
async function render(formData) {
return chroma !== null
? renderWasm(formData)
: renderServer(formData);
}
// https://stackoverflow.com/a/37697925/7980
function handleTab(e) {
var after, before, end, lastNewLine, changeLength, re, replace, selection, start, val;
@@ -97,38 +130,33 @@ document.addEventListener("DOMContentLoaded", function () {
}
}
function update(event) {
fetch("api/render", {
method: 'POST',
mode: 'cors',
cache: 'no-cache',
credentials: 'same-origin',
headers: {
'X-CSRF-Token': csrfToken,
'Content-Type': 'application/json',
},
redirect: 'follow',
referrer: 'no-referrer',
body: JSON.stringify(getFormJSON()),
}).then(data => {
data.json().then(
value => {
if (value.language) {
languageSelect.value = value.language;
}
style.innerHTML = "#output { " + value.background + "}";
if (htmlCheckbox.checked) {
output.innerText = value.html;
} else {
output.innerHTML = value.html;
}
}
);
}).catch(reason => {
console.log(reason);
});
async function update(event) {
try {
const formData = getFormJSON();
const value = await render(formData);
event.preventDefault();
if (value.language) {
languageSelect.value = value.language;
}
style.innerHTML = "#output { " + value.background + "}";
if (htmlCheckbox.checked) {
output.innerText = value.html;
} else {
output.innerHTML = value.html;
}
} catch (error) {
console.error('Error highlighting code:', error);
// Fallback: display plain text
if (htmlCheckbox.checked) {
output.innerText = textArea.value;
} else {
output.innerHTML = '<pre>' + textArea.value + '</pre>';
}
}
if (event) {
event.preventDefault();
}
}
function share(event) {
@@ -157,7 +185,7 @@ document.addEventListener("DOMContentLoaded", function () {
}
var eventHandler = (event) => update(event);
var debouncedEventHandler = debounce(eventHandler, 250);
var debouncedEventHandler = debounce(eventHandler, chroma === null ? 250 : 100);
languageSelect.addEventListener('change', eventHandler);
styleSelect.addEventListener('change', eventHandler);

77
cmd/libchromawasm/main.go Normal file
View File

@@ -0,0 +1,77 @@
//go:build wasm
// Package main is an experimental WASM library intended for TinyGO.
package main
import (
"strings"
"syscall/js"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters/html"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
)
func main() {
// Register the highlight function with the JavaScript global object
js.Global().Set("highlight", js.FuncOf(highlight))
// Keep the program running
select {}
}
// Highlight source code using Chroma.
//
// Equivalent to the JS function:
//
// function highlight(source, lexer, styleName, classes)
//
// If the "lexer" is unknown, this will attempt to autodetect the language type.
func highlight(this js.Value, args []js.Value) any {
source := args[0].String()
lexer := args[1].String()
styleName := args[2].String()
classes := args[3].Bool()
language := lexers.Get(lexer)
if language == nil {
language = lexers.Analyse(source)
if language != nil {
lexer = language.Config().Name
}
}
if language == nil {
language = lexers.Fallback
}
tokens, err := chroma.Coalesce(language).Tokenise(nil, source)
if err != nil {
panic(err)
}
style := styles.Get(styleName)
if style == nil {
style = styles.Fallback
}
buf := &strings.Builder{}
options := []html.Option{}
if classes {
options = append(options, html.WithClasses(true), html.Standalone(true))
}
formatter := html.New(options...)
err = formatter.Format(buf, style, tokens)
if err != nil {
panic(err)
}
lang := language.Config().Name
if language == lexers.Fallback {
lang = ""
}
return js.ValueOf(map[string]any{
"html": buf.String(),
"language": lang,
"background": html.StyleEntryToCSS(style.Get(chroma.Background)),
})
}

View File

@@ -7,6 +7,7 @@
":semanticCommitScope(deps)",
"group:allNonMajor",
"schedule:earlyMondays", // Run once a week.
'helpers:pinGitHubActionDigests',
],
"packageRules": [
{