Turned code templates into embedded static assets and draft config page

Change-Id: Ifcab25a9620b9b8dde3a3b26b2420dbbc5b15fdd
diff --git a/cmd/koralmapper/main.go b/cmd/koralmapper/main.go
index 0e5044b..1286af0 100644
--- a/cmd/koralmapper/main.go
+++ b/cmd/koralmapper/main.go
@@ -1,14 +1,20 @@
 package main
 
 import (
+	"bytes"
+	"embed"
 	"fmt"
+	"html/template"
+	"io/fs"
 	"net/url"
 	"os"
 	"os/signal"
 	"path"
 	"path/filepath"
+	"strconv"
 	"strings"
 	"syscall"
+	texttemplate "text/template"
 	"time"
 
 	"github.com/KorAP/Koral-Mapper/config"
@@ -19,6 +25,9 @@
 	"github.com/rs/zerolog/log"
 )
 
+//go:embed static/*
+var staticFS embed.FS
+
 const (
 	maxInputLength = 1024 * 1024 // 1MB
 	maxParamLength = 1024        // 1KB
@@ -31,13 +40,7 @@
 	LogLevel *string  `kong:"short='l',help='Log level (debug, info, warn, error)'"`
 }
 
-type TemplateMapping struct {
-	ID          string
-	Description string
-}
-
-// TemplateData holds data for the Kalamar plugin template
-type TemplateData struct {
+type BasePageData struct {
 	Title       string
 	Version     string
 	Hash        string
@@ -46,8 +49,14 @@
 	Server      string
 	SDK         string
 	ServiceURL  string
+}
+
+type SingleMappingPageData struct {
+	BasePageData
 	MapID       string
-	Mappings    []TemplateMapping
+	Mappings    []config.MappingList
+	QueryURL    string
+	ResponseURL string
 }
 
 type QueryParams struct {
@@ -68,6 +77,13 @@
 	LayerB   string
 }
 
+// ConfigPageData holds all data passed to the configuration page template.
+type ConfigPageData struct {
+	BasePageData
+	AnnotationMappings []config.MappingList
+	CorpusMappings     []config.MappingList
+}
+
 func parseConfig() *appConfig {
 	cfg := &appConfig{}
 
@@ -240,9 +256,14 @@
 	// Start server
 	go func() {
 		log.Info().Int("port", finalPort).Msg("Starting server")
+		fmt.Printf("Starting server port=%d\n", finalPort)
 
 		for _, list := range yamlConfig.Lists {
 			log.Info().Str("id", list.ID).Str("desc", list.Description).Msg("Loaded mapping")
+			fmt.Printf("Loaded mapping desc=%s id=%s\n",
+				formatConsoleField(list.Description),
+				list.ID,
+			)
 		}
 
 		if err := app.Listen(fmt.Sprintf(":%d", finalPort)); err != nil {
@@ -263,11 +284,17 @@
 }
 
 func setupRoutes(app *fiber.App, m *mapper.Mapper, yamlConfig *config.MappingConfig) {
+	configTmpl := template.Must(template.ParseFS(staticFS, "static/config.html"))
+	pluginTmpl := texttemplate.Must(texttemplate.ParseFS(staticFS, "static/plugin.html"))
+
 	// Health check endpoint
 	app.Get("/health", func(c *fiber.Ctx) error {
 		return c.SendString("OK")
 	})
 
+	// Static file serving from embedded FS
+	app.Get("/static/*", handleStaticFile())
+
 	// Composite cascade transformation endpoints
 	app.Post("/query", handleCompositeQueryTransform(m, yamlConfig.Lists))
 	app.Post("/response", handleCompositeResponseTransform(m, yamlConfig.Lists))
@@ -279,8 +306,59 @@
 	app.Post("/:map/response", handleResponseTransform(m))
 
 	// Kalamar plugin endpoint
-	app.Get("/", handleKalamarPlugin(yamlConfig))
-	app.Get("/:map", handleKalamarPlugin(yamlConfig))
+	app.Get("/", handleKalamarPlugin(yamlConfig, configTmpl, pluginTmpl))
+	app.Get("/:map", handleKalamarPlugin(yamlConfig, configTmpl, pluginTmpl))
+}
+
+func handleStaticFile() fiber.Handler {
+	return func(c *fiber.Ctx) error {
+		name := c.Params("*")
+		data, err := fs.ReadFile(staticFS, "static/"+name)
+		if err != nil {
+			return c.Status(fiber.StatusNotFound).SendString("not found")
+		}
+		switch {
+		case strings.HasSuffix(name, ".js"):
+			c.Set("Content-Type", "text/javascript; charset=utf-8")
+		case strings.HasSuffix(name, ".css"):
+			c.Set("Content-Type", "text/css; charset=utf-8")
+		case strings.HasSuffix(name, ".html"):
+			c.Set("Content-Type", "text/html; charset=utf-8")
+		}
+		return c.Send(data)
+	}
+}
+
+func buildBasePageData(yamlConfig *config.MappingConfig) BasePageData {
+	return BasePageData{
+		Title:       config.Title,
+		Version:     config.Version,
+		Hash:        config.Buildhash,
+		Date:        config.Buildtime,
+		Description: config.Description,
+		Server:      yamlConfig.Server,
+		SDK:         yamlConfig.SDK,
+		ServiceURL:  yamlConfig.ServiceURL,
+	}
+}
+
+func buildConfigPageData(yamlConfig *config.MappingConfig) ConfigPageData {
+	data := ConfigPageData{
+		BasePageData: buildBasePageData(yamlConfig),
+	}
+
+	for _, list := range yamlConfig.Lists {
+		normalized := list
+		if normalized.Type == "" {
+			normalized.Type = "annotation"
+		}
+		if list.IsCorpus() {
+			data.CorpusMappings = append(data.CorpusMappings, normalized)
+		} else {
+			data.AnnotationMappings = append(data.AnnotationMappings, normalized)
+		}
+	}
+	return data
 }
 
 func handleCompositeQueryTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
@@ -513,10 +591,23 @@
 	return nil
 }
 
-func handleKalamarPlugin(yamlConfig *config.MappingConfig) fiber.Handler {
+func handleKalamarPlugin(yamlConfig *config.MappingConfig, configTmpl *template.Template, pluginTmpl *texttemplate.Template) fiber.Handler {
 	return func(c *fiber.Ctx) error {
 		mapID := c.Params("map")
 
+		// Config page (GET /)
+		if mapID == "" {
+			data := buildConfigPageData(yamlConfig)
+			var buf bytes.Buffer
+			if err := configTmpl.Execute(&buf, data); err != nil {
+				log.Error().Err(err).Msg("Failed to execute config template")
+				return c.Status(fiber.StatusInternalServerError).SendString("internal error")
+			}
+			c.Set("Content-Type", "text/html")
+			return c.Send(buf.Bytes())
+		}
+
+		// Single-mapping page (GET /:map) — existing behavior
 		// Get query parameters
 		dir := c.Query("dir", "atob")
 		foundryA := c.Query("foundryA", "")
@@ -537,30 +628,6 @@
 			})
 		}
 
-		// Get list of available mappings
-		var mappings []TemplateMapping
-		for _, list := range yamlConfig.Lists {
-			mappings = append(mappings, TemplateMapping{
-				ID:          list.ID,
-				Description: list.Description,
-			})
-		}
-
-		// Prepare template data
-		data := TemplateData{
-			Title:       config.Title,
-			Version:     config.Version,
-			Hash:        config.Buildhash,
-			Date:        config.Buildtime,
-			Description: config.Description,
-			Server:      yamlConfig.Server,
-			SDK:         yamlConfig.SDK,
-			ServiceURL:  yamlConfig.ServiceURL,
-			MapID:       mapID,
-			Mappings:    mappings,
-		}
-
-		// Add query parameters to template data
 		queryParams := QueryParams{
 			Dir:      dir,
 			FoundryA: foundryA,
@@ -569,136 +636,56 @@
 			LayerB:   layerB,
 		}
 
-		// Generate HTML
-		html := generateKalamarPluginHTML(data, queryParams)
+		queryURL, err := buildMapServiceURL(yamlConfig.ServiceURL, mapID, "query", queryParams)
+		if err != nil {
+			log.Warn().Err(err).Msg("Failed to build query service URL")
+			return c.Status(fiber.StatusInternalServerError).SendString("internal error")
+		}
+		reversed := queryParams
+		if queryParams.Dir == "btoa" {
+			reversed.Dir = "atob"
+		} else {
+			reversed.Dir = "btoa"
+		}
+		responseURL, err := buildMapServiceURL(yamlConfig.ServiceURL, mapID, "response", reversed)
+		if err != nil {
+			log.Warn().Err(err).Msg("Failed to build response service URL")
+			return c.Status(fiber.StatusInternalServerError).SendString("internal error")
+		}
 
+		data := SingleMappingPageData{
+			BasePageData: buildBasePageData(yamlConfig),
+			MapID:        mapID,
+			Mappings:     yamlConfig.Lists,
+			QueryURL:     queryURL,
+			ResponseURL:  responseURL,
+		}
+
+		var buf bytes.Buffer
+		if err := pluginTmpl.Execute(&buf, data); err != nil {
+			log.Error().Err(err).Msg("Failed to execute plugin template")
+			return c.Status(fiber.StatusInternalServerError).SendString("internal error")
+		}
 		c.Set("Content-Type", "text/html")
-		return c.SendString(html)
+		return c.Send(buf.Bytes())
 	}
 }
 
-// generateKalamarPluginHTML creates the HTML template for the Kalamar plugin page
-// This function can be easily modified to change the appearance and content
-func generateKalamarPluginHTML(data TemplateData, queryParams QueryParams) string {
-	html := `<!DOCTYPE html>
-<html lang="en">
-<head>
-    <meta charset="UTF-8">
-    <title>` + data.Title + `</title>
-    <script src="` + data.SDK + `"
-            data-server="` + data.Server + `"></script>
-</head>
-<body>
-    <div class="container">
-        <h1>` + data.Title + `</h1>
-		<p>` + data.Description + `</p>`
-
-	if data.MapID != "" {
-		html += `<p>Map ID: ` + data.MapID + `</p>`
+func buildMapServiceURL(serviceURL, mapID, endpoint string, params QueryParams) (string, error) {
+	service, err := url.Parse(serviceURL)
+	if err != nil {
+		return "", err
 	}
+	service.Path = path.Join(service.Path, mapID, endpoint)
+	service.RawQuery = buildQueryParams(params.Dir, params.FoundryA, params.FoundryB, params.LayerA, params.LayerB)
+	return service.String(), nil
+}
 
-	html += `		<h2>Plugin Information</h2>
-        <p><strong>Version:</strong> <tt>` + data.Version + `</tt></p>
-		<p><strong>Build Date:</strong> <tt>` + data.Date + `</tt></p>
-		<p><strong>Build Hash:</strong> <tt>` + data.Hash + `</tt></p>
-
-        <h2>Available API Endpoints</h2>
-        <dl>
-
-		    <dt><tt><strong>GET</strong> /:map</tt></dt>
-            <dd><small>Kalamar integration</small></dd>
-
-			<dt><tt><strong>POST</strong> /:map/query</tt></dt>
-            <dd><small>Transform JSON query objects using term mapping rules</small></dd>
-
-			<dt><tt><strong>POST</strong> /:map/response</tt></dt>
-            <dd><small>Transform JSON response objects using term mapping rules</small></dd>
-			
-        </dl>
-		
-		<h2>Available Term Mappings</h2>
-	    <dl>`
-
-	for _, m := range data.Mappings {
-		html += `<dt><tt>` + m.ID + `</tt></dt>`
-		html += `<dd>` + m.Description + `</dd>`
+func formatConsoleField(value string) string {
+	if strings.ContainsAny(value, " \t") {
+		return strconv.Quote(value)
 	}
-
-	html += `
-    </dl></div>`
-
-	if data.MapID != "" {
-
-		queryServiceURL, err := url.Parse(data.ServiceURL)
-		if err != nil {
-			log.Warn().Err(err).Msg("Failed to join URL path")
-		}
-
-		// Use path.Join to normalize the path part
-		queryServiceURL.Path = path.Join(queryServiceURL.Path, data.MapID+"/query")
-
-		// Build query parameters for query URL
-		queryParamString := buildQueryParams(queryParams.Dir, queryParams.FoundryA, queryParams.FoundryB, queryParams.LayerA, queryParams.LayerB)
-		queryServiceURL.RawQuery = queryParamString
-
-		responseServiceURL, err := url.Parse(data.ServiceURL)
-		if err != nil {
-			log.Warn().Err(err).Msg("Failed to join URL path")
-		}
-
-		// Use path.Join to normalize the path part
-		responseServiceURL.Path = path.Join(responseServiceURL.Path, data.MapID+"/response")
-
-		reversedDir := "btoa"
-		if queryParams.Dir == "btoa" {
-			reversedDir = "atob"
-		}
-
-		// Build query parameters for response URL (with reversed direction)
-		responseParamString := buildQueryParams(reversedDir, queryParams.FoundryA, queryParams.FoundryB, queryParams.LayerA, queryParams.LayerB)
-		responseServiceURL.RawQuery = responseParamString
-
-		html += `<script>
-  		<!-- activates/deactivates Mapper. -->
-  		  
-        let qdata = {
-          'action'  : 'pipe',
-          'service' : '` + queryServiceURL.String() + `'
-        };
-
-        let rdata = {
-          'action'  : 'pipe',
-          'service' : '` + responseServiceURL.String() + `'
-        };
-
-
-        function pluginit (p) {
-          p.onMessage = function(msg) {
-            if (msg.key == 'koralmapper') {
-              if (msg.value) {
-                qdata['job'] = 'add';
-              }
-              else {
-                qdata['job'] = 'del';
-              };
-              KorAPlugin.sendMsg(qdata);
-			 if (msg.value) {
-                rdata['job'] = 'add-after';
-              }
-              else {
-                rdata['job'] = 'del-after';
-              };  
-              KorAPlugin.sendMsg(rdata);
-            };
-          };
-        };
-     </script>`
-	}
-
-	html += `  </body>
-</html>`
-
-	return html
+	return value
 }
 
 // buildQueryParams builds a query string from the provided parameters
diff --git a/cmd/koralmapper/main_test.go b/cmd/koralmapper/main_test.go
index a3e45a8..30def87 100644
--- a/cmd/koralmapper/main_test.go
+++ b/cmd/koralmapper/main_test.go
@@ -3,7 +3,9 @@
 import (
 	"bytes"
 	"encoding/json"
+	"html/template"
 	"io"
+	"io/fs"
 	"net/http"
 	"net/http/httptest"
 	"os"
@@ -19,32 +21,56 @@
 	"github.com/stretchr/testify/require"
 )
 
-func TestTransformEndpoint(t *testing.T) {
-	// Create test mapping list
-	mappingList := tmconfig.MappingList{
-		ID:       "test-mapper",
-		FoundryA: "opennlp",
-		LayerA:   "p",
-		FoundryB: "upos",
-		LayerB:   "p",
-		Mappings: []tmconfig.MappingRule{
-			"[PIDAT] <> [opennlp/p=PIDAT & opennlp/p=AdjType:Pdt]",
-			"[DET] <> [opennlp/p=DET]",
-		},
+func loadConfigFromYAML(t *testing.T, configYAML string, mappingYAMLs ...string) *tmconfig.MappingConfig {
+	t.Helper()
+
+	configPath := ""
+	if configYAML != "" {
+		cfgFile, err := os.CreateTemp("", "koralmapper-config-*.yaml")
+		require.NoError(t, err)
+		t.Cleanup(func() { _ = os.Remove(cfgFile.Name()) })
+		_, err = cfgFile.WriteString(configYAML)
+		require.NoError(t, err)
+		require.NoError(t, cfgFile.Close())
+		configPath = cfgFile.Name()
 	}
 
+	mappingPaths := make([]string, 0, len(mappingYAMLs))
+	for _, content := range mappingYAMLs {
+		mapFile, err := os.CreateTemp("", "koralmapper-mapping-*.yaml")
+		require.NoError(t, err)
+		t.Cleanup(func() { _ = os.Remove(mapFile.Name()) })
+		_, err = mapFile.WriteString(content)
+		require.NoError(t, err)
+		require.NoError(t, mapFile.Close())
+		mappingPaths = append(mappingPaths, mapFile.Name())
+	}
+
+	cfg, err := tmconfig.LoadFromSources(configPath, mappingPaths)
+	require.NoError(t, err)
+	return cfg
+}
+
+func TestTransformEndpoint(t *testing.T) {
+	cfg := loadConfigFromYAML(t, `
+lists:
+  - id: test-mapper
+    foundryA: opennlp
+    layerA: p
+    foundryB: upos
+    layerB: p
+    mappings:
+      - "[PIDAT] <> [opennlp/p=PIDAT & opennlp/p=AdjType:Pdt]"
+      - "[DET] <> [opennlp/p=DET]"
+`)
+
 	// Create mapper
-	m, err := mapper.NewMapper([]tmconfig.MappingList{mappingList})
+	m, err := mapper.NewMapper(cfg.Lists)
 	require.NoError(t, err)
 
-	// Create mock config for testing
-	mockConfig := &tmconfig.MappingConfig{
-		Lists: []tmconfig.MappingList{mappingList},
-	}
-
 	// Create fiber app
 	app := fiber.New()
-	setupRoutes(app, m, mockConfig)
+	setupRoutes(app, m, cfg)
 
 	tests := []struct {
 		name          string
@@ -260,30 +286,24 @@
 }
 
 func TestResponseTransformEndpoint(t *testing.T) {
-	// Create test mapping list
-	mappingList := tmconfig.MappingList{
-		ID:       "test-response-mapper",
-		FoundryA: "marmot",
-		LayerA:   "m",
-		FoundryB: "opennlp",
-		LayerB:   "p",
-		Mappings: []tmconfig.MappingRule{
-			"[gender:masc] <> [p=M & m=M]",
-		},
-	}
+	cfg := loadConfigFromYAML(t, `
+lists:
+  - id: test-response-mapper
+    foundryA: marmot
+    layerA: m
+    foundryB: opennlp
+    layerB: p
+    mappings:
+      - "[gender:masc] <> [p=M & m=M]"
+`)
 
 	// Create mapper
-	m, err := mapper.NewMapper([]tmconfig.MappingList{mappingList})
+	m, err := mapper.NewMapper(cfg.Lists)
 	require.NoError(t, err)
 
-	// Create mock config for testing
-	mockConfig := &tmconfig.MappingConfig{
-		Lists: []tmconfig.MappingList{mappingList},
-	}
-
 	// Create fiber app
 	app := fiber.New()
-	setupRoutes(app, m, mockConfig)
+	setupRoutes(app, m, cfg)
 
 	tests := []struct {
 		name          string
@@ -1292,71 +1312,62 @@
 	assert.Contains(t, htmlContent, "'service' : '"+expectedJSURL)
 }
 
-func TestGenerateKalamarPluginHTMLWithURLJoining(t *testing.T) {
+func TestBuildMapServiceURLWithURLJoining(t *testing.T) {
 	tests := []struct {
 		name       string
 		serviceURL string
 		mapID      string
+		endpoint   string
 		expected   string
 	}{
 		{
 			name:       "Service URL without trailing slash",
 			serviceURL: "https://example.com/plugin/koralmapper",
 			mapID:      "test-mapper",
-			expected:   "'service' : 'https://example.com/plugin/koralmapper/test-mapper/query",
+			endpoint:   "query",
+			expected:   "https://example.com/plugin/koralmapper/test-mapper/query?dir=atob",
 		},
 		{
 			name:       "Service URL with trailing slash",
 			serviceURL: "https://example.com/plugin/koralmapper/",
 			mapID:      "test-mapper",
-			expected:   "'service' : 'https://example.com/plugin/koralmapper/test-mapper/query",
+			endpoint:   "query",
+			expected:   "https://example.com/plugin/koralmapper/test-mapper/query?dir=atob",
 		},
 		{
 			name:       "Map ID with leading slash",
 			serviceURL: "https://example.com/plugin/koralmapper",
 			mapID:      "/test-mapper",
-			expected:   "'service' : 'https://example.com/plugin/koralmapper/test-mapper/query",
+			endpoint:   "query",
+			expected:   "https://example.com/plugin/koralmapper/test-mapper/query?dir=atob",
 		},
 		{
 			name:       "Both with slashes",
 			serviceURL: "https://example.com/plugin/koralmapper/",
 			mapID:      "/test-mapper",
-			expected:   "'service' : 'https://example.com/plugin/koralmapper/test-mapper/query",
+			endpoint:   "query",
+			expected:   "https://example.com/plugin/koralmapper/test-mapper/query?dir=atob",
 		},
 		{
 			name:       "Complex map ID",
 			serviceURL: "https://example.com/api/v1/",
 			mapID:      "complex-mapper-name_123",
-			expected:   "'service' : 'https://example.com/api/v1/complex-mapper-name_123/query",
+			endpoint:   "query",
+			expected:   "https://example.com/api/v1/complex-mapper-name_123/query?dir=atob",
 		},
 	}
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			data := TemplateData{
-				Title:       "Test Mapper",
-				Version:     "1.0.0",
-				Hash:        "abcd1234",
-				Date:        "2024-01-01",
-				Description: "Test description",
-				Server:      "https://example.com/",
-				SDK:         "https://example.com/js/sdk.js",
-				ServiceURL:  tt.serviceURL,
-				MapID:       tt.mapID,
-				Mappings:    []TemplateMapping{},
-			}
-
-			// Use default query parameters for this test
-			queryParams := QueryParams{
+			got, err := buildMapServiceURL(tt.serviceURL, tt.mapID, tt.endpoint, QueryParams{
 				Dir:      "atob",
 				FoundryA: "",
 				FoundryB: "",
 				LayerA:   "",
 				LayerB:   "",
-			}
-
-			html := generateKalamarPluginHTML(data, queryParams)
-			assert.Contains(t, html, tt.expected)
+			})
+			require.NoError(t, err)
+			assert.Equal(t, tt.expected, got)
 		})
 	}
 }
@@ -1550,33 +1561,28 @@
 }
 
 func TestCompositeQueryEndpoint(t *testing.T) {
-	lists := []tmconfig.MappingList{
-		{
-			ID:       "step1",
-			FoundryA: "opennlp",
-			LayerA:   "p",
-			FoundryB: "opennlp",
-			LayerB:   "p",
-			Mappings: []tmconfig.MappingRule{
-				"[PIDAT] <> [DET]",
-			},
-		},
-		{
-			ID:       "step2",
-			FoundryA: "opennlp",
-			LayerA:   "p",
-			FoundryB: "upos",
-			LayerB:   "p",
-			Mappings: []tmconfig.MappingRule{
-				"[DET] <> [PRON]",
-			},
-		},
-	}
-	m, err := mapper.NewMapper(lists)
+	cfg := loadConfigFromYAML(t, `
+lists:
+  - id: step1
+    foundryA: opennlp
+    layerA: p
+    foundryB: opennlp
+    layerB: p
+    mappings:
+      - "[PIDAT] <> [DET]"
+  - id: step2
+    foundryA: opennlp
+    layerA: p
+    foundryB: upos
+    layerB: p
+    mappings:
+      - "[DET] <> [PRON]"
+`)
+	m, err := mapper.NewMapper(cfg.Lists)
 	require.NoError(t, err)
 
 	app := fiber.New()
-	setupRoutes(app, m, &tmconfig.MappingConfig{Lists: lists})
+	setupRoutes(app, m, cfg)
 
 	tests := []struct {
 		name         string
@@ -1669,23 +1675,22 @@
 }
 
 func TestCompositeResponseEndpoint(t *testing.T) {
-	lists := []tmconfig.MappingList{
-		{
-			ID:       "resp-step1",
-			Type:     "corpus",
-			Mappings: []tmconfig.MappingRule{"textClass=novel <> genre=fiction"},
-		},
-		{
-			ID:       "resp-step2",
-			Type:     "corpus",
-			Mappings: []tmconfig.MappingRule{"genre=fiction <> category=lit"},
-		},
-	}
-	m, err := mapper.NewMapper(lists)
+	cfg := loadConfigFromYAML(t, `
+lists:
+  - id: resp-step1
+    type: corpus
+    mappings:
+      - "textClass=novel <> genre=fiction"
+  - id: resp-step2
+    type: corpus
+    mappings:
+      - "genre=fiction <> category=lit"
+`)
+	m, err := mapper.NewMapper(cfg.Lists)
 	require.NoError(t, err)
 
 	app := fiber.New()
-	setupRoutes(app, m, &tmconfig.MappingConfig{Lists: lists})
+	setupRoutes(app, m, cfg)
 
 	tests := []struct {
 		name         string
@@ -1767,3 +1772,410 @@
 		})
 	}
 }
+
+func TestEmbeddedFilesExist(t *testing.T) {
+	files := []string{"static/config.html", "static/plugin.html", "static/config.js", "static/style.css"}
+	for _, f := range files {
+		t.Run(f, func(t *testing.T) {
+			data, err := fs.ReadFile(staticFS, f)
+			require.NoError(t, err, "embedded file %s should exist", f)
+			assert.NotEmpty(t, data, "embedded file %s should not be empty", f)
+		})
+	}
+}
+
+func TestConfigTemplateParsesSuccessfully(t *testing.T) {
+	tmpl, err := template.ParseFS(staticFS, "static/config.html")
+	require.NoError(t, err, "config template should parse without error")
+	require.NotNil(t, tmpl)
+}
+
+func TestStaticFileServing(t *testing.T) {
+	mappingList := tmconfig.MappingList{
+		ID:       "test-mapper",
+		Mappings: []tmconfig.MappingRule{"[A] <> [B]"},
+	}
+	m, err := mapper.NewMapper([]tmconfig.MappingList{mappingList})
+	require.NoError(t, err)
+	mockConfig := &tmconfig.MappingConfig{Lists: []tmconfig.MappingList{mappingList}}
+	tmconfig.ApplyDefaults(mockConfig)
+
+	app := fiber.New()
+	setupRoutes(app, m, mockConfig)
+
+	tests := []struct {
+		name          string
+		url           string
+		expectedCode  int
+		expectedCType string
+	}{
+		{
+			name:          "config.js is served with correct content type",
+			url:           "/static/config.js",
+			expectedCode:  http.StatusOK,
+			expectedCType: "text/javascript",
+		},
+		{
+			name:          "style.css is served with correct content type",
+			url:           "/static/style.css",
+			expectedCode:  http.StatusOK,
+			expectedCType: "text/css",
+		},
+		{
+			name:         "non-existent static file returns 404",
+			url:          "/static/nonexistent.txt",
+			expectedCode: http.StatusNotFound,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			req := httptest.NewRequest(http.MethodGet, tt.url, nil)
+			resp, err := app.Test(req)
+			require.NoError(t, err)
+			defer resp.Body.Close()
+
+			assert.Equal(t, tt.expectedCode, resp.StatusCode)
+			if tt.expectedCType != "" {
+				assert.Contains(t, resp.Header.Get("Content-Type"), tt.expectedCType)
+			}
+		})
+	}
+}
+
+func TestStaticFileContent(t *testing.T) {
+	mappingList := tmconfig.MappingList{
+		ID:       "test-mapper",
+		Mappings: []tmconfig.MappingRule{"[A] <> [B]"},
+	}
+	m, err := mapper.NewMapper([]tmconfig.MappingList{mappingList})
+	require.NoError(t, err)
+	mockConfig := &tmconfig.MappingConfig{Lists: []tmconfig.MappingList{mappingList}}
+	tmconfig.ApplyDefaults(mockConfig)
+
+	app := fiber.New()
+	setupRoutes(app, m, mockConfig)
+
+	// Verify served content matches embedded content
+	files := []string{"config.js", "style.css"}
+	for _, f := range files {
+		t.Run(f, func(t *testing.T) {
+			embedded, err := fs.ReadFile(staticFS, "static/"+f)
+			require.NoError(t, err)
+
+			req := httptest.NewRequest(http.MethodGet, "/static/"+f, nil)
+			resp, err := app.Test(req)
+			require.NoError(t, err)
+			defer resp.Body.Close()
+
+			body, err := io.ReadAll(resp.Body)
+			require.NoError(t, err)
+			assert.Equal(t, embedded, body, "served content should match embedded content for %s", f)
+		})
+	}
+}
+
+func TestConfigPageRendering(t *testing.T) {
+	lists := []tmconfig.MappingList{
+		{
+			ID:          "anno-mapper",
+			Description: "Annotation mapping",
+			FoundryA:    "opennlp",
+			LayerA:      "p",
+			FoundryB:    "upos",
+			LayerB:      "p",
+			Mappings:    []tmconfig.MappingRule{"[A] <> [B]"},
+		},
+		{
+			ID:          "corpus-mapper",
+			Type:        "corpus",
+			Description: "Corpus mapping",
+			Mappings:    []tmconfig.MappingRule{"textClass=science <> textClass=akademisch"},
+		},
+	}
+	m, err := mapper.NewMapper(lists)
+	require.NoError(t, err)
+
+	mockConfig := &tmconfig.MappingConfig{
+		SDK:        "https://example.com/sdk.js",
+		Server:     "https://example.com/",
+		ServiceURL: "https://example.com/plugin/koralmapper",
+		Lists:      lists,
+	}
+
+	app := fiber.New()
+	setupRoutes(app, m, mockConfig)
+
+	req := httptest.NewRequest(http.MethodGet, "/", nil)
+	resp, err := app.Test(req)
+	require.NoError(t, err)
+	defer resp.Body.Close()
+
+	assert.Equal(t, http.StatusOK, resp.StatusCode)
+
+	body, err := io.ReadAll(resp.Body)
+	require.NoError(t, err)
+	htmlContent := string(body)
+
+	// HTML structure
+	assert.Contains(t, htmlContent, "<!DOCTYPE html>")
+	assert.Contains(t, htmlContent, `<meta charset="UTF-8">`)
+	assert.Contains(t, htmlContent, "Koral-Mapper")
+
+	// SDK and server
+	assert.Contains(t, htmlContent, `src="https://example.com/sdk.js"`)
+	assert.Contains(t, htmlContent, `data-server="https://example.com/"`)
+
+	// ServiceURL as data attribute
+	assert.Contains(t, htmlContent, `data-service-url="https://example.com/plugin/koralmapper"`)
+
+	// Static file references
+	assert.Contains(t, htmlContent, `/static/style.css`)
+	assert.Contains(t, htmlContent, `/static/config.js`)
+
+	// Annotation mapping section
+	assert.Contains(t, htmlContent, "Query")
+	assert.Contains(t, htmlContent, `data-id="anno-mapper"`)
+	assert.Contains(t, htmlContent, `data-type="annotation"`)
+	assert.Contains(t, htmlContent, `value="opennlp"`)
+	assert.Contains(t, htmlContent, `value="upos"`)
+	assert.Contains(t, htmlContent, "Annotation mapping")
+
+	// Corpus mapping section
+	assert.Contains(t, htmlContent, "Corpus")
+	assert.Contains(t, htmlContent, `data-id="corpus-mapper"`)
+	assert.Contains(t, htmlContent, `data-type="corpus"`)
+	assert.Contains(t, htmlContent, "Corpus mapping")
+}
+
+func TestConfigPageAnnotationMappingHasFoundryInputs(t *testing.T) {
+	lists := []tmconfig.MappingList{
+		{
+			ID:       "anno-mapper",
+			FoundryA: "opennlp",
+			LayerA:   "p",
+			FoundryB: "upos",
+			LayerB:   "pos",
+			Mappings: []tmconfig.MappingRule{"[A] <> [B]"},
+		},
+	}
+	m, err := mapper.NewMapper(lists)
+	require.NoError(t, err)
+
+	mockConfig := &tmconfig.MappingConfig{Lists: lists}
+	tmconfig.ApplyDefaults(mockConfig)
+
+	app := fiber.New()
+	setupRoutes(app, m, mockConfig)
+
+	req := httptest.NewRequest(http.MethodGet, "/", nil)
+	resp, err := app.Test(req)
+	require.NoError(t, err)
+	defer resp.Body.Close()
+
+	body, err := io.ReadAll(resp.Body)
+	require.NoError(t, err)
+	htmlContent := string(body)
+
+	// Data attributes for default values
+	assert.Contains(t, htmlContent, `data-default-foundry-a="opennlp"`)
+	assert.Contains(t, htmlContent, `data-default-layer-a="p"`)
+	assert.Contains(t, htmlContent, `data-default-foundry-b="upos"`)
+	assert.Contains(t, htmlContent, `data-default-layer-b="pos"`)
+
+	// Input fields with correct CSS classes
+	assert.Contains(t, htmlContent, `class="foundryA"`)
+	assert.Contains(t, htmlContent, `class="layerA"`)
+	assert.Contains(t, htmlContent, `class="foundryB"`)
+	assert.Contains(t, htmlContent, `class="layerB"`)
+
+	// Direction arrow
+	assert.Contains(t, htmlContent, `class="dir-arrow"`)
+	assert.Contains(t, htmlContent, `data-dir="atob"`)
+
+	// Request and response checkboxes
+	assert.Contains(t, htmlContent, `class="request-cb"`)
+	assert.Contains(t, htmlContent, `class="response-cb"`)
+}
+
+func TestConfigPageCorpusMappingHasNoFoundryInputs(t *testing.T) {
+	lists := []tmconfig.MappingList{
+		{
+			ID:       "corpus-mapper",
+			Type:     "corpus",
+			Mappings: []tmconfig.MappingRule{"textClass=science <> textClass=akademisch"},
+		},
+	}
+
+	m, err := mapper.NewMapper(lists)
+	require.NoError(t, err)
+
+	mockConfig := &tmconfig.MappingConfig{Lists: lists}
+	tmconfig.ApplyDefaults(mockConfig)
+
+	app := fiber.New()
+	setupRoutes(app, m, mockConfig)
+
+	req := httptest.NewRequest(http.MethodGet, "/", nil)
+	resp, err := app.Test(req)
+	require.NoError(t, err)
+	defer resp.Body.Close()
+
+	body, err := io.ReadAll(resp.Body)
+	require.NoError(t, err)
+	htmlContent := string(body)
+
+	// Corpus section exists
+	assert.Contains(t, htmlContent, `data-id="corpus-mapper"`)
+	assert.Contains(t, htmlContent, `data-type="corpus"`)
+
+	// Checkboxes present
+	assert.Contains(t, htmlContent, `class="request-cb"`)
+	assert.Contains(t, htmlContent, `class="response-cb"`)
+
+	// No foundry/layer inputs (only corpus mappings, no annotation section)
+	assert.NotContains(t, htmlContent, `class="foundryA"`)
+	assert.NotContains(t, htmlContent, `class="dir-arrow"`)
+}
+
+func TestConfigPageBackwardCompatibility(t *testing.T) {
+	lists := []tmconfig.MappingList{
+		{
+			ID:       "test-mapper",
+			Mappings: []tmconfig.MappingRule{"[A] <> [B]"},
+		},
+	}
+
+	m, err := mapper.NewMapper(lists)
+	require.NoError(t, err)
+
+	mockConfig := &tmconfig.MappingConfig{
+		ServiceURL: "https://example.com/plugin/koralmapper",
+		Lists:      lists,
+	}
+	tmconfig.ApplyDefaults(mockConfig)
+
+	app := fiber.New()
+	setupRoutes(app, m, mockConfig)
+
+	req := httptest.NewRequest(http.MethodGet, "/test-mapper", nil)
+	resp, err := app.Test(req)
+	require.NoError(t, err)
+	defer resp.Body.Close()
+
+	assert.Equal(t, http.StatusOK, resp.StatusCode)
+
+	body, err := io.ReadAll(resp.Body)
+	require.NoError(t, err)
+	htmlContent := string(body)
+
+	// Old-style single-mapping page behavior
+	assert.Contains(t, htmlContent, "<!DOCTYPE html>")
+	assert.Contains(t, htmlContent, "Koral-Mapper")
+	assert.Contains(t, htmlContent, "Map ID: test-mapper")
+	assert.Contains(t, htmlContent, "KorAPlugin.sendMsg")
+	assert.Contains(t, htmlContent, "test-mapper/query")
+}
+
+func TestBuildConfigPageData(t *testing.T) {
+	lists := []tmconfig.MappingList{
+		{
+			ID:          "anno1",
+			Description: "First annotation",
+			FoundryA:    "f1a",
+			LayerA:      "l1a",
+			FoundryB:    "f1b",
+			LayerB:      "l1b",
+			Mappings:    []tmconfig.MappingRule{"[A] <> [B]"},
+		},
+		{
+			ID:          "corpus1",
+			Type:        "corpus",
+			Description: "First corpus",
+			Mappings:    []tmconfig.MappingRule{"textClass=a <> textClass=b"},
+		},
+		{
+			ID:          "anno2",
+			Type:        "annotation",
+			Description: "Second annotation",
+			FoundryA:    "f2a",
+			LayerA:      "l2a",
+			FoundryB:    "f2b",
+			LayerB:      "l2b",
+			Mappings:    []tmconfig.MappingRule{"[C] <> [D]"},
+		},
+	}
+
+	mockConfig := &tmconfig.MappingConfig{
+		SDK:        "https://example.com/sdk.js",
+		Server:     "https://example.com/",
+		ServiceURL: "https://example.com/service",
+		Lists:      lists,
+	}
+
+	data := buildConfigPageData(mockConfig)
+
+	assert.Equal(t, "https://example.com/sdk.js", data.SDK)
+	assert.Equal(t, "https://example.com/", data.Server)
+	assert.Equal(t, "https://example.com/service", data.ServiceURL)
+
+	require.Len(t, data.AnnotationMappings, 2)
+	assert.Equal(t, "anno1", data.AnnotationMappings[0].ID)
+	assert.Equal(t, "annotation", data.AnnotationMappings[0].Type)
+	assert.Equal(t, "f1a", data.AnnotationMappings[0].FoundryA)
+	assert.Equal(t, "First annotation", data.AnnotationMappings[0].Description)
+	assert.Equal(t, "anno2", data.AnnotationMappings[1].ID)
+	assert.Equal(t, "annotation", data.AnnotationMappings[1].Type)
+
+	require.Len(t, data.CorpusMappings, 1)
+	assert.Equal(t, "corpus1", data.CorpusMappings[0].ID)
+	assert.Equal(t, "corpus", data.CorpusMappings[0].Type)
+	assert.Equal(t, "First corpus", data.CorpusMappings[0].Description)
+}
+
+func TestConfigPagePreservesOrderOfMappings(t *testing.T) {
+	lists := []tmconfig.MappingList{
+		{
+			ID:       "mapper-z",
+			FoundryA: "fa",
+			FoundryB: "fb",
+			Mappings: []tmconfig.MappingRule{"[A] <> [B]"},
+		},
+		{
+			ID:       "mapper-a",
+			FoundryA: "fa",
+			FoundryB: "fb",
+			Mappings: []tmconfig.MappingRule{"[C] <> [D]"},
+		},
+		{
+			ID:       "mapper-m",
+			FoundryA: "fa",
+			FoundryB: "fb",
+			Mappings: []tmconfig.MappingRule{"[E] <> [F]"},
+		},
+	}
+	m, err := mapper.NewMapper(lists)
+	require.NoError(t, err)
+
+	mockConfig := &tmconfig.MappingConfig{Lists: lists}
+	tmconfig.ApplyDefaults(mockConfig)
+
+	app := fiber.New()
+	setupRoutes(app, m, mockConfig)
+
+	req := httptest.NewRequest(http.MethodGet, "/", nil)
+	resp, err := app.Test(req)
+	require.NoError(t, err)
+	defer resp.Body.Close()
+
+	body, err := io.ReadAll(resp.Body)
+	require.NoError(t, err)
+	htmlContent := string(body)
+
+	// Verify the order is preserved (z before a before m)
+	idxZ := strings.Index(htmlContent, `data-id="mapper-z"`)
+	idxA := strings.Index(htmlContent, `data-id="mapper-a"`)
+	idxM := strings.Index(htmlContent, `data-id="mapper-m"`)
+	assert.Greater(t, idxA, idxZ, "mapper-a should appear after mapper-z")
+	assert.Greater(t, idxM, idxA, "mapper-m should appear after mapper-a")
+}
diff --git a/cmd/koralmapper/static/config.html b/cmd/koralmapper/static/config.html
new file mode 100644
index 0000000..a946501
--- /dev/null
+++ b/cmd/koralmapper/static/config.html
@@ -0,0 +1,66 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <title>{{.Title}}</title>
+    <link rel="stylesheet" href="/static/style.css">
+    <script src="{{.SDK}}"
+            data-server="{{.Server}}"></script>
+</head>
+<body>
+    <div class="container" data-service-url="{{.ServiceURL}}">
+        <h1>{{.Title}}</h1>
+        <p>{{.Description}}</p>
+
+        {{if .AnnotationMappings}}
+        <section class="mapping-section">
+            <h2>Query</h2>
+            {{range .AnnotationMappings}}
+            <div class="mapping" data-id="{{.ID}}" data-type="annotation"
+                 data-default-foundry-a="{{.FoundryA}}" data-default-layer-a="{{.LayerA}}"
+                 data-default-foundry-b="{{.FoundryB}}" data-default-layer-b="{{.LayerB}}">
+                <h3>{{.ID}}</h3>
+                {{if .Description}}<p class="desc">{{.Description}}</p>{{end}}
+                <div class="mapping-row">
+                    <label><input type="checkbox" class="request-cb" name="request"> Request:</label>
+                    <input type="text" class="foundryA" value="{{.FoundryA}}" size="8">/<input type="text" class="layerA" value="{{.LayerA}}" size="4">
+                    <button type="button" class="dir-arrow" data-dir="atob">&rarr;</button>
+                    <input type="text" class="foundryB" value="{{.FoundryB}}" size="8">/<input type="text" class="layerB" value="{{.LayerB}}" size="4">
+                </div>
+                <div class="mapping-row">
+                    <label><input type="checkbox" class="response-cb" name="response"> Response:</label>
+                    <input type="text" class="foundryA" value="{{.FoundryA}}" size="8">/<input type="text" class="layerA" value="{{.LayerA}}" size="4">
+                    <button type="button" class="dir-arrow" data-dir="atob">&rarr;</button>
+                    <input type="text" class="foundryB" value="{{.FoundryB}}" size="8">/<input type="text" class="layerB" value="{{.LayerB}}" size="4">
+                </div>
+            </div>
+            {{end}}
+        </section>
+        {{end}}
+
+        {{if .CorpusMappings}}
+        <section class="mapping-section">
+            <h2>Corpus</h2>
+            {{range .CorpusMappings}}
+            <div class="mapping" data-id="{{.ID}}" data-type="corpus">
+                <h3>{{.ID}}</h3>
+                {{if .Description}}<p class="desc">{{.Description}}</p>{{end}}
+                <div class="mapping-row">
+                    <label><input type="checkbox" class="request-cb" name="request"> Request</label>
+                </div>
+                <div class="mapping-row">
+                    <label><input type="checkbox" class="response-cb" name="response"> Response</label>
+                </div>
+            </div>
+            {{end}}
+        </section>
+        {{end}}
+
+        <h2>Plugin Information</h2>
+        <p><strong>Version:</strong> <tt>{{.Version}}</tt></p>
+        <p><strong>Build Date:</strong> <tt>{{.Date}}</tt></p>
+        <p><strong>Build Hash:</strong> <tt>{{.Hash}}</tt></p>
+    </div>
+    <script src="/static/config.js"></script>
+</body>
+</html>
diff --git a/cmd/koralmapper/static/config.js b/cmd/koralmapper/static/config.js
new file mode 100644
index 0000000..3918c74
--- /dev/null
+++ b/cmd/koralmapper/static/config.js
@@ -0,0 +1 @@
+"use strict";
diff --git a/cmd/koralmapper/static/plugin.html b/cmd/koralmapper/static/plugin.html
new file mode 100644
index 0000000..2000c06
--- /dev/null
+++ b/cmd/koralmapper/static/plugin.html
@@ -0,0 +1,76 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <title>{{.Title}}</title>
+    <script src="{{.SDK}}"
+            data-server="{{.Server}}"></script>
+</head>
+<body>
+    <div class="container">
+        <h1>{{.Title}}</h1>
+        <p>{{.Description}}</p>
+
+        <p>Map ID: {{.MapID}}</p>
+
+        <h2>Plugin Information</h2>
+        <p><strong>Version:</strong> <tt>{{.Version}}</tt></p>
+        <p><strong>Build Date:</strong> <tt>{{.Date}}</tt></p>
+        <p><strong>Build Hash:</strong> <tt>{{.Hash}}</tt></p>
+
+        <h2>Available API Endpoints</h2>
+        <dl>
+            <dt><tt><strong>GET</strong> /:map</tt></dt>
+            <dd><small>Kalamar integration</small></dd>
+
+            <dt><tt><strong>POST</strong> /:map/query</tt></dt>
+            <dd><small>Transform JSON query objects using term mapping rules</small></dd>
+
+            <dt><tt><strong>POST</strong> /:map/response</tt></dt>
+            <dd><small>Transform JSON response objects using term mapping rules</small></dd>
+        </dl>
+
+        <h2>Available Term Mappings</h2>
+        <dl>
+            {{range .Mappings}}
+            <dt><tt>{{.ID}}</tt></dt>
+            <dd>{{.Description}}</dd>
+            {{end}}
+        </dl>
+    </div>
+
+    <script>
+      <!-- activates/deactivates Mapper. -->
+      let qdata = {
+        'action'  : 'pipe',
+        'service' : '{{.QueryURL}}'
+      };
+
+      let rdata = {
+        'action'  : 'pipe',
+        'service' : '{{.ResponseURL}}'
+      };
+
+      function pluginit (p) {
+        p.onMessage = function(msg) {
+          if (msg.key == 'koralmapper') {
+            if (msg.value) {
+              qdata['job'] = 'add';
+            }
+            else {
+              qdata['job'] = 'del';
+            };
+            KorAPlugin.sendMsg(qdata);
+            if (msg.value) {
+              rdata['job'] = 'add-after';
+            }
+            else {
+              rdata['job'] = 'del-after';
+            };
+            KorAPlugin.sendMsg(rdata);
+          };
+        };
+      };
+    </script>
+</body>
+</html>
diff --git a/cmd/koralmapper/static/style.css b/cmd/koralmapper/static/style.css
new file mode 100644
index 0000000..693ce9b
--- /dev/null
+++ b/cmd/koralmapper/static/style.css
@@ -0,0 +1 @@
+body { font-family: sans-serif; }