diff --git a/cmd/gh-aw-wasm/main.go b/cmd/gh-aw-wasm/main.go
index c54c8a0402..ba47909fc2 100644
--- a/cmd/gh-aw-wasm/main.go
+++ b/cmd/gh-aw-wasm/main.go
@@ -5,6 +5,7 @@ package main
import (
"syscall/js"
+ "github.com/github/gh-aw/pkg/parser"
"github.com/github/gh-aw/pkg/workflow"
)
@@ -14,21 +15,25 @@ func main() {
}
// compileWorkflow is the JS-callable function.
-// Usage: compileWorkflow(markdownString) → Promise<{yaml, warnings, error}>
+// Usage: compileWorkflow(markdownString, filesObject?) → Promise<{yaml, warnings, error}>
//
-// Only a single argument (the markdown string) is accepted.
-// Import resolution is not currently supported in the Wasm build.
+// Arguments:
+// - markdownString: the main workflow markdown content
+// - filesObject (optional): a JS object mapping file paths to content strings,
+// used for import resolution (e.g. {"shared/tools.md": "---\ntools:..."})
func compileWorkflow(this js.Value, args []js.Value) any {
if len(args) < 1 {
- return newRejectedPromise("compileWorkflow requires exactly 1 argument: markdown string")
- }
-
- if len(args) > 1 {
- return newRejectedPromise("compileWorkflow accepts only 1 argument; importResolver is not supported in the Wasm build")
+ return newRejectedPromise("compileWorkflow requires at least 1 argument: markdown string")
}
markdown := args[0].String()
+ // Extract virtual files from optional second argument
+ var files map[string][]byte
+ if len(args) >= 2 && !args[1].IsNull() && !args[1].IsUndefined() {
+ files = jsObjectToFileMap(args[1])
+ }
+
var handler js.Func
handler = js.FuncOf(func(this js.Value, promiseArgs []js.Value) any {
resolve := promiseArgs[0]
@@ -37,7 +42,7 @@ func compileWorkflow(this js.Value, args []js.Value) any {
go func() {
defer handler.Release()
- result, err := doCompile(markdown)
+ result, err := doCompile(markdown, files)
if err != nil {
reject.Invoke(js.Global().Get("Error").New(err.Error()))
return
@@ -51,8 +56,30 @@ func compileWorkflow(this js.Value, args []js.Value) any {
return js.Global().Get("Promise").New(handler)
}
-// doCompile performs the actual compilation entirely in memory — no filesystem access.
-func doCompile(markdown string) (js.Value, error) {
+// jsObjectToFileMap converts a JS object {path: content, ...} to map[string][]byte.
+func jsObjectToFileMap(obj js.Value) map[string][]byte {
+ files := make(map[string][]byte)
+
+ // Use Object.keys() to iterate over the JS object
+ keys := js.Global().Get("Object").Call("keys", obj)
+ length := keys.Length()
+ for i := 0; i < length; i++ {
+ key := keys.Index(i).String()
+ value := obj.Get(key).String()
+ files[key] = []byte(value)
+ }
+
+ return files
+}
+
+// doCompile performs the actual compilation entirely in memory.
+func doCompile(markdown string, files map[string][]byte) (js.Value, error) {
+ // Set up virtual filesystem for import resolution
+ if files != nil {
+ parser.SetVirtualFiles(files)
+ defer parser.ClearVirtualFiles()
+ }
+
compiler := workflow.NewCompiler(
workflow.WithNoEmit(true),
workflow.WithSkipValidation(true),
diff --git a/docs/public/editor/editor.js b/docs/public/editor/editor.js
index 202e8cded2..7727a7995e 100644
--- a/docs/public/editor/editor.js
+++ b/docs/public/editor/editor.js
@@ -45,6 +45,8 @@ const divider = $('divider');
const panelEditor = $('panelEditor');
const panelOutput = $('panelOutput');
const panels = $('panels');
+const tabBar = $('tabBar');
+const tabAdd = $('tabAdd');
// ---------------------------------------------------------------
// State
@@ -56,6 +58,11 @@ let autoCompile = true;
let compileTimer = null;
let currentYaml = '';
+// File tabs state: ordered list of { name, content }
+const MAIN_FILE = 'workflow.md';
+let files = [{ name: MAIN_FILE, content: DEFAULT_CONTENT }];
+let activeTab = MAIN_FILE;
+
// ---------------------------------------------------------------
// Theme
// ---------------------------------------------------------------
@@ -116,14 +123,103 @@ function syncLineNumberScroll() {
lineNumbers.scrollTop = editor.scrollTop;
}
+// ---------------------------------------------------------------
+// File tabs
+// ---------------------------------------------------------------
+function getFile(name) {
+ return files.find(f => f.name === name);
+}
+
+function renderTabs() {
+ tabBar.querySelectorAll('.tab').forEach(el => el.remove());
+
+ for (const file of files) {
+ const tab = document.createElement('div');
+ tab.className = 'tab' + (file.name === activeTab ? ' active' : '');
+ tab.dataset.name = file.name;
+
+ const label = document.createElement('span');
+ label.textContent = file.name;
+ tab.appendChild(label);
+
+ if (file.name !== MAIN_FILE) {
+ const close = document.createElement('button');
+ close.className = 'tab-close';
+ close.title = 'Remove file';
+ close.innerHTML = '';
+ close.addEventListener('click', (e) => {
+ e.stopPropagation();
+ removeTab(file.name);
+ });
+ tab.appendChild(close);
+ }
+
+ tab.addEventListener('click', () => switchTab(file.name));
+ tabBar.insertBefore(tab, tabAdd);
+ }
+}
+
+function switchTab(name) {
+ const current = getFile(activeTab);
+ if (current) current.content = editor.value;
+
+ activeTab = name;
+ const file = getFile(name);
+ if (file) {
+ editor.value = file.content;
+ updateLineNumbers();
+ }
+ renderTabs();
+}
+
+function addTab() {
+ const name = prompt('File path (e.g. shared/my-tools.md):');
+ if (!name || !name.trim()) return;
+
+ const trimmed = name.trim();
+ if (getFile(trimmed)) { switchTab(trimmed); return; }
+
+ const defaultImportContent = `---
+# Shared workflow component
+# This file can define: tools, steps, engine, mcp-servers, etc.
+tools:
+ - name: example_tool
+ description: An example tool
+---
+
+# Instructions
+
+Add your shared workflow instructions here.
+`;
+
+ files.push({ name: trimmed, content: defaultImportContent });
+ switchTab(trimmed);
+}
+
+function removeTab(name) {
+ if (name === MAIN_FILE) return;
+ files = files.filter(f => f.name !== name);
+ if (activeTab === name) {
+ switchTab(MAIN_FILE);
+ } else {
+ renderTabs();
+ }
+ if (autoCompile && isReady) scheduleCompile();
+}
+
+tabAdd.addEventListener('click', addTab);
+
// ---------------------------------------------------------------
// Editor setup
// ---------------------------------------------------------------
editor.value = DEFAULT_CONTENT;
updateLineNumbers();
+renderTabs();
editor.addEventListener('input', () => {
updateLineNumbers();
+ const file = getFile(activeTab);
+ if (file) file.content = editor.value;
if (autoCompile && isReady) scheduleCompile();
});
@@ -160,11 +256,25 @@ function scheduleCompile() {
compileTimer = setTimeout(doCompile, 400);
}
+function getImportFiles() {
+ const importFiles = {};
+ for (const file of files) {
+ if (file.name !== MAIN_FILE) importFiles[file.name] = file.content;
+ }
+ return Object.keys(importFiles).length > 0 ? importFiles : undefined;
+}
+
async function doCompile() {
if (!isReady || isCompiling) return;
if (compileTimer) { clearTimeout(compileTimer); compileTimer = null; }
- const md = editor.value;
+ // Save current editor content
+ const currentFile = getFile(activeTab);
+ if (currentFile) currentFile.content = editor.value;
+
+ // Get the main workflow content
+ const mainFile = getFile(MAIN_FILE);
+ const md = mainFile ? mainFile.content : '';
if (!md.trim()) {
outputPre.style.display = 'none';
outputPlaceholder.style.display = 'flex';
@@ -181,7 +291,8 @@ async function doCompile() {
warningBanner.classList.remove('visible');
try {
- const result = await compiler.compile(md);
+ const importFiles = getImportFiles();
+ const result = await compiler.compile(md, importFiles);
if (result.error) {
setStatus('error', 'Error');
diff --git a/docs/public/wasm/compiler-loader.js b/docs/public/wasm/compiler-loader.js
index 5456013886..5cb8778356 100644
--- a/docs/public/wasm/compiler-loader.js
+++ b/docs/public/wasm/compiler-loader.js
@@ -8,6 +8,8 @@
* const compiler = createWorkerCompiler();
* await compiler.ready;
* const { yaml, warnings, error } = await compiler.compile(markdownString);
+ * // With imports:
+ * const { yaml } = await compiler.compile(markdown, { 'shared/tools.md': '...' });
* compiler.terminate();
*/
@@ -17,7 +19,7 @@
* @param {Object} [options]
* @param {string} [options.workerUrl] - URL to compiler-worker.js
* (default: resolves relative to this module)
- * @returns {{ compile: (markdown: string) => Promise<{yaml: string, warnings: string[], error: string|null}>,
+ * @returns {{ compile: (markdown: string, files?: Record) => Promise<{yaml: string, warnings: string[], error: string|null}>,
* ready: Promise,
* terminate: () => void }}
*/
@@ -111,9 +113,11 @@ export function createWorkerCompiler(options = {}) {
* Compile a markdown workflow string to GitHub Actions YAML.
*
* @param {string} markdown
+ * @param {Record} [files] - Optional map of file paths to content
+ * for import resolution (e.g. {"shared/tools.md": "---\ntools:..."})
* @returns {Promise<{yaml: string, warnings: string[], error: string|null}>}
*/
- function compile(markdown) {
+ function compile(markdown, files) {
if (isTerminated) {
return Promise.reject(new Error('Compiler worker has been terminated.'));
}
@@ -122,7 +126,11 @@ export function createWorkerCompiler(options = {}) {
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
- worker.postMessage({ type: 'compile', id, markdown });
+ const msg = { type: 'compile', id, markdown };
+ if (files && Object.keys(files).length > 0) {
+ msg.files = files;
+ }
+ worker.postMessage(msg);
});
}
diff --git a/docs/public/wasm/compiler-worker.js b/docs/public/wasm/compiler-worker.js
index d98b63f595..5d9349a151 100644
--- a/docs/public/wasm/compiler-worker.js
+++ b/docs/public/wasm/compiler-worker.js
@@ -3,7 +3,7 @@
* the compileWorkflow function via postMessage.
*
* Message protocol (inbound):
- * { type: 'compile', id: , markdown: }
+ * { type: 'compile', id: , markdown: , files?: