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