feat: publish the Sandwich Hime source preview

Signed-off-by: Cole Speelman <gamertan@noreply.localhost>
This commit is contained in:
2026-08-11 20:15:06 -04:00
commit 9b29b3d7f8
100 changed files with 10989 additions and 0 deletions
+620
View File
@@ -0,0 +1,620 @@
// SPDX-License-Identifier: AGPL-3.0-only
package compiler
import (
"fmt"
"html"
"strings"
)
type htmlState uint8
const (
htmlData htmlState = iota
htmlAfterLT
htmlDeclarationStart
htmlDeclarationDash
htmlDeclaration
htmlComment
htmlTagName
htmlBeforeAttribute
htmlAttributeName
htmlAfterAttributeName
htmlBeforeAttributeValue
htmlAttributeDoubleQuoted
htmlAttributeSingleQuoted
htmlSelfClosing
htmlEndTagName
htmlAfterEndTagName
htmlRawText
htmlRawAfterLT
htmlRawEndTagName
htmlRawAfterEndTagName
)
type contextAnalyzer struct {
file *sourceFile
state htmlState
currentTag string
currentAttr string
stack []string
selfClosing bool
commentDash int
rawTag string
rawEndName string
scriptTail string
attributes map[string]string
seenAttrs map[string]bool
attrLiteral strings.Builder
attrDynamicNodes []int
attrFirstDynamic int
attrDynamicPrefix string
}
var voidElements = map[string]bool{
"area": true, "base": true, "br": true, "col": true, "embed": true,
"hr": true, "img": true, "input": true, "link": true, "meta": true,
"param": true, "source": true, "track": true, "wbr": true,
}
var urlAttributes = map[string]bool{
"action": true, "background": true, "cite": true, "classid": true,
"code": true, "codebase": true, "data": true, "dynsrc": true,
"formaction": true, "href": true, "icon": true, "itemid": true,
"longdesc": true, "lowsrc": true, "manifest": true, "poster": true,
"profile": true, "src": true, "usemap": true, "xlink:href": true,
"xmlns": true,
}
var unsupportedDynamicAttributes = map[string]string{
"archive": "URL-list",
"imagesrcset": "responsive-image URL-list",
"itemtype": "URL-list",
"ping": "URL-list",
"srcdoc": "nested HTML",
"srcset": "responsive-image URL-list",
}
func analyzeContexts(file *sourceFile) []Diagnostic {
analyzer := &contextAnalyzer{file: file, state: htmlData, attrFirstDynamic: -1}
var diagnostics []Diagnostic
for nodeIndex := range file.Nodes {
node := &file.Nodes[nodeIndex]
switch node.Kind {
case nodeText:
if d := analyzer.consumeText(node.Text, node.Pos); d != nil {
diagnostics = append(diagnostics, *d)
return diagnostics
}
case nodeComment:
// Hime-san comments emit no bytes and cannot change HTML state.
case nodeStatement:
if analyzer.state != htmlData {
diagnostics = append(diagnostics, diagnostic(file.Path, node.Pos, "HIM1301", "Go statements are only allowed at HTML content boundaries"))
return diagnostics
}
node.Context = ContextNone
case nodeComponent:
if analyzer.state != htmlData {
diagnostics = append(diagnostics, diagnostic(file.Path, node.Pos, "HIM1302", "component rendering (<?~) is only allowed at HTML content boundaries"))
return diagnostics
}
node.Context = ContextHTMLText
case nodeExpression:
if analyzer.state == htmlBeforeAttributeValue {
diagnostics = append(diagnostics, diagnostic(file.Path, node.Pos, "HIM1328", "attribute values must be quoted"))
return diagnostics
}
if analyzer.inAttribute() && unsupportedDynamicAttributes[analyzer.currentAttr] != "" {
diagnostics = append(diagnostics, diagnostic(file.Path, node.Pos, "HIM1345", fmt.Sprintf("dynamic %s attributes require an unsupported %s context in v1", analyzer.currentAttr, unsupportedDynamicAttributes[analyzer.currentAttr])))
return diagnostics
}
if analyzer.inAttribute() && analyzer.currentTag == "meta" && analyzer.currentAttr == "http-equiv" {
diagnostics = append(diagnostics, diagnostic(file.Path, node.Pos, "HIM1346", "dynamic meta http-equiv values are not supported in v1"))
return diagnostics
}
if analyzer.inAttribute() && analyzer.currentAttr == "style" {
diagnostics = append(diagnostics, diagnostic(file.Path, node.Pos, "HIM1343", "dynamic style attributes are not supported in v1; use a static style attribute or a class"))
return diagnostics
}
context, ok := analyzer.dynamicContext()
if !ok {
diagnostics = append(diagnostics, diagnostic(file.Path, node.Pos, "HIM1303", "dynamic output is not allowed while constructing markup; use HTML text or a quoted attribute value"))
return diagnostics
}
node.Context = context
if analyzer.inAttribute() {
if analyzer.attrFirstDynamic < 0 {
analyzer.attrFirstDynamic = nodeIndex
analyzer.attrDynamicPrefix = analyzer.attrLiteral.String()
}
analyzer.attrDynamicNodes = append(analyzer.attrDynamicNodes, nodeIndex)
}
}
}
if analyzer.state != htmlData {
diagnostics = append(diagnostics, diagnostic(file.Path, endPosition(file.Source), "HIM1310", "template ends in an incomplete or ambiguous HTML parser context"))
}
if len(analyzer.stack) != 0 {
diagnostics = append(diagnostics, diagnostic(file.Path, endPosition(file.Source), "HIM1311", fmt.Sprintf("component must finish in its starting HTML context; unclosed <%s>", analyzer.stack[len(analyzer.stack)-1])))
}
return diagnostics
}
func (a *contextAnalyzer) consumeText(text string, start sourcePosition) *Diagnostic {
positionTable := newPositionTable(a.file.Source)
for index := 0; index < len(text); index++ {
b := text[index]
position := positionTable.at(start.Offset + index)
if a.rawTag == "script" {
a.scriptTail += string(b)
if len(a.scriptTail) > len("<!--") {
a.scriptTail = a.scriptTail[len(a.scriptTail)-len("<!--"):]
}
if a.scriptTail == "<!--" {
return a.problem(position, "HIM1357", "HTML comment syntax inside <script> is not supported because it enters ambiguous escaped script parser states; remove the <!-- sequence")
}
}
reprocess := true
for reprocess {
reprocess = false
switch a.state {
case htmlData:
if b == '<' {
a.state = htmlAfterLT
}
case htmlAfterLT:
switch {
case b == '!':
a.state = htmlDeclarationStart
case b == '/':
a.currentTag = ""
a.state = htmlEndTagName
case isTagNameStart(b):
a.currentTag = strings.ToLower(string(b))
a.attributes = make(map[string]string)
a.seenAttrs = make(map[string]bool)
a.selfClosing = false
a.state = htmlTagName
default:
return a.problem(position, "HIM1320", "malformed HTML after '<'; dynamic tag construction is not supported")
}
case htmlDeclarationStart:
if b == '-' {
a.state = htmlDeclarationDash
} else if isASCIILetter(b) {
a.state = htmlDeclaration
} else {
return a.problem(position, "HIM1321", "unsupported HTML declaration")
}
case htmlDeclarationDash:
if b != '-' {
return a.problem(position, "HIM1322", "malformed HTML comment opening")
}
a.commentDash = 0
a.state = htmlComment
case htmlDeclaration:
if b == '>' {
a.state = htmlData
} else if b == '<' {
return a.problem(position, "HIM1323", "malformed HTML declaration")
}
case htmlComment:
if b == '-' {
a.commentDash++
} else if b == '>' && a.commentDash >= 2 {
a.commentDash = 0
a.state = htmlData
} else {
a.commentDash = 0
}
case htmlTagName:
switch {
case isTagNameChar(b):
a.currentTag += strings.ToLower(string(b))
case isHTMLSpace(b):
a.state = htmlBeforeAttribute
case b == '>':
if d := a.finishOpenTag(position); d != nil {
return d
}
case b == '/':
a.selfClosing = true
a.state = htmlSelfClosing
default:
return a.problem(position, "HIM1324", "unsupported character in HTML tag name")
}
case htmlBeforeAttribute:
switch {
case isHTMLSpace(b):
case isAttributeNameStart(b):
a.beginAttribute(b)
a.state = htmlAttributeName
case b == '>':
if d := a.finishOpenTag(position); d != nil {
return d
}
case b == '/':
a.selfClosing = true
a.state = htmlSelfClosing
default:
return a.problem(position, "HIM1325", "dynamic or malformed attribute names are not supported")
}
case htmlAttributeName:
switch {
case isAttributeNameChar(b):
a.currentAttr += strings.ToLower(string(b))
case isHTMLSpace(b):
if d := a.validateAttributeName(position); d != nil {
return d
}
a.state = htmlAfterAttributeName
case b == '=':
if d := a.validateAttributeName(position); d != nil {
return d
}
a.state = htmlBeforeAttributeValue
case b == '>':
if d := a.validateAttributeName(position); d != nil {
return d
}
if d := a.finishOpenTag(position); d != nil {
return d
}
case b == '/':
if d := a.validateAttributeName(position); d != nil {
return d
}
a.selfClosing = true
a.state = htmlSelfClosing
default:
return a.problem(position, "HIM1326", "unsupported character in attribute name")
}
case htmlAfterAttributeName:
switch {
case isHTMLSpace(b):
case b == '=':
a.state = htmlBeforeAttributeValue
case isAttributeNameStart(b):
a.beginAttribute(b)
a.state = htmlAttributeName
case b == '>':
if d := a.finishOpenTag(position); d != nil {
return d
}
case b == '/':
a.selfClosing = true
a.state = htmlSelfClosing
default:
return a.problem(position, "HIM1327", "expected '=' or another attribute")
}
case htmlBeforeAttributeValue:
switch {
case isHTMLSpace(b):
case b == '"':
a.resetAttributeValue()
a.state = htmlAttributeDoubleQuoted
case b == '\'':
a.resetAttributeValue()
a.state = htmlAttributeSingleQuoted
default:
return a.problem(position, "HIM1328", "attribute values must be quoted")
}
case htmlAttributeDoubleQuoted:
if b == '"' {
if d := a.finishAttributeValue(position); d != nil {
return d
}
a.state = htmlBeforeAttribute
} else if b == '<' {
return a.problem(position, "HIM1329", "'<' is not supported inside attribute values")
} else {
a.attrLiteral.WriteByte(b)
}
case htmlAttributeSingleQuoted:
if b == '\'' {
if d := a.finishAttributeValue(position); d != nil {
return d
}
a.state = htmlBeforeAttribute
} else if b == '<' {
return a.problem(position, "HIM1329", "'<' is not supported inside attribute values")
} else {
a.attrLiteral.WriteByte(b)
}
case htmlSelfClosing:
if isHTMLSpace(b) {
continue
}
if b != '>' {
return a.problem(position, "HIM1330", "expected '>' after '/' in a tag")
}
if d := a.finishOpenTag(position); d != nil {
return d
}
case htmlEndTagName:
switch {
case isTagNameChar(b):
a.currentTag += strings.ToLower(string(b))
case isHTMLSpace(b) && a.currentTag != "":
a.state = htmlAfterEndTagName
case b == '>' && a.currentTag != "":
if d := a.finishCloseTag(position); d != nil {
return d
}
default:
return a.problem(position, "HIM1331", "malformed closing tag")
}
case htmlAfterEndTagName:
if isHTMLSpace(b) {
continue
}
if b != '>' {
return a.problem(position, "HIM1332", "unexpected content in closing tag")
}
if d := a.finishCloseTag(position); d != nil {
return d
}
case htmlRawText:
if b == '<' {
a.state = htmlRawAfterLT
}
case htmlRawAfterLT:
if b == '/' {
a.rawEndName = ""
a.state = htmlRawEndTagName
} else if b != '<' {
a.state = htmlRawText
}
case htmlRawEndTagName:
if isTagNameChar(b) {
a.rawEndName += strings.ToLower(string(b))
continue
}
if a.rawEndName != a.rawTag {
a.state = htmlRawText
if b == '<' {
a.state = htmlRawAfterLT
}
continue
}
if b == '>' {
if d := a.finishRawClose(position); d != nil {
return d
}
} else if isHTMLSpace(b) {
a.state = htmlRawAfterEndTagName
} else {
a.state = htmlRawText
}
case htmlRawAfterEndTagName:
if isHTMLSpace(b) {
continue
}
if b != '>' {
return a.problem(position, "HIM1333", "malformed script/style closing tag")
}
if d := a.finishRawClose(position); d != nil {
return d
}
}
}
}
return nil
}
func (a *contextAnalyzer) beginAttribute(first byte) {
a.currentAttr = strings.ToLower(string(first))
a.resetAttributeValue()
}
func (a *contextAnalyzer) resetAttributeValue() {
a.attrLiteral.Reset()
a.attrDynamicNodes = a.attrDynamicNodes[:0]
a.attrFirstDynamic = -1
a.attrDynamicPrefix = ""
}
func (a *contextAnalyzer) validateAttributeName(position sourcePosition) *Diagnostic {
if a.seenAttrs[a.currentAttr] {
return a.problem(position, "HIM1347", fmt.Sprintf("duplicate attribute %q creates ambiguous browser parsing", a.currentAttr))
}
a.seenAttrs[a.currentAttr] = true
if strings.HasPrefix(a.currentAttr, "on") {
return a.problem(position, "HIM1340", fmt.Sprintf("event-handler attribute %q is not supported", a.currentAttr))
}
return nil
}
func (a *contextAnalyzer) finishAttributeValue(position sourcePosition) *Diagnostic {
if len(a.attrDynamicNodes) == 0 {
a.attributes[a.currentAttr] = html.UnescapeString(a.attrLiteral.String())
}
if !urlAttributes[a.currentAttr] {
return nil
}
if len(a.attrDynamicNodes) == 0 {
if !safeStaticURL(a.attrLiteral.String()) {
return a.problem(position, "HIM1344", "static URL uses a dangerous or ambiguous scheme; use a safe URL or an explicit full TrustURL value")
}
return nil
}
prefix := a.attrDynamicPrefix
allLiteral := a.attrLiteral.String()
suffix := strings.TrimPrefix(allLiteral, prefix)
if prefix == "" && (len(a.attrDynamicNodes) != 1 || suffix != "") {
return a.problem(position, "HIM1341", "a dynamic URL without a static safe prefix must occupy the entire quoted attribute")
}
if prefix != "" && !safeURLPrefix(prefix) {
return a.problem(position, "HIM1342", "mixed static/dynamic URL attributes require a relative or explicit safe-scheme static prefix")
}
return nil
}
func safeStaticURL(value string) bool {
decoded := strings.TrimSpace(html.UnescapeString(value))
if decoded == "" {
return true
}
for _, r := range decoded {
if r < 0x20 || r == 0x7f {
return false
}
}
lower := strings.ToLower(decoded)
if strings.HasPrefix(lower, "/") || strings.HasPrefix(lower, "./") || strings.HasPrefix(lower, "../") || strings.HasPrefix(lower, "#") || strings.HasPrefix(lower, "?") {
return true
}
colon := strings.IndexByte(lower, ':')
boundary := len(lower)
for _, separator := range []byte{'/', '?', '#'} {
if index := strings.IndexByte(lower, separator); index >= 0 && index < boundary {
boundary = index
}
}
if colon < 0 || colon > boundary {
return true
}
scheme := lower[:colon+1]
return scheme == "http:" || scheme == "https:" || scheme == "mailto:" || scheme == "tel:"
}
func safeURLPrefix(prefix string) bool {
trimmed := strings.TrimSpace(strings.ToLower(prefix))
if trimmed == "" {
return false
}
if strings.HasPrefix(trimmed, "/") || strings.HasPrefix(trimmed, "./") || strings.HasPrefix(trimmed, "../") || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "?") {
return true
}
for _, scheme := range []string{"http:", "https:", "mailto:", "tel:"} {
if strings.HasPrefix(trimmed, scheme) {
return true
}
}
return false
}
func (a *contextAnalyzer) finishOpenTag(position sourcePosition) *Diagnostic {
if a.currentTag == "" {
return a.problem(position, "HIM1350", "empty HTML tag name")
}
if a.currentTag == "svg" || a.currentTag == "math" {
return a.problem(position, "HIM1355", fmt.Sprintf("foreign-content element <%s> is not supported by the v1 HTML context analyzer", a.currentTag))
}
if a.currentTag == "plaintext" || a.currentTag == "noscript" {
return a.problem(position, "HIM1356", fmt.Sprintf("HTML element <%s> has environment-dependent or non-terminating parsing and is not supported in v1", a.currentTag))
}
if a.currentTag == "meta" && strings.EqualFold(strings.TrimSpace(a.attributes["http-equiv"]), "refresh") {
return a.problem(position, "HIM1346", "meta refresh is an unsupported navigation context in v1")
}
if a.selfClosing && !voidElements[a.currentTag] {
return a.problem(position, "HIM1354", fmt.Sprintf("self-closing syntax is not valid for non-void HTML element <%s>; use an explicit closing tag", a.currentTag))
}
if !a.selfClosing && !voidElements[a.currentTag] {
a.stack = append(a.stack, a.currentTag)
}
if !a.selfClosing && (a.currentTag == "script" || a.currentTag == "style" || a.currentTag == "textarea" || a.currentTag == "title" || a.currentTag == "iframe" || a.currentTag == "noembed" || a.currentTag == "noframes" || a.currentTag == "xmp") {
a.rawTag = a.currentTag
a.scriptTail = ""
a.state = htmlRawText
} else {
a.state = htmlData
}
a.currentAttr = ""
return nil
}
func (a *contextAnalyzer) finishCloseTag(position sourcePosition) *Diagnostic {
if voidElements[a.currentTag] {
return a.problem(position, "HIM1351", fmt.Sprintf("void element <%s> cannot have a closing tag", a.currentTag))
}
if len(a.stack) == 0 || a.stack[len(a.stack)-1] != a.currentTag {
expected := "no closing tag"
if len(a.stack) != 0 {
expected = fmt.Sprintf("</%s>", a.stack[len(a.stack)-1])
}
return a.problem(position, "HIM1352", fmt.Sprintf("unbalanced closing tag </%s>; expected %s", a.currentTag, expected))
}
a.stack = a.stack[:len(a.stack)-1]
a.state = htmlData
a.currentTag = ""
return nil
}
func (a *contextAnalyzer) finishRawClose(position sourcePosition) *Diagnostic {
if len(a.stack) == 0 || a.stack[len(a.stack)-1] != a.rawTag {
return a.problem(position, "HIM1353", fmt.Sprintf("unbalanced </%s>", a.rawTag))
}
a.stack = a.stack[:len(a.stack)-1]
a.state = htmlData
a.currentTag = ""
a.rawTag = ""
a.rawEndName = ""
a.scriptTail = ""
return nil
}
func (a *contextAnalyzer) dynamicContext() (Context, bool) {
switch a.state {
case htmlData:
return ContextHTMLText, true
case htmlAttributeDoubleQuoted, htmlAttributeSingleQuoted:
if urlAttributes[a.currentAttr] {
return ContextURL, true
}
return ContextAttr, true
case htmlRawText:
if a.rawTag == "script" {
return ContextJS, true
}
if a.rawTag == "style" {
return ContextCSS, true
}
if a.rawTag == "textarea" || a.rawTag == "title" {
return ContextRCDATA, true
}
}
return ContextNone, false
}
func (a *contextAnalyzer) inAttribute() bool {
return a.state == htmlAttributeDoubleQuoted || a.state == htmlAttributeSingleQuoted
}
func (a *contextAnalyzer) problem(position sourcePosition, code, message string) *Diagnostic {
d := diagnostic(a.file.Path, position, code, message)
return &d
}
func isASCIILetter(b byte) bool {
return b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z'
}
func isTagNameStart(b byte) bool {
return isASCIILetter(b)
}
func isTagNameChar(b byte) bool {
return isASCIILetter(b) || b >= '0' && b <= '9' || b == ':' || b == '-'
}
func isAttributeNameStart(b byte) bool {
return isASCIILetter(b) || b == '_' || b == ':'
}
func isAttributeNameChar(b byte) bool {
return isAttributeNameStart(b) || b >= '0' && b <= '9' || b == '-' || b == '.'
}
func isHTMLSpace(b byte) bool {
return b == ' ' || b == '\t' || b == '\r' || b == '\n' || b == '\f'
}
func endPosition(source []byte) sourcePosition {
return newPositionTable(source).at(len(source))
}