Skip to content
Open
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
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ build: generate
@mkdir -p dist
go build -ldflags="$(shell ./scripts/ldflags.sh)" -o dist/cem .

dev-serve: generate
@mkdir -p dist
go build -tags cemdev -ldflags="$(shell ./scripts/ldflags.sh)" -o dist/cem .

# NOTE: this is a non-traditional install target, which installs to ~/.local/bin/
# It's mostly intended for local development, not for distribution
install: build
Expand Down
114 changes: 114 additions & 0 deletions serve/DEV_MODE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Dev Mode for Chrome UI Development

This document describes the `cemdev` build tag feature that enables live-reload of embedded chrome UI assets during development.

## Overview

The cem dev server embeds JS/CSS/HTML templates at compile time via `embed.FS` (see `serve/middleware/routes/templates.go`). When iterating on the chrome UI (e.g., working on components in `serve/middleware/routes/templates/elements/`), developers normally have to:

1. Kill the server
2. Run `make build`
3. Restart the server

With the `cemdev` build tag, you can enable dev mode where:

1. The server reads `templates/` files from disk instead of `embed.FS`
2. A file watcher on `serve/middleware/routes/templates/elements/**/*.ts` triggers esbuild transpilation and sends a reload signal over WebSocket

## Building with Dev Mode

To build the binary with dev mode enabled:

```bash
make dev-serve
```

This creates `dist/cem` with the `cemdev` build tag.

## Usage

After building with dev mode, start the server as normal:

```bash
dist/cem serve -p examples/kitchen-sink
```

The server will log:
```
[INFO] Dev mode: reading internal modules from disk
[INFO] Dev mode: watching elements directory for changes
```

Now when you edit any TypeScript file in `serve/middleware/routes/templates/elements/`, the server will:

1. Automatically transpile the TypeScript to JavaScript using esbuild
2. Broadcast a reload message to all connected clients
3. The browser will automatically refresh to show your changes

## Architecture

The implementation uses Go build tags to conditionally compile different versions of the code:

### Production Mode (default)

- `serve/internal_modules_prod.go` (`//go:build !cemdev`)
- Reads internal modules from `embed.FS`
- `setupDevWatcher()` is a no-op

### Dev Mode (`-tags cemdev`)

- `serve/internal_modules_dev.go` (`//go:build cemdev`)
- Reads internal modules from disk using `runtime.Caller()` to find the source directory
- Sets up a file watcher on `serve/middleware/routes/templates/elements/**/*.ts`
- On file changes:
- Calls `elements.TranspileElements()` to run esbuild
- Broadcasts reload message to WebSocket clients

### Shared Code

- `serve/internal/elements/transpile.go` - Reusable esbuild transpilation logic
- `serve/middleware/routes/internal_modules.go` - Exports `ReadInternalModule` as a variable that can be overridden

## Testing

The feature includes comprehensive tests:

### Transpilation Tests

```bash
go test ./serve/internal/elements/ -v
```

Tests the esbuild transpilation logic in isolation.

### Production Mode Tests

```bash
go test ./serve/ -v -run TestReadInternalModule_Production
```

Verifies that production mode reads from embed.FS.

### Dev Mode Tests

```bash
go test -tags cemdev ./serve/ -v -run "TestReadInternalModule_Dev|TestSetupDevWatcher_Dev"
```

Verifies that dev mode reads from disk and sets up the file watcher correctly.

## Limitations

- Only TypeScript files in `serve/middleware/routes/templates/elements/` are watched
- Test files (`*.test.ts`) are not transpiled
- The file watcher has a 300ms debounce delay to avoid excessive rebuilds
- Dev mode is not recommended for production use (it's slower and reads from disk)

## Troubleshooting

If the watcher isn't working:

1. Check that you built with `make dev-serve` (not `make build`)
2. Verify the server logs show "Dev mode: watching elements directory for changes"
3. Make sure you're editing `.ts` files (not `.js` files)
4. Check that the file you're editing is in a subdirectory of `serve/middleware/routes/templates/elements/`
81 changes: 81 additions & 0 deletions serve/internal/elements/transpile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
Copyright © 2025 Benny Powers <web@bennypowers.com>

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package elements

import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/evanw/esbuild/pkg/api"
)

// TranspileElements transpiles TypeScript element files to JavaScript
// It looks for .ts files in sourceDir and outputs .js files to the same location
func TranspileElements(sourceDir string) error {
// Find all TypeScript files in the elements directory
var tsFiles []string
err := filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.HasSuffix(path, ".ts") && !strings.HasSuffix(path, ".test.ts") {
tsFiles = append(tsFiles, path)
}
return nil
})
if err != nil {
return fmt.Errorf("walking source directory: %w", err)
}

if len(tsFiles) == 0 {
return nil // No TypeScript files to transpile
}

// Build entry points for esbuild
entryPoints := make([]string, len(tsFiles))
copy(entryPoints, tsFiles)

// Run esbuild to transpile all files
result := api.Build(api.BuildOptions{
EntryPoints: entryPoints,
Outdir: sourceDir,
Bundle: false, // Don't bundle, just transpile
Write: true, // Write to disk
Format: api.FormatESModule,
Target: api.ES2020,
Loader: map[string]api.Loader{
".ts": api.LoaderTS,
},
LogLevel: api.LogLevelWarning,
Sourcemap: api.SourceMapInline,
OutExtension: map[string]string{".js": ".js"},
AllowOverwrite: true, // Allow overwriting existing .js files
})

if len(result.Errors) > 0 {
var errMsgs []string
for _, err := range result.Errors {
errMsgs = append(errMsgs, err.Text)
}
return fmt.Errorf("esbuild errors: %s", strings.Join(errMsgs, "; "))
}

return nil
}
167 changes: 167 additions & 0 deletions serve/internal/elements/transpile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/*
Copyright © 2025 Benny Powers <web@bennypowers.com>

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package elements

import (
"os"
"path/filepath"
"strings"
"testing"
)

func TestTranspileElements(t *testing.T) {
tests := []struct {
name string
setupFiles map[string]string // filename -> content
wantError bool
checkOutput map[string]bool // filename -> should exist
}{
{
name: "transpiles single TypeScript file",
setupFiles: map[string]string{
"test-element.ts": `export class TestElement extends HTMLElement {
connectedCallback() {
this.textContent = 'Hello';
}
}`,
},
wantError: false,
checkOutput: map[string]bool{
"test-element.js": true,
},
},
{
name: "transpiles multiple TypeScript files",
setupFiles: map[string]string{
"element-a.ts": `export class ElementA extends HTMLElement {}`,
"element-b.ts": `export class ElementB extends HTMLElement {}`,
},
wantError: false,
checkOutput: map[string]bool{
"element-a.js": true,
"element-b.js": true,
},
},
{
name: "skips test files",
setupFiles: map[string]string{
"element.ts": `export class Element extends HTMLElement {}`,
"element.test.ts": `import { Element } from './element';`,
},
wantError: false,
checkOutput: map[string]bool{
"element.js": true,
"element.test.js": false,
},
},
{
name: "handles directory with no TypeScript files",
setupFiles: map[string]string{
"readme.md": `# Readme`,
},
wantError: false,
checkOutput: map[string]bool{
"readme.md": true,
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create temporary directory
tmpDir := t.TempDir()

// Set up test files
for filename, content := range tt.setupFiles {
path := filepath.Join(tmpDir, filename)
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file %s: %v", filename, err)
}
}

// Run transpilation
err := TranspileElements(tmpDir)

// Check error
if (err != nil) != tt.wantError {
t.Errorf("TranspileElements() error = %v, wantError %v", err, tt.wantError)
return
}

// Check output files
for filename, shouldExist := range tt.checkOutput {
path := filepath.Join(tmpDir, filename)
_, err := os.Stat(path)
exists := err == nil

if exists != shouldExist {
t.Errorf("Output file %s: exists = %v, want %v", filename, exists, shouldExist)
}

// For .js files, verify they contain valid JavaScript
if shouldExist && exists && strings.HasSuffix(filename, ".js") {
content, err := os.ReadFile(path)
if err != nil {
t.Errorf("Failed to read output file %s: %v", filename, err)
continue
}
if len(content) == 0 {
t.Errorf("Output file %s is empty", filename)
}
// Basic sanity check - should contain 'export'
if !strings.Contains(string(content), "export") {
t.Errorf("Output file %s doesn't appear to be valid JavaScript: %s", filename, string(content))
}
}
}
})
}
}

func TestTranspileElements_Subdirectories(t *testing.T) {
// Create temporary directory structure
tmpDir := t.TempDir()

// Create subdirectories with TypeScript files
subdirs := []string{"cem-drawer", "cem-panel"}
for _, subdir := range subdirs {
dir := filepath.Join(tmpDir, subdir)
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("Failed to create subdirectory %s: %v", subdir, err)
}

tsFile := filepath.Join(dir, subdir+".ts")
content := `export class MyElement extends HTMLElement {}`
if err := os.WriteFile(tsFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create TypeScript file: %v", err)
}
}

// Run transpilation
if err := TranspileElements(tmpDir); err != nil {
t.Fatalf("TranspileElements() error = %v", err)
}

// Verify .js files were created in subdirectories
for _, subdir := range subdirs {
jsFile := filepath.Join(tmpDir, subdir, subdir+".js")
if _, err := os.Stat(jsFile); err != nil {
t.Errorf("Expected .js file not found: %s", jsFile)
}
}
}
Loading
Loading