Skip to content

Commit 91f2d27

Browse files
committed
Interactive Quarto examples
1 parent b0914a1 commit 91f2d27

10 files changed

Lines changed: 599 additions & 14 deletions

File tree

doc/_quarto.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,14 @@ format:
117117
<script src="https://cdn.jsdelivr.net/gh/posit-dev/supported-by-posit/js/badge.min.js"
118118
data-light-bg="#94D2BD"
119119
data-dark-bg="#001219"></script>
120+
include-after-body:
121+
- text: |
122+
<script type="module">
123+
const offset = document.querySelector('meta[name="quarto:offset"]')?.content || './';
124+
const base = offset + 'wasm/';
125+
const link = document.createElement('link');
126+
link.rel = 'stylesheet';
127+
link.href = base + 'quarto.css';
128+
document.head.appendChild(link);
129+
import(base + 'quarto.js');
130+
</script>

ggsql-wasm/build-wasm.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ else
3838
echo "Skipping WASM binary build (--skip-binary)."
3939
fi
4040

41-
echo "Building WASM demo..."
41+
echo "Building WASM demo and Quarto integration..."
4242
(cd "$SCRIPT_DIR/demo" && npm install && npm run build)
4343

4444
echo "Copying output to doc/wasm..."

ggsql-wasm/demo/build.mjs

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,9 @@ await esbuild.build({
4040
format: "iife",
4141
});
4242

43-
// Build main application bundle
44-
const buildOptions = {
45-
entryPoints: [join(__dirname, "src/main.ts")],
43+
// Shared build options
44+
const sharedOptions = {
4645
bundle: true,
47-
outfile: join(distDir, "bundle.js"),
4846
format: "esm",
4947
platform: "browser",
5048
target: "es2020",
@@ -55,13 +53,35 @@ const buildOptions = {
5553
},
5654
};
5755

56+
// Build playground bundle
57+
const playgroundOptions = {
58+
...sharedOptions,
59+
entryPoints: [join(__dirname, "src/main.ts")],
60+
outfile: join(distDir, "bundle.js"),
61+
};
62+
63+
// Build quarto integration bundle
64+
const quartoOptions = {
65+
...sharedOptions,
66+
entryPoints: [join(__dirname, "src/quarto/main.ts")],
67+
outfile: join(distDir, "quarto.js"),
68+
loader: {
69+
...sharedOptions.loader,
70+
".css": "css",
71+
},
72+
};
73+
5874
if (isWatch) {
5975
console.log("Starting watch mode...");
60-
const ctx = await esbuild.context(buildOptions);
61-
await ctx.watch();
76+
const playgroundCtx = await esbuild.context(playgroundOptions);
77+
const quartoCtx = await esbuild.context(quartoOptions);
78+
await Promise.all([playgroundCtx.watch(), quartoCtx.watch()]);
6279
console.log("Watching for changes...");
6380
} else {
64-
console.log("Building main bundle...");
65-
await esbuild.build(buildOptions);
81+
console.log("Building bundles...");
82+
await Promise.all([
83+
esbuild.build(playgroundOptions),
84+
esbuild.build(quartoOptions),
85+
]);
6686
console.log("Build complete!");
6787
}

ggsql-wasm/demo/src/context.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import init, { GgsqlContext } from "ggsql-wasm";
2+
import { WASM_BASE } from "./wasmBase";
23

34
export class WasmContextManager {
45
private context: GgsqlContext | null = null;
@@ -7,7 +8,7 @@ export class WasmContextManager {
78
async initialize(): Promise<void> {
89
if (this.initialized) return;
910

10-
await init("./ggsql_wasm_bg.wasm");
11+
await init(WASM_BASE + "ggsql_wasm_bg.wasm");
1112
this.context = new GgsqlContext();
1213
this.initialized = true;
1314
}

ggsql-wasm/demo/src/editor.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import * as monaco from "monaco-editor";
22
import { createOnigScanner, createOnigString, loadWASM } from "vscode-oniguruma";
33
import { Registry, parseRawGrammar, type IGrammar } from "vscode-textmate";
4+
import { WASM_BASE } from "./wasmBase";
45

56
// Must be set before any Monaco editor is created
67
(self as any).MonacoEnvironment = {
7-
getWorkerUrl: (_moduleId: string, _label: string) => "./editor.worker.js",
8+
getWorkerUrl: (_moduleId: string, _label: string) => WASM_BASE + "editor.worker.js",
89
};
910

1011
// Map TextMate scope names to Monaco theme token colors
@@ -40,7 +41,7 @@ function scopeToMonacoToken(scopes: string[]): string {
4041

4142
async function initTextMateGrammar(): Promise<IGrammar | null> {
4243
// Load oniguruma WASM
43-
const onigWasm = await fetch("./onig.wasm");
44+
const onigWasm = await fetch(WASM_BASE + "onig.wasm");
4445
const onigBuffer = await onigWasm.arrayBuffer();
4546
await loadWASM(onigBuffer);
4647

@@ -52,7 +53,7 @@ async function initTextMateGrammar(): Promise<IGrammar | null> {
5253
}),
5354
loadGrammar: async (scopeName: string) => {
5455
if (scopeName === "source.ggsql") {
55-
const response = await fetch("./ggsql.tmLanguage.json");
56+
const response = await fetch(WASM_BASE + "ggsql.tmLanguage.json");
5657
const grammarText = await response.text();
5758
return parseRawGrammar(grammarText, "ggsql.tmLanguage.json");
5859
}
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import * as monaco from "monaco-editor";
2+
import {
3+
createOnigScanner,
4+
createOnigString,
5+
loadWASM,
6+
} from "vscode-oniguruma";
7+
import { Registry, parseRawGrammar, type IGrammar } from "vscode-textmate";
8+
import { WASM_BASE } from "../wasmBase";
9+
10+
// Must be set before any Monaco editor is created
11+
(self as any).MonacoEnvironment = {
12+
getWorkerUrl: (_moduleId: string, _label: string) =>
13+
WASM_BASE + "editor.worker.js",
14+
};
15+
16+
// Map TextMate scope names to Monaco theme token colors
17+
const SCOPE_TO_TOKEN: [string, string][] = [
18+
["comment", "comment"],
19+
["string", "string"],
20+
["constant.numeric", "number"],
21+
["constant.language", "keyword"],
22+
["keyword", "keyword"],
23+
["support.function", "type"],
24+
["support.type.geom", "type"],
25+
["support.type.aesthetic", "variable"],
26+
["support.type.coord", "type"],
27+
["support.type.theme", "type"],
28+
["support.type.property", "variable"],
29+
["constant.language.scale-type", "type"],
30+
["keyword.operator", "operator"],
31+
["punctuation", "delimiter"],
32+
];
33+
34+
function scopeToMonacoToken(scopes: string[]): string {
35+
for (let i = scopes.length - 1; i >= 0; i--) {
36+
const scope = scopes[i];
37+
for (const [pattern, token] of SCOPE_TO_TOKEN) {
38+
if (scope.startsWith(pattern)) {
39+
return token;
40+
}
41+
}
42+
}
43+
return "";
44+
}
45+
46+
// Singleton grammar initialization
47+
let grammarPromise: Promise<IGrammar | null> | null = null;
48+
49+
async function initTextMateGrammar(): Promise<IGrammar | null> {
50+
const onigWasm = await fetch(WASM_BASE + "onig.wasm");
51+
const onigBuffer = await onigWasm.arrayBuffer();
52+
await loadWASM(onigBuffer);
53+
54+
const registry = new Registry({
55+
onigLib: Promise.resolve({
56+
createOnigScanner,
57+
createOnigString,
58+
}),
59+
loadGrammar: async (scopeName: string) => {
60+
if (scopeName === "source.ggsql") {
61+
const response = await fetch(WASM_BASE + "ggsql.tmLanguage.json");
62+
const grammarText = await response.text();
63+
return parseRawGrammar(grammarText, "ggsql.tmLanguage.json");
64+
}
65+
return null;
66+
},
67+
});
68+
69+
return registry.loadGrammar("source.ggsql");
70+
}
71+
72+
function getGrammar(): Promise<IGrammar | null> {
73+
if (!grammarPromise) {
74+
grammarPromise = initTextMateGrammar();
75+
}
76+
return grammarPromise;
77+
}
78+
79+
let languageRegistered = false;
80+
81+
async function ensureLanguageRegistered(): Promise<void> {
82+
if (languageRegistered) return;
83+
languageRegistered = true;
84+
85+
monaco.languages.register({ id: "ggsql" });
86+
87+
monaco.languages.setLanguageConfiguration("ggsql", {
88+
comments: {
89+
lineComment: "--",
90+
blockComment: ["/*", "*/"],
91+
},
92+
brackets: [
93+
["{", "}"],
94+
["[", "]"],
95+
["(", ")"],
96+
],
97+
autoClosingPairs: [
98+
{ open: "{", close: "}" },
99+
{ open: "[", close: "]" },
100+
{ open: "(", close: ")" },
101+
{ open: "'", close: "'", notIn: ["string", "comment"] },
102+
{ open: '"', close: '"', notIn: ["string", "comment"] },
103+
],
104+
surroundingPairs: [
105+
{ open: "{", close: "}" },
106+
{ open: "[", close: "]" },
107+
{ open: "(", close: ")" },
108+
{ open: "'", close: "'" },
109+
{ open: '"', close: '"' },
110+
],
111+
});
112+
113+
const grammar = await getGrammar();
114+
if (grammar) {
115+
monaco.languages.setTokensProvider("ggsql", {
116+
getInitialState: () => new TMState(null),
117+
tokenize: (
118+
line: string,
119+
state: TMState
120+
): monaco.languages.ILineTokens => {
121+
const result = grammar.tokenizeLine(line, state.ruleStack);
122+
const tokens: monaco.languages.IToken[] = result.tokens.map((t) => ({
123+
startIndex: t.startIndex,
124+
scopes: scopeToMonacoToken(t.scopes),
125+
}));
126+
return {
127+
tokens,
128+
endState: new TMState(result.ruleStack),
129+
};
130+
},
131+
});
132+
}
133+
}
134+
135+
// TextMate state wrapper for Monaco
136+
class TMState implements monaco.languages.IState {
137+
constructor(public ruleStack: any) {}
138+
139+
clone(): TMState {
140+
return new TMState(this.ruleStack);
141+
}
142+
143+
equals(other: monaco.languages.IState): boolean {
144+
if (!(other instanceof TMState)) return false;
145+
if (!this.ruleStack && !other.ruleStack) return true;
146+
if (!this.ruleStack || !other.ruleStack) return false;
147+
return this.ruleStack.equals(other.ruleStack);
148+
}
149+
}
150+
151+
export interface EditorInstance {
152+
getValue(): string;
153+
setValue(value: string): void;
154+
editor: monaco.editor.IStandaloneCodeEditor;
155+
}
156+
157+
const LINE_HEIGHT = 19;
158+
const PADDING_TOP = 8;
159+
const PADDING_BOTTOM = 8;
160+
const MAX_EDITOR_HEIGHT = 400;
161+
162+
function editorHeight(lineCount: number): number {
163+
const contentHeight = lineCount * LINE_HEIGHT + PADDING_TOP + PADDING_BOTTOM;
164+
return Math.min(contentHeight, MAX_EDITOR_HEIGHT);
165+
}
166+
167+
export async function createEditor(
168+
container: HTMLElement,
169+
initialValue: string
170+
): Promise<EditorInstance> {
171+
await ensureLanguageRegistered();
172+
173+
const lineCount = initialValue.split("\n").length;
174+
container.style.height = editorHeight(lineCount) + "px";
175+
176+
const editor = monaco.editor.create(container, {
177+
value: initialValue,
178+
language: "ggsql",
179+
theme: "vs",
180+
automaticLayout: true,
181+
minimap: { enabled: false },
182+
fontSize: 13,
183+
lineNumbers: "on",
184+
glyphMargin: false,
185+
folding: false,
186+
lineNumbersMinChars: 2,
187+
scrollBeyondLastLine: false,
188+
wordWrap: "on",
189+
padding: { top: PADDING_TOP, bottom: PADDING_BOTTOM },
190+
renderLineHighlightOnlyWhenFocus: true,
191+
overviewRulerLanes: 0,
192+
hideCursorInOverviewRuler: true,
193+
overviewRulerBorder: false,
194+
scrollbar: {
195+
vertical: "auto",
196+
horizontal: "hidden",
197+
verticalScrollbarSize: 8,
198+
},
199+
});
200+
201+
// Auto-resize editor height to content
202+
editor.onDidContentSizeChange(() => {
203+
const newLineCount = editor.getModel()?.getLineCount() || lineCount;
204+
container.style.height = editorHeight(newLineCount) + "px";
205+
editor.layout();
206+
});
207+
208+
return {
209+
getValue: () => editor.getValue(),
210+
setValue: (value: string) => editor.setValue(value),
211+
editor,
212+
};
213+
}

0 commit comments

Comments
 (0)