Restart Init

This commit is contained in:
Simon Martens
2026-02-18 13:41:44 +01:00
parent 938cdeb27b
commit 4f4288905d
2955 changed files with 4795 additions and 53375 deletions

View File

@@ -3,44 +3,14 @@ package xmlmodels
import (
"encoding/json"
"encoding/xml"
"github.com/Theodor-Springmann-Stiftung/lenz-web/xmlparsing"
)
type Letter struct {
XMLName xml.Name `xml:"letterText"`
Letter int `xml:"letter,attr"`
Pages []Page `xml:"page"`
Hands []RefElement `xml:"hand"`
HTML xmlparsing.Parsed[LenzTextHandler, *LenzParseState] `xml:"-"`
}
func (l *Letter) UnmarshalXML(dec *xml.Decoder, start xml.StartElement) error {
type alias struct {
XMLName xml.Name `xml:"letterText"`
Letter int `xml:"letter,attr"`
Pages []Page `xml:"page"`
Hands []RefElement `xml:"hand"`
Inner string `xml:",innerxml"`
}
var data alias
if err := dec.DecodeElement(&data, &start); err != nil {
return err
}
l.XMLName = data.XMLName
l.Letter = data.Letter
l.Pages = data.Pages
l.Hands = data.Hands
parsed, err := parseText(Get(), data.Inner)
if err != nil {
return err
}
l.HTML = parsed
return nil
XMLName xml.Name `xml:"letterText"`
Letter int `xml:"letter,attr"`
Pages []Page `xml:"page"`
Hands []RefElement `xml:"hand"`
Inner string `xml:",innerxml"`
}
func (l Letter) Keys() []any {

View File

@@ -1,395 +0,0 @@
package xmlmodels
import (
"math/rand"
"strconv"
"strings"
"github.com/Theodor-Springmann-Stiftung/lenz-web/xmlparsing"
)
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
func randString(length int) string {
b := make([]byte, length)
for i := range b {
b[i] = charset[rand.Intn(len(charset))]
}
return string(b)
}
type Note struct {
Id string
Tokens Tokens
}
type PageRender struct {
Index string
StartsInline bool
Tokens Tokens
rendered string
}
func (p *PageRender) HTML() string {
if p == nil {
return ""
}
if p.rendered != "" {
return p.rendered
}
p.rendered = p.Tokens.String()
return p.rendered
}
type LenzParseState struct {
Tokens Tokens
Notes []Note
Count []Note
Pages []*PageRender
currentPage *PageRender
paging bool
LC int
PC string
CloseElement bool
Break bool
PageBreak bool
LineBreak bool
Lib *Library
rendered string
}
func (s *LenzParseState) String() string {
if s == nil {
return ""
}
if s.rendered != "" {
return s.rendered
}
builder := strings.Builder{}
builder.WriteString(outToken{Name: "div", Classes: []string{"count"}, Type: Element}.String())
builder.WriteString(s.CountHTML())
builder.WriteString(outToken{Name: "div", Classes: []string{"count"}, Type: EndElement}.String())
tokens := s.Tokens
tokens.Prepend(outToken{Name: "div", Classes: []string{"fulltext"}, Type: Element})
tokens.AppendEndElement()
builder.WriteString(tokens.String())
builder.WriteString(outToken{Name: "div", Classes: []string{"notes"}, Type: Element}.String())
builder.WriteString(s.NotesHTML())
builder.WriteString(outToken{Name: "div", Classes: []string{"notes"}, Type: EndElement}.String())
s.rendered = builder.String()
return s.rendered
}
func (s *LenzParseState) CountHTML() string {
if s == nil {
return ""
}
builder := strings.Builder{}
for _, c := range s.Count {
builder.WriteString(c.Tokens.String())
}
return builder.String()
}
func (s *LenzParseState) NotesHTML() string {
if s == nil {
return ""
}
builder := strings.Builder{}
for _, note := range s.Notes {
builder.WriteString(note.Tokens.String())
}
return builder.String()
}
func (s *LenzParseState) AppendNote(note Note) {
s.Notes = append(s.Notes, note)
}
func (s *LenzParseState) ensureCurrentPage() *PageRender {
if s.currentPage == nil {
s.startPage(s.PC)
}
return s.currentPage
}
func (s *LenzParseState) startPage(index string) *PageRender {
if index == "" {
index = strconv.Itoa(len(s.Pages) + 1)
}
page := &PageRender{Index: index}
s.Pages = append(s.Pages, page)
s.currentPage = page
s.paging = true
return page
}
func (s *LenzParseState) currentPageTokens() *Tokens {
if !s.paging {
return nil
}
return &s.ensureCurrentPage().Tokens
}
func (s *LenzParseState) appendDefaultElement(token *xmlparsing.Token, ids ...string) {
s.Tokens.AppendDefaultElement(token, ids...)
if pageTokens := s.currentPageTokens(); pageTokens != nil {
pageTokens.AppendDefaultElement(token, ids...)
}
}
func (s *LenzParseState) appendDivElement(id string, classes ...string) {
s.Tokens.AppendDivElement(id, classes...)
if pageTokens := s.currentPageTokens(); pageTokens != nil {
pageTokens.AppendDivElement(id, classes...)
}
}
func (s *LenzParseState) appendEndElement() {
s.Tokens.AppendEndElement()
if pageTokens := s.currentPageTokens(); pageTokens != nil {
pageTokens.AppendEndElement()
}
}
func (s *LenzParseState) appendCustomAttribute(name, value string) {
s.Tokens.AppendCustomAttribute(name, value)
if pageTokens := s.currentPageTokens(); pageTokens != nil {
pageTokens.AppendCustomAttribute(name, value)
}
}
func (s *LenzParseState) appendLink(href string, classes ...string) {
s.Tokens.AppendLink(href, classes...)
if pageTokens := s.currentPageTokens(); pageTokens != nil {
pageTokens.AppendLink(href, classes...)
}
}
func (s *LenzParseState) appendEmptyElement(name string, id string, classes ...string) {
s.Tokens.AppendEmptyElement(name, id, classes...)
if pageTokens := s.currentPageTokens(); pageTokens != nil {
pageTokens.AppendEmptyElement(name, id, classes...)
}
}
func (s *LenzParseState) appendText(text string) {
s.Tokens.AppendText(text)
if pageTokens := s.currentPageTokens(); pageTokens != nil {
pageTokens.AppendText(text)
}
}
func (s *LenzParseState) markCurrentPageInline(inline bool) {
if page := s.ensureCurrentPage(); page != nil {
page.StartsInline = inline
}
}
type LenzTextHandler struct {
Lib *Library
}
func (h LenzTextHandler) NewState() *LenzParseState {
return &LenzParseState{
CloseElement: true,
PC: "1",
Lib: h.Lib,
}
}
func (h LenzTextHandler) OnOpenElement(state *xmlparsing.ParseState[*LenzParseState], elem *xmlparsing.Token) error {
ps := state.Data()
switch elem.Name {
case "insertion":
ps.appendDefaultElement(elem)
ps.appendDivElement("", "insertion-marker")
ps.appendEndElement()
case "sidenote":
id := randString(8)
ps.appendDefaultElement(elem)
ps.Break = false
ps.appendCustomAttribute("aria-describedby", id)
if elem.Attributes["annotation"] != "" ||
elem.Attributes["page"] != "" ||
elem.Attributes["pos"] != "" {
note := Note{Id: id}
note.Tokens.AppendDivElement(id, "note-sidenote-meta")
ps.appendDivElement(id, "inline-sidenote-meta")
if elem.Attributes["page"] != "" {
note.Tokens.AppendDivElement("", "sidenote-page")
note.Tokens.AppendText(elem.Attributes["page"])
note.Tokens.AppendEndElement()
ps.appendDivElement("", "sidenote-page")
ps.appendText(elem.Attributes["page"])
ps.appendEndElement()
}
if elem.Attributes["annotation"] != "" {
note.Tokens.AppendDivElement("", "sidenote-note")
note.Tokens.AppendText(elem.Attributes["annotation"])
note.Tokens.AppendEndElement()
ps.appendDivElement("", "sidenote-note")
ps.appendText(elem.Attributes["annotation"])
ps.appendEndElement()
}
if elem.Attributes["pos"] != "" {
note.Tokens.AppendDivElement("", "sidenote-pos")
note.Tokens.AppendText(elem.Attributes["pos"])
note.Tokens.AppendEndElement()
ps.appendDivElement("", "sidenote-pos")
ps.appendText(elem.Attributes["pos"])
ps.appendEndElement()
}
note.Tokens.AppendEndElement()
ps.appendEndElement()
ps.AppendNote(note)
}
case "note":
id := randString(8)
ps.appendLink("#"+id, "nanchor-note")
ps.appendEndElement()
ps.appendDivElement(id, "note", "note-note")
case "nr":
ext := elem.Attributes["extent"]
if ext == "" {
ext = "1"
}
extno, err := strconv.Atoi(ext)
if err != nil {
extno = 1
}
ps.appendDefaultElement(elem)
for i := 0; i < extno; i++ {
ps.appendText("&nbsp;")
}
case "hand":
id := randString(8)
idno, err := strconv.Atoi(elem.Attributes["ref"])
var person *PersonDef
if err == nil && ps.Lib != nil {
person = ps.Lib.Persons.Item(idno)
}
hand := "N/A"
if person != nil {
hand = person.Name
}
note := Note{Id: id}
note.Tokens.AppendDivElement(id, "note-hand")
note.Tokens.AppendText(hand)
note.Tokens.AppendEndElement()
ps.AppendNote(note)
ps.appendDivElement(id, "inline-hand")
ps.appendText(hand)
ps.appendEndElement()
ps.appendDivElement("", "hand")
ps.appendCustomAttribute("aria-describedby", id)
case "line":
if val := elem.Attributes["type"]; val != "empty" {
ps.LC += 1
if ps.Break {
ps.appendEmptyElement("br", ps.PC+"-"+strconv.Itoa(ps.LC))
}
ps.appendDefaultElement(elem)
} else {
ps.appendEmptyElement("br", "", "empty")
ps.CloseElement = false
}
ps.LineBreak = true
case "page":
ps.PC = elem.Attributes["index"]
ps.PageBreak = true
ps.CloseElement = false
ps.startPage(ps.PC)
default:
ps.appendDefaultElement(elem)
}
return nil
}
func (h LenzTextHandler) OnCloseElement(state *xmlparsing.ParseState[*LenzParseState], elem *xmlparsing.Token) error {
ps := state.Data()
if elem.Name == "sidenote" {
ps.LineBreak = true
}
if ps.CloseElement {
ps.appendEndElement()
} else {
ps.CloseElement = true
}
return nil
}
func (h LenzTextHandler) OnText(state *xmlparsing.ParseState[*LenzParseState], elem *xmlparsing.Token) error {
ps := state.Data()
trimmed := strings.TrimSpace(elem.Data)
if trimmed == "" {
return nil
}
if !ps.Break {
ps.Break = true
}
if ps.PageBreak && ps.PC != "1" {
ps.PageBreak = false
inline := !ps.LineBreak
ps.markCurrentPageInline(inline)
note := Note{Id: ps.PC}
quality := "outside"
if inline {
quality = "inside"
}
ps.appendDivElement("", "eanchor-page", "eanchor-page-"+quality)
ps.appendCustomAttribute("aria-describedby", ps.PC)
ps.appendEndElement()
ps.appendDivElement("", "page-counter", "page-"+quality)
ps.appendText(ps.PC)
ps.appendEndElement()
note.Tokens.AppendDivElement(ps.PC, "page", "page-"+quality)
note.Tokens.AppendText(ps.PC)
note.Tokens.AppendEndElement()
ps.Count = append(ps.Count, note)
}
if ps.LineBreak {
ps.LineBreak = false
}
ps.appendDefaultElement(elem)
return nil
}
func (h LenzTextHandler) OnComment(*xmlparsing.ParseState[*LenzParseState], *xmlparsing.Token) error {
return nil
}
func (h LenzTextHandler) Result(state *xmlparsing.ParseState[*LenzParseState]) (string, error) {
return state.Data().String(), nil
}
func parseText(lib *Library, raw string) (xmlparsing.Parsed[LenzTextHandler, *LenzParseState], error) {
handler := LenzTextHandler{Lib: lib}
parsed := xmlparsing.NewParsed[LenzTextHandler, *LenzParseState](handler)
return parsed, parsed.ParseString(raw)
}
// TemplateParse exposes the legacy helper for go templates (e.g. traditions).
func TemplateParse(lib *Library) func(letter *Meta, s string) string {
return func(_ *Meta, s string) string {
parsed, err := parseText(lib, s)
if err != nil {
return err.Error()
}
return parsed.Data().String()
}
}

View File

@@ -1,23 +0,0 @@
package xmlmodels
import "testing"
func TestPageRendering(t *testing.T) {
content := `<letterText><page index="1"/><line>First</line><page index="2"/><line>Second</line></letterText>`
parsed, err := parseText(nil, content)
if err != nil {
t.Fatalf("parse error: %v", err)
}
state := parsed.Data()
for i, page := range state.Pages {
t.Logf("page %d idx=%s html=%q", i, page.Index, page.HTML())
}
if len(state.Pages) != 2 {
t.Fatalf("expected 2 pages, got %d", len(state.Pages))
}
for i, page := range state.Pages {
if page.HTML() == "" {
t.Fatalf("page %d empty", i)
}
}
}

View File

@@ -10,8 +10,8 @@ import (
"strconv"
"strings"
"sync"
"time"
gitpkg "github.com/Theodor-Springmann-Stiftung/lenz-web/git"
"github.com/Theodor-Springmann-Stiftung/lenz-web/xmlparsing"
)
@@ -23,8 +23,7 @@ const (
)
type Library struct {
mu sync.Mutex
xmlparsing.Library
Commit gitpkg.Commit
Persons *xmlparsing.XMLParser[PersonDef]
Places *xmlparsing.XMLParser[LocationDef]
@@ -38,7 +37,6 @@ type Library struct {
}
func (l *Library) String() string {
// TODO:
sb := strings.Builder{}
sb.WriteString("Persons: ")
@@ -63,7 +61,7 @@ func (l *Library) String() string {
hands += 1
sb.WriteString("\n")
sb.WriteString(strconv.Itoa(l.Letter) + ": ")
sb.WriteString(strconv.Itoa(len(l.Hands)) + " nde, No " + strconv.Itoa(hands))
sb.WriteString(strconv.Itoa(len(l.Hands)) + " hands, No " + strconv.Itoa(hands))
}
sb.WriteString("\n")
@@ -78,9 +76,8 @@ func (l *Library) String() string {
return sb.String()
}
// INFO: this is the only place where the providers are created. There is no need for locking on access.
func NewLibrary() *Library {
return &Library{
func NewLibrary(baseDir string, commit *gitpkg.Commit) (*Library, error) {
lib := &Library{
Persons: xmlparsing.NewXMLParser[PersonDef](),
Places: xmlparsing.NewXMLParser[LocationDef](),
AppDefs: xmlparsing.NewXMLParser[AppDef](),
@@ -88,122 +85,69 @@ func NewLibrary() *Library {
Traditions: xmlparsing.NewXMLParser[Tradition](),
Metas: xmlparsing.NewXMLParser[Meta](),
}
if commit != nil {
lib.Commit = *commit
}
if err := lib.parse(baseDir); err != nil {
return nil, err
}
return lib, nil
}
func (l *Library) Parse(source xmlparsing.ParseSource, baseDir, commit string) error {
// INFO: this lock prevents multiple parses from happening at the same time.
l.mu.Lock()
defer l.mu.Unlock()
func (l *Library) parse(baseDir string) error {
l.cache.Clear()
wg := sync.WaitGroup{}
meta := xmlparsing.ParseMeta{
Source: source,
BaseDir: baseDir,
Commit: commit,
Date: time.Now(),
}
metamu := sync.Mutex{}
l.prepare()
failedPaths := make([]string, 0)
failedMu := sync.Mutex{}
parse := func(fn func() error, path string, label string) {
wg.Add(1)
go func() {
defer wg.Done()
if err := fn(); err != nil {
metamu.Lock()
failedMu.Lock()
slog.Error("Failed to serialize "+label+":", "error", err)
meta.FailedPaths = append(meta.FailedPaths, filepath.Join(meta.BaseDir, path))
metamu.Unlock()
failedPaths = append(failedPaths, filepath.Join(baseDir, path))
failedMu.Unlock()
}
wg.Done()
}()
}
// References must be ready before dependent documents (hands etc.) resolve correctly.
parse(func() error {
return l.Persons.Serialize(&PersonDefs{}, filepath.Join(meta.BaseDir, REFERENCES_PATH), meta)
return l.Persons.Serialize(&PersonDefs{}, filepath.Join(baseDir, REFERENCES_PATH))
}, REFERENCES_PATH, "persons")
parse(func() error {
return l.Places.Serialize(&LocationDefs{}, filepath.Join(meta.BaseDir, REFERENCES_PATH), meta)
return l.Places.Serialize(&LocationDefs{}, filepath.Join(baseDir, REFERENCES_PATH))
}, REFERENCES_PATH, "places")
parse(func() error {
return l.AppDefs.Serialize(&AppDefs{}, filepath.Join(meta.BaseDir, REFERENCES_PATH), meta)
return l.AppDefs.Serialize(&AppDefs{}, filepath.Join(baseDir, REFERENCES_PATH))
}, REFERENCES_PATH, "appdefs")
wg.Wait()
// Remaining documents can be parsed once references are available.
parse(func() error {
return l.Letters.Serialize(&DocumentsRoot{}, filepath.Join(meta.BaseDir, LETTERS_PATH), meta)
return l.Letters.Serialize(&DocumentsRoot{}, filepath.Join(baseDir, LETTERS_PATH))
}, LETTERS_PATH, "letters")
parse(func() error {
return l.Traditions.Serialize(&TraditionsRoot{}, filepath.Join(meta.BaseDir, TRADITIONS_PATH), meta)
return l.Traditions.Serialize(&TraditionsRoot{}, filepath.Join(baseDir, TRADITIONS_PATH))
}, TRADITIONS_PATH, "traditions")
parse(func() error {
return l.Metas.Serialize(&MetaRoot{}, filepath.Join(meta.BaseDir, META_PATH), meta)
return l.Metas.Serialize(&MetaRoot{}, filepath.Join(baseDir, META_PATH))
}, META_PATH, "meta")
wg.Wait()
l.cleanup(meta)
l.Parses = append(l.Parses, meta)
if len(failedPaths) > 0 {
return fmt.Errorf("parsing encountered errors: failed paths: %v", failedPaths)
}
var errors []string
if len(meta.FailedPaths) > 0 {
errors = append(errors, fmt.Sprintf("Failed paths: %v", meta.FailedPaths))
}
if len(errors) > 0 {
return fmt.Errorf("Parsing encountered errors: %v", strings.Join(errors, "; "))
}
return nil
}
func (l *Library) prepare() {
l.Persons.Prepare()
l.Places.Prepare()
l.AppDefs.Prepare()
l.Letters.Prepare()
l.Traditions.Prepare()
l.Metas.Prepare()
}
func (l *Library) cleanup(meta xmlparsing.ParseMeta) {
wg := sync.WaitGroup{}
wg.Add(6)
go func() {
l.Persons.Cleanup(meta)
wg.Done()
}()
go func() {
l.Places.Cleanup(meta)
wg.Done()
}()
go func() {
l.AppDefs.Cleanup(meta)
wg.Done()
}()
go func() {
l.Letters.Cleanup(meta)
wg.Done()
}()
go func() {
l.Traditions.Cleanup(meta)
wg.Done()
}()
go func() {
l.Metas.Cleanup(meta)
wg.Done()
}()
wg.Wait()
}
type NextPrev struct {
Next, Prev *Meta
}

View File

@@ -1,40 +1,7 @@
package xmlmodels
import (
"sync"
import gitpkg "github.com/Theodor-Springmann-Stiftung/lenz-web/git"
"github.com/Theodor-Springmann-Stiftung/lenz-web/xmlparsing"
)
var lib *Library
var mu sync.RWMutex
func Set(l *Library) {
mu.Lock()
defer mu.Unlock()
if lib != nil {
panic("Trying to reinitialize Library")
}
lib = l
}
func Get() *Library {
mu.RLock()
defer mu.RUnlock()
if lib == nil {
panic("Trying to get uninitialized Library")
}
return lib
}
func Parse(dir, hash string) (*Library, error) {
if lib == nil {
Set(NewLibrary())
}
if hash == "" {
return Get(), lib.Parse(xmlparsing.Path, dir, hash)
}
return Get(), lib.Parse(xmlparsing.Commit, dir, hash)
func Parse(dir string, commit *gitpkg.Commit) (*Library, error) {
return NewLibrary(dir, commit)
}

View File

@@ -1,219 +0,0 @@
package xmlmodels
import (
"strings"
"github.com/Theodor-Springmann-Stiftung/lenz-web/xmlparsing"
)
type outType int
const (
NA outType = iota
Text
Element
EmptyElement
EndElement
)
type outToken struct {
Type outType
Name string
Classes []string
Id string
Value string
Attributes map[string]string
}
func (o outToken) String() string {
switch o.Type {
case Text:
return o.Value
case Element:
builder := strings.Builder{}
builder.WriteString("<")
builder.WriteString(o.Name)
if len(o.Classes) > 0 {
builder.WriteString(" class=\"")
builder.WriteString(strings.Join(o.Classes, " "))
builder.WriteString("\"")
}
if len(o.Id) > 0 {
builder.WriteString(" id=\"")
builder.WriteString(o.Id)
builder.WriteString("\"")
}
if len(o.Attributes) > 0 {
for key, value := range o.Attributes {
builder.WriteString(" ")
builder.WriteString(key)
builder.WriteString("=\"")
builder.WriteString(value)
builder.WriteString("\"")
}
}
builder.WriteString(">")
return builder.String()
case EndElement:
return "</" + o.Name + ">"
case EmptyElement:
builder := strings.Builder{}
builder.WriteString("<")
builder.WriteString(o.Name)
if len(o.Classes) > 0 {
builder.WriteString(" class=\"")
builder.WriteString(strings.Join(o.Classes, " "))
builder.WriteString("\"")
}
if len(o.Id) > 0 {
builder.WriteString(" id=\"")
builder.WriteString(o.Id)
builder.WriteString("\"")
}
if len(o.Attributes) > 0 {
for key, value := range o.Attributes {
builder.WriteString(" ")
builder.WriteString(key)
builder.WriteString("=\"")
builder.WriteString(value)
builder.WriteString("\"")
}
}
builder.WriteString("/>")
return builder.String()
}
return ""
}
func (o *outToken) ClassesFromAttrs(attrs map[string]string) {
if len(attrs) == 0 {
return
}
for key, value := range attrs {
o.Classes = append(o.Classes, key+"-"+value)
}
}
func Default(token *xmlparsing.Token) outToken {
o := outToken{}
switch token.Type {
case xmlparsing.StartElement:
o.Name = "div"
o.Type = Element
o.Classes = []string{token.Name}
o.ClassesFromAttrs(token.Attributes)
case xmlparsing.EndElement:
o.Type = EndElement
case xmlparsing.CharData:
o.Type = Text
o.Value = token.Data
}
return o
}
type Tokens struct {
Out []outToken
}
func (s *Tokens) Prepend(token outToken) {
s.Out = append([]outToken{token}, s.Out...)
}
func (s *Tokens) AppendDefaultElement(token *xmlparsing.Token, ids ...string) {
t := Default(token)
if len(ids) > 0 {
t.Id = ids[0]
}
s.Out = append(s.Out, t)
}
func (s *Tokens) AppendCustomAttribute(name, value string) {
if len(s.Out) == 0 {
return
}
if s.Out[len(s.Out)-1].Attributes == nil {
s.Out[len(s.Out)-1].Attributes = make(map[string]string)
}
s.Out[len(s.Out)-1].Attributes[name] = value
}
func (s *Tokens) AppendElement(name string, id string, classes ...string) {
s.Out = append(s.Out, outToken{
Name: name,
Id: id,
Classes: classes,
Type: Element,
})
}
func (s *Tokens) AppendEndElement() {
skip := 0
for i := len(s.Out) - 1; i >= 0; i-- {
if s.Out[i].Type == EndElement {
skip++
}
if s.Out[i].Type == Element && s.Out[i].Name != "p" && s.Out[i].Name != "br" {
if skip == 0 {
s.Out = append(s.Out, outToken{
Name: s.Out[i].Name,
Type: EndElement,
})
return
} else {
skip--
}
}
}
}
func (s *Tokens) AppendDivElement(id string, classes ...string) {
s.Out = append(s.Out, outToken{
Name: "div",
Id: id,
Classes: classes,
Type: Element,
})
}
func (s *Tokens) AppendEmptyElement(name string, id string, classes ...string) {
s.Out = append(s.Out, outToken{
Name: name,
Id: id,
Classes: classes,
Type: EmptyElement,
})
}
func (s *Tokens) AppendLink(href string, classes ...string) {
s.Out = append(s.Out, outToken{
Name: "a",
Attributes: map[string]string{"href": href},
Classes: classes,
Type: Element,
})
}
func (s *Tokens) AppendText(text string) {
s.Out = append(s.Out, outToken{
Type: Text,
Value: text,
})
}
func (s *Tokens) String() string {
builder := strings.Builder{}
for _, token := range s.Out {
builder.WriteString(token.String())
}
return builder.String()
}