Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 38 additions & 11 deletions cmd/gh-aw-wasm/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package main
import (
"syscall/js"

"github.com/github/gh-aw/pkg/parser"
"github.com/github/gh-aw/pkg/workflow"
)

Expand All @@ -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]
Expand All @@ -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
Expand All @@ -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
Comment on lines +59 to +72
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jsObjectToFileMap assumes the optional second argument is a plain object whose values are strings. If a caller passes a non-object (e.g. a string/array) or non-string values, .String() will coerce silently (e.g. "[object Object]") and lead to confusing compile errors. Consider validating args[1] is an object and that each value is a string (or can be converted to one) and rejecting the Promise with a clear message when the shape is invalid.

Copilot uses AI. Check for mistakes.
}

// 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()
}
Comment on lines +77 to +81
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SetVirtualFiles/ClearVirtualFiles mutate package-level globals, but compileWorkflow launches each request in a goroutine. If multiple compileWorkflow() calls are in-flight concurrently (possible for createWorkerCompiler().compile() consumers), the virtual FS can be overwritten/cleared mid-compilation, producing incorrect results. Consider serializing compilation in the WASM entrypoint (e.g., a mutex) or making the virtual FS state per-compilation instead of global.

Copilot uses AI. Check for mistakes.

compiler := workflow.NewCompiler(
workflow.WithNoEmit(true),
workflow.WithSkipValidation(true),
Expand Down
115 changes: 113 additions & 2 deletions docs/public/editor/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ const divider = $('divider');
const panelEditor = $('panelEditor');
const panelOutput = $('panelOutput');
const panels = $('panels');
const tabBar = $('tabBar');
const tabAdd = $('tabAdd');

// ---------------------------------------------------------------
// State
Expand All @@ -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
// ---------------------------------------------------------------
Expand Down Expand Up @@ -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 = '<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.749.749 0 011.275.326.749.749 0 01-.215.734L9.06 8l3.22 3.22a.749.749 0 01-.326 1.275.749.749 0 01-.734-.215L8 9.06l-3.22 3.22a.751.751 0 01-1.042-.018.751.751 0 01-.018-1.042L6.94 8 3.72 4.78a.75.75 0 010-1.06z"/></svg>';
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();
});

Expand Down Expand Up @@ -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';
Expand All @@ -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');
Expand Down
14 changes: 11 additions & 3 deletions docs/public/wasm/compiler-loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
*/

Expand All @@ -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<string,string>) => Promise<{yaml: string, warnings: string[], error: string|null}>,
* ready: Promise<void>,
* terminate: () => void }}
*/
Expand Down Expand Up @@ -111,9 +113,11 @@ export function createWorkerCompiler(options = {}) {
* Compile a markdown workflow string to GitHub Actions YAML.
*
* @param {string} markdown
* @param {Record<string, string>} [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.'));
}
Expand All @@ -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);
});
}

Expand Down
6 changes: 4 additions & 2 deletions docs/public/wasm/compiler-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* the compileWorkflow function via postMessage.
*
* Message protocol (inbound):
* { type: 'compile', id: <number|string>, markdown: <string> }
* { type: 'compile', id: <number|string>, markdown: <string>, files?: <object> }
*
* Message protocol (outbound):
* { type: 'ready' }
Expand Down Expand Up @@ -113,7 +113,9 @@

try {
// compileWorkflow returns a Promise (Go side).
var result = await compileWorkflow(msg.markdown);
// Pass optional files object for import resolution.
var files = msg.files || null;
var result = await compileWorkflow(msg.markdown, files);

// The Go function returns { yaml: string, warnings: Array, error: null|string }
var warnings = [];
Expand Down
5 changes: 2 additions & 3 deletions pkg/parser/import_processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package parser
import (
"encoding/json"
"fmt"
"os"
"path"
"sort"
"strings"
Expand Down Expand Up @@ -448,7 +447,7 @@ func processImportsFromFrontmatterWithManifestAndSource(frontmatter map[string]a
}

// Read the imported file to extract nested imports
content, err := os.ReadFile(item.fullPath)
content, err := readFileFunc(item.fullPath)
if err != nil {
return nil, fmt.Errorf("failed to read imported file '%s': %w", item.fullPath, err)
}
Expand Down Expand Up @@ -841,7 +840,7 @@ func topologicalSortImports(imports []string, baseDir string, cache *ImportCache
}

// Read and parse the file to extract its imports
content, err := os.ReadFile(fullPath)
content, err := readFileFunc(fullPath)
if err != nil {
importLog.Printf("Failed to read file %s during topological sort: %v", fullPath, err)
dependencies[importPath] = []string{}
Expand Down
3 changes: 1 addition & 2 deletions pkg/parser/include_expander.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"bufio"
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
)
Expand Down Expand Up @@ -163,7 +162,7 @@ func processIncludesForField(content, baseDir string, extractFunc func(string) (
}

// Read the included file
fileContent, err := os.ReadFile(fullPath)
fileContent, err := readFileFunc(fullPath)
if err != nil {
// For any processing errors, fail compilation
return nil, "", fmt.Errorf("failed to read included file '%s': %w", fullPath, err)
Expand Down
2 changes: 1 addition & 1 deletion pkg/parser/include_processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ func processIncludesWithVisited(content, baseDir string, extractTools bool, visi
// processIncludedFileWithVisited processes a single included file with cycle detection for nested includes
func processIncludedFileWithVisited(filePath, sectionName string, extractTools bool, visited map[string]bool) (string, error) {
includeLog.Printf("Reading included file: %s (extractTools=%t, section=%s)", filePath, extractTools, sectionName)
content, err := os.ReadFile(filePath)
content, err := readFileFunc(filePath)
if err != nil {
return "", fmt.Errorf("failed to read included file %s: %w", filePath, err)
}
Expand Down
9 changes: 5 additions & 4 deletions pkg/parser/remote_fetch_wasm.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ package parser

import (
"fmt"
"os"
"path/filepath"
"strings"
)
Expand Down Expand Up @@ -80,10 +79,12 @@ func ResolveIncludePath(filePath, baseDir string, cache *ImportCache) (string, e
return "", fmt.Errorf("security: path %s must be within .github folder (resolves to: %s)", filePath, relativePath)
}

if _, err := os.Stat(fullPath); os.IsNotExist(err) {
return "", fmt.Errorf("file not found: %s", fullPath)
// In wasm builds, check the virtual filesystem first
if VirtualFileExists(fullPath) {
return fullPath, nil
}
return fullPath, nil

return "", fmt.Errorf("file not found: %s", fullPath)
}

func isWorkflowSpec(path string) bool {
Expand Down
8 changes: 8 additions & 0 deletions pkg/parser/virtual_fs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package parser

import "os"

// readFileFunc is the function used to read file contents throughout the parser.
// In wasm builds, this is overridden to read from a virtual filesystem
// populated by the browser via SetVirtualFiles.
var readFileFunc = os.ReadFile
Loading
Loading