| 1 | package format |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | ) |
| 6 | |
| 7 | // ANSI color codes. |
| 8 | const ( |
| 9 | reset = "\033[0m" |
| 10 | red = "\033[31m" |
| 11 | green = "\033[32m" |
| 12 | yellow = "\033[33m" |
| 13 | blue = "\033[34m" |
| 14 | magenta = "\033[35m" |
| 15 | cyan = "\033[36m" |
| 16 | gray = "\033[90m" |
| 17 | ) |
| 18 | |
| 19 | // Colorize applies syntax highlighting to formatted Confluence XML. |
| 20 | func Colorize(input string) string { |
| 21 | var buf strings.Builder |
| 22 | i := 0 |
| 23 | for i < len(input) { |
| 24 | if input[i] != '<' { |
| 25 | // Text content — no color |
| 26 | end := strings.Index(input[i:], "<") |
| 27 | if end == -1 { |
| 28 | buf.WriteString(input[i:]) |
| 29 | break |
| 30 | } |
| 31 | buf.WriteString(input[i : i+end]) |
| 32 | i += end |
| 33 | continue |
| 34 | } |
| 35 | |
| 36 | // CDATA |
| 37 | if strings.HasPrefix(input[i:], "<![CDATA[") { |
| 38 | end := strings.Index(input[i:], "]]>") |
| 39 | if end == -1 { |
| 40 | buf.WriteString(gray) |
| 41 | buf.WriteString(input[i:]) |
| 42 | buf.WriteString(reset) |
| 43 | break |
| 44 | } |
| 45 | buf.WriteString(gray) |
| 46 | buf.WriteString(input[i : i+end+3]) |
| 47 | buf.WriteString(reset) |
| 48 | i += end + 3 |
| 49 | continue |
| 50 | } |
| 51 | |
| 52 | // Comment |
| 53 | if strings.HasPrefix(input[i:], "<!--") { |
| 54 | end := strings.Index(input[i:], "-->") |
| 55 | if end == -1 { |
| 56 | buf.WriteString(gray) |
| 57 | buf.WriteString(input[i:]) |
| 58 | buf.WriteString(reset) |
| 59 | break |
| 60 | } |
| 61 | buf.WriteString(gray) |
| 62 | buf.WriteString(input[i : i+end+3]) |
| 63 | buf.WriteString(reset) |
| 64 | i += end + 3 |
| 65 | continue |
| 66 | } |
| 67 | |
| 68 | // XML tag |
| 69 | end := strings.Index(input[i:], ">") |
| 70 | if end == -1 { |
| 71 | buf.WriteString(input[i:]) |
| 72 | break |
| 73 | } |
| 74 | tag := input[i : i+end+1] |
| 75 | buf.WriteString(colorizeTag(tag)) |
| 76 | i += end + 1 |
| 77 | } |
| 78 | return buf.String() |
| 79 | } |
| 80 | |
| 81 | func colorizeTag(tag string) string { |
| 82 | var buf strings.Builder |
| 83 | |
| 84 | // Closing tag: </name> |
| 85 | if strings.HasPrefix(tag, "</") { |
| 86 | name := tag[2 : len(tag)-1] |
| 87 | buf.WriteString(gray) |
| 88 | buf.WriteString("</") |
| 89 | buf.WriteString(reset) |
| 90 | buf.WriteString(tagNameColor(name)) |
| 91 | buf.WriteString(name) |
| 92 | buf.WriteString(reset) |
| 93 | buf.WriteString(gray) |
| 94 | buf.WriteString(">") |
| 95 | buf.WriteString(reset) |
| 96 | return buf.String() |
| 97 | } |
| 98 | |
| 99 | // Opening or self-closing tag |
| 100 | selfClosing := strings.HasSuffix(tag, "/>") |
| 101 | inner := tag[1:] |
| 102 | if selfClosing { |
| 103 | inner = inner[:len(inner)-2] |
| 104 | } else { |
| 105 | inner = inner[:len(inner)-1] |
| 106 | } |
| 107 | |
| 108 | // Split tag name from attributes |
| 109 | nameEnd := strings.IndexAny(inner, " \t\n") |
| 110 | var name, attrs string |
| 111 | if nameEnd == -1 { |
| 112 | name = inner |
| 113 | } else { |
| 114 | name = inner[:nameEnd] |
| 115 | attrs = inner[nameEnd:] |
| 116 | } |
| 117 | |
| 118 | buf.WriteString(gray) |
| 119 | buf.WriteString("<") |
| 120 | buf.WriteString(reset) |
| 121 | buf.WriteString(tagNameColor(name)) |
| 122 | buf.WriteString(name) |
| 123 | buf.WriteString(reset) |
| 124 | |
| 125 | if attrs != "" { |
| 126 | buf.WriteString(colorizeAttrs(attrs)) |
| 127 | } |
| 128 | |
| 129 | if selfClosing { |
| 130 | buf.WriteString(gray) |
| 131 | buf.WriteString("/>") |
| 132 | buf.WriteString(reset) |
| 133 | } else { |
| 134 | buf.WriteString(gray) |
| 135 | buf.WriteString(">") |
| 136 | buf.WriteString(reset) |
| 137 | } |
| 138 | |
| 139 | return buf.String() |
| 140 | } |
| 141 | |
| 142 | func tagNameColor(name string) string { |
| 143 | lower := strings.ToLower(name) |
| 144 | switch { |
| 145 | case strings.HasPrefix(lower, "ac:"): |
| 146 | return magenta |
| 147 | case strings.HasPrefix(lower, "ri:"): |
| 148 | return cyan |
| 149 | default: |
| 150 | return blue |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | func colorizeAttrs(attrs string) string { |
| 155 | var buf strings.Builder |
| 156 | rest := attrs |
| 157 | for len(rest) > 0 { |
| 158 | // Find next attribute: name="value" or name='value' |
| 159 | eqIdx := strings.Index(rest, "=") |
| 160 | if eqIdx == -1 { |
| 161 | // No more attributes, just whitespace or text |
| 162 | buf.WriteString(rest) |
| 163 | break |
| 164 | } |
| 165 | |
| 166 | // Everything before = is whitespace + attr name |
| 167 | before := rest[:eqIdx] |
| 168 | rest = rest[eqIdx+1:] |
| 169 | |
| 170 | // Split leading whitespace from attr name |
| 171 | trimmed := strings.TrimLeft(before, " \t\n") |
| 172 | ws := before[:len(before)-len(trimmed)] |
| 173 | |
| 174 | buf.WriteString(ws) |
| 175 | buf.WriteString(yellow) |
| 176 | buf.WriteString(trimmed) |
| 177 | buf.WriteString(reset) |
| 178 | buf.WriteString(gray) |
| 179 | buf.WriteString("=") |
| 180 | buf.WriteString(reset) |
| 181 | |
| 182 | // Read quoted value |
| 183 | if len(rest) > 0 && (rest[0] == '"' || rest[0] == '\'') { |
| 184 | quote := rest[0] |
| 185 | endQ := strings.IndexByte(rest[1:], quote) |
| 186 | if endQ == -1 { |
| 187 | buf.WriteString(green) |
| 188 | buf.WriteString(rest) |
| 189 | buf.WriteString(reset) |
| 190 | break |
| 191 | } |
| 192 | buf.WriteString(green) |
| 193 | buf.WriteString(rest[:endQ+2]) |
| 194 | buf.WriteString(reset) |
| 195 | rest = rest[endQ+2:] |
| 196 | } |
| 197 | } |
| 198 | return buf.String() |
| 199 | } |
| 200 | |