feat(scripts): support external file embedding
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
package assetloader
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const ScriptSourcePathMetadataKey = "source_path"
|
||||
|
||||
var (
|
||||
embedDirectivePattern = regexp.MustCompile(`(?m)^\s*--\s*@embed:\s*(.+?)\s*$`)
|
||||
embedAttrPattern = regexp.MustCompile(`([a-zA-Z_][a-zA-Z0-9_]*)=("[^"]*"|'[^']*'|\S+)`)
|
||||
embedVarPattern = regexp.MustCompile(`^:[a-zA-Z_][a-zA-Z0-9_]*$`)
|
||||
)
|
||||
|
||||
// ProcessEmbedDirectives expands SQL comments in the form:
|
||||
//
|
||||
// -- @embed: path=... var=:... mode=text|base64
|
||||
//
|
||||
// Paths are resolved relative to sqlPath. Text mode embeds a quoted UTF-8 SQL
|
||||
// string literal. Base64 mode embeds a quoted base64 literal suitable for
|
||||
// decode(:var, 'base64').
|
||||
func ProcessEmbedDirectives(sqlPath, sql string) (string, error) {
|
||||
directives := embedDirectivePattern.FindAllStringSubmatch(sql, -1)
|
||||
if len(directives) == 0 {
|
||||
return sql, nil
|
||||
}
|
||||
|
||||
if sqlPath == "" {
|
||||
return "", fmt.Errorf("sql path is required for embed directives")
|
||||
}
|
||||
|
||||
result := embedDirectivePattern.ReplaceAllString(sql, "")
|
||||
for i, directive := range directives {
|
||||
literal, placeholder, err := embedDirectiveLiteral(sqlPath, directive[1], i+1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !embedPlaceholderPattern(placeholder).MatchString(result) {
|
||||
return "", fmt.Errorf("%s embed directive %d: placeholder %s not found", sqlPath, i+1, placeholder)
|
||||
}
|
||||
result = replaceEmbedPlaceholder(result, placeholder, literal)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func embedDirectiveLiteral(sqlPath, raw string, directiveNumber int) (string, string, error) {
|
||||
attrs, err := parseEmbedAttrs(raw)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("%s embed directive %d: %w", sqlPath, directiveNumber, err)
|
||||
}
|
||||
|
||||
pathValue := attrs["path"]
|
||||
varValue := attrs["var"]
|
||||
modeValue := attrs["mode"]
|
||||
if pathValue == "" {
|
||||
return "", "", fmt.Errorf("%s embed directive %d: missing path", sqlPath, directiveNumber)
|
||||
}
|
||||
if !embedVarPattern.MatchString(varValue) {
|
||||
return "", "", fmt.Errorf("%s embed directive %d: var must be a named placeholder like :asset", sqlPath, directiveNumber)
|
||||
}
|
||||
if modeValue != "text" && modeValue != "base64" {
|
||||
return "", "", fmt.Errorf("%s embed directive %d: mode must be text or base64", sqlPath, directiveNumber)
|
||||
}
|
||||
|
||||
resolved := filepath.Join(filepath.Dir(sqlPath), filepath.Clean(pathValue))
|
||||
data, err := os.ReadFile(resolved)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("%s embed directive %d: reading %s: %w", sqlPath, directiveNumber, resolved, err)
|
||||
}
|
||||
|
||||
switch modeValue {
|
||||
case "text":
|
||||
if !utf8.Valid(data) {
|
||||
return "", "", fmt.Errorf("%s embed directive %d: %s is not valid UTF-8", sqlPath, directiveNumber, resolved)
|
||||
}
|
||||
literal, err := sqlStringLiteral(string(data))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("%s embed directive %d: %w", sqlPath, directiveNumber, err)
|
||||
}
|
||||
return literal, varValue, nil
|
||||
case "base64":
|
||||
literal, err := sqlStringLiteral(base64.StdEncoding.EncodeToString(data))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("%s embed directive %d: %w", sqlPath, directiveNumber, err)
|
||||
}
|
||||
return literal, varValue, nil
|
||||
default:
|
||||
return "", "", fmt.Errorf("%s embed directive %d: mode must be text or base64", sqlPath, directiveNumber)
|
||||
}
|
||||
}
|
||||
|
||||
func parseEmbedAttrs(raw string) (map[string]string, error) {
|
||||
attrs := map[string]string{}
|
||||
matches := embedAttrPattern.FindAllStringSubmatchIndex(raw, -1)
|
||||
if len(matches) == 0 {
|
||||
return nil, fmt.Errorf("expected path, var, and mode attributes")
|
||||
}
|
||||
|
||||
lastEnd := 0
|
||||
for _, match := range matches {
|
||||
gap := strings.TrimSpace(raw[lastEnd:match[0]])
|
||||
if gap != "" {
|
||||
return nil, fmt.Errorf("invalid attribute syntax near %q", gap)
|
||||
}
|
||||
|
||||
key := raw[match[2]:match[3]]
|
||||
value := raw[match[4]:match[5]]
|
||||
if _, exists := attrs[key]; exists {
|
||||
return nil, fmt.Errorf("duplicate attribute %q", key)
|
||||
}
|
||||
unquoted, err := unquoteEmbedValue(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid %s value: %w", key, err)
|
||||
}
|
||||
attrs[key] = unquoted
|
||||
lastEnd = match[1]
|
||||
}
|
||||
if tail := strings.TrimSpace(raw[lastEnd:]); tail != "" {
|
||||
return nil, fmt.Errorf("invalid attribute syntax near %q", tail)
|
||||
}
|
||||
|
||||
for key := range attrs {
|
||||
if key != "path" && key != "var" && key != "mode" {
|
||||
return nil, fmt.Errorf("unknown attribute %q", key)
|
||||
}
|
||||
}
|
||||
|
||||
return attrs, nil
|
||||
}
|
||||
|
||||
func unquoteEmbedValue(value string) (string, error) {
|
||||
if len(value) < 2 {
|
||||
return value, nil
|
||||
}
|
||||
if value[0] == '"' {
|
||||
return strconv.Unquote(value)
|
||||
}
|
||||
if value[0] == '\'' && value[len(value)-1] == '\'' {
|
||||
return value[1 : len(value)-1], nil
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func sqlStringLiteral(value string) (string, error) {
|
||||
if strings.ContainsRune(value, '\x00') {
|
||||
return "", fmt.Errorf("embedded text contains NUL byte")
|
||||
}
|
||||
return "'" + strings.ReplaceAll(value, "'", "''") + "'", nil
|
||||
}
|
||||
|
||||
func replaceEmbedPlaceholder(sql, placeholder, literal string) string {
|
||||
return embedPlaceholderPattern(placeholder).ReplaceAllString(sql, "${1}"+literal+"${2}")
|
||||
}
|
||||
|
||||
func embedPlaceholderPattern(placeholder string) *regexp.Regexp {
|
||||
return regexp.MustCompile(`(^|[^a-zA-Z0-9_:])` + regexp.QuoteMeta(placeholder) + `([^a-zA-Z0-9_]|$)`)
|
||||
}
|
||||
Reference in New Issue
Block a user