434 lines
13 KiB
Go
434 lines
13 KiB
Go
package topdata
|
|
|
|
import (
|
|
"fmt"
|
|
"html"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
type wikiTemplatePage struct {
|
|
PageID string
|
|
Category string
|
|
Key string
|
|
Title string
|
|
Status string
|
|
Row map[string]any
|
|
ManualSections []wikiManualSection
|
|
}
|
|
|
|
func (ctx *wikiContext) renderWikiPageTemplate(category string, page wikiTemplatePage) (string, error) {
|
|
name := wikiTemplateName(category)
|
|
source, path := ctx.loadWikiTemplateSource(name)
|
|
if source == "" && name != "default.html" {
|
|
source, path = ctx.loadWikiTemplateSource("default.html")
|
|
}
|
|
if source == "" {
|
|
source = builtinWikiTemplate(category)
|
|
path = "builtin:" + name
|
|
}
|
|
return ctx.renderWikiTemplateString(path, source, page)
|
|
}
|
|
|
|
func wikiTemplateName(category string) string {
|
|
switch category {
|
|
case "classes", "feat", "skills", "spells", "racialtypes", "baseitems":
|
|
return category + ".html"
|
|
default:
|
|
return "default.html"
|
|
}
|
|
}
|
|
|
|
func (ctx *wikiContext) loadWikiTemplateSource(name string) (string, string) {
|
|
if strings.TrimSpace(ctx.templateDir) == "" {
|
|
return "", ""
|
|
}
|
|
path := filepath.Join(ctx.templateDir, filepath.FromSlash(name))
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", path
|
|
}
|
|
return string(raw), path
|
|
}
|
|
|
|
func builtinWikiTemplate(category string) string {
|
|
switch category {
|
|
case "classes":
|
|
return `<h1>{{title}}</h1>
|
|
{{UserTopSection}}
|
|
{{format:StatusCallout}}
|
|
{{format:Description}}
|
|
{{format:FactsTable}}
|
|
{{format:SkillList}}
|
|
{{format:FeatProgression}}
|
|
{{format:UserBottomFallback}}`
|
|
case "racialtypes":
|
|
return `<h1>{{title}}</h1>
|
|
{{UserTopSection}}
|
|
{{format:StatusCallout}}
|
|
{{format:Description}}
|
|
{{format:FactsTable}}
|
|
{{format:RaceNameForms}}
|
|
{{format:UserBottomFallback}}`
|
|
default:
|
|
return `<h1>{{title}}</h1>
|
|
{{UserTopSection}}
|
|
{{format:StatusCallout}}
|
|
{{format:Description}}
|
|
{{format:FactsTable}}
|
|
{{format:UserBottomFallback}}`
|
|
}
|
|
}
|
|
|
|
func (ctx *wikiContext) renderWikiTemplateString(path, source string, page wikiTemplatePage) (string, error) {
|
|
state := &wikiTemplateRenderState{
|
|
path: path,
|
|
page: page,
|
|
seenSections: map[string]struct{}{},
|
|
active: true,
|
|
}
|
|
rendered, offset, closed, err := ctx.renderWikiTemplateRange(source, 0, "", state)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if closed {
|
|
return "", fmt.Errorf("render wiki template %s for %s: unexpected closing block", path, page.PageID)
|
|
}
|
|
if offset != len(source) {
|
|
return "", fmt.Errorf("render wiki template %s for %s: template parse stopped early", path, page.PageID)
|
|
}
|
|
return strings.TrimSpace(rendered), nil
|
|
}
|
|
|
|
type wikiTemplateRenderState struct {
|
|
path string
|
|
page wikiTemplatePage
|
|
row map[string]any
|
|
dot any
|
|
seenSections map[string]struct{}
|
|
active bool
|
|
}
|
|
|
|
func (ctx *wikiContext) renderWikiTemplateRange(source string, offset int, stopBlock string, state *wikiTemplateRenderState) (string, int, bool, error) {
|
|
var out strings.Builder
|
|
for {
|
|
start := strings.Index(source[offset:], "{{")
|
|
if start < 0 {
|
|
if stopBlock != "" {
|
|
return "", offset, false, fmt.Errorf("render wiki template %s for %s: missing {{/%s}}", state.path, state.page.PageID, stopBlock)
|
|
}
|
|
if state.active {
|
|
out.WriteString(source[offset:])
|
|
}
|
|
return out.String(), len(source), false, nil
|
|
}
|
|
start += offset
|
|
end := strings.Index(source[start+2:], "}}")
|
|
if end < 0 {
|
|
return "", offset, false, fmt.Errorf("render wiki template %s for %s: unclosed placeholder", state.path, state.page.PageID)
|
|
}
|
|
end += start + 2
|
|
if state.active {
|
|
out.WriteString(source[offset:start])
|
|
}
|
|
token := strings.TrimSpace(source[start+2 : end])
|
|
if strings.HasPrefix(token, "/") {
|
|
name := strings.TrimSpace(strings.TrimPrefix(token, "/"))
|
|
if stopBlock == "" || name != stopBlock {
|
|
return "", offset, false, fmt.Errorf("render wiki template %s for %s: unexpected {{/%s}}", state.path, state.page.PageID, name)
|
|
}
|
|
return out.String(), end + 2, true, nil
|
|
}
|
|
if strings.HasPrefix(token, "#each ") {
|
|
providerName := strings.TrimSpace(strings.TrimPrefix(token, "#each "))
|
|
body, nextOffset, err := extractWikiTemplateBlock(source, end+2, "each", state.path, state.page.PageID)
|
|
if err != nil {
|
|
return "", offset, false, err
|
|
}
|
|
if state.active {
|
|
items, err := ctx.resolveWikiTemplateEachItems(providerName, state)
|
|
if err != nil {
|
|
return "", offset, false, fmt.Errorf("render wiki template %s for %s: %w", state.path, state.page.PageID, err)
|
|
}
|
|
for _, item := range items {
|
|
child := *state
|
|
child.row = item.row
|
|
child.dot = item.dot
|
|
rendered, childOffset, closed, err := ctx.renderWikiTemplateRange(body, 0, "", &child)
|
|
if err != nil {
|
|
return "", offset, false, err
|
|
}
|
|
if closed || childOffset != len(body) {
|
|
return "", offset, false, fmt.Errorf("render wiki template %s for %s: malformed each body", state.path, state.page.PageID)
|
|
}
|
|
out.WriteString(rendered)
|
|
}
|
|
}
|
|
offset = nextOffset
|
|
continue
|
|
}
|
|
if strings.HasPrefix(token, "#if ") {
|
|
expr := strings.TrimSpace(strings.TrimPrefix(token, "#if "))
|
|
body, nextOffset, err := extractWikiTemplateBlock(source, end+2, "if", state.path, state.page.PageID)
|
|
if err != nil {
|
|
return "", offset, false, err
|
|
}
|
|
if state.active {
|
|
value, err := ctx.evalWikiTemplateExpression(expr, state)
|
|
if err != nil {
|
|
return "", offset, false, err
|
|
}
|
|
if truthyWikiTableValue(value) {
|
|
child := *state
|
|
rendered, childOffset, closed, err := ctx.renderWikiTemplateRange(body, 0, "", &child)
|
|
if err != nil {
|
|
return "", offset, false, err
|
|
}
|
|
if closed || childOffset != len(body) {
|
|
return "", offset, false, fmt.Errorf("render wiki template %s for %s: malformed if body", state.path, state.page.PageID)
|
|
}
|
|
out.WriteString(rendered)
|
|
}
|
|
}
|
|
offset = nextOffset
|
|
continue
|
|
}
|
|
if strings.HasPrefix(token, "#with ") {
|
|
expr := strings.TrimSpace(strings.TrimPrefix(token, "#with "))
|
|
body, nextOffset, err := extractWikiTemplateBlock(source, end+2, "with", state.path, state.page.PageID)
|
|
if err != nil {
|
|
return "", offset, false, err
|
|
}
|
|
if state.active {
|
|
value, err := ctx.evalWikiTemplateExpression(expr, state)
|
|
if err != nil {
|
|
return "", offset, false, err
|
|
}
|
|
if truthyWikiTableValue(value) {
|
|
child := *state
|
|
child.dot = value
|
|
rendered, childOffset, closed, err := ctx.renderWikiTemplateRange(body, 0, "", &child)
|
|
if err != nil {
|
|
return "", offset, false, err
|
|
}
|
|
if closed || childOffset != len(body) {
|
|
return "", offset, false, fmt.Errorf("render wiki template %s for %s: malformed with body", state.path, state.page.PageID)
|
|
}
|
|
out.WriteString(rendered)
|
|
}
|
|
}
|
|
offset = nextOffset
|
|
continue
|
|
}
|
|
if !state.active {
|
|
offset = end + 2
|
|
continue
|
|
}
|
|
rendered, sectionID, err := ctx.renderWikiTemplateTokenWithState(token, state)
|
|
if err != nil {
|
|
return "", offset, false, err
|
|
}
|
|
if sectionID != "" {
|
|
if _, exists := state.seenSections[sectionID]; exists {
|
|
return "", offset, false, fmt.Errorf("render wiki template %s for %s: duplicate preserved section %q", state.path, state.page.PageID, sectionID)
|
|
}
|
|
state.seenSections[sectionID] = struct{}{}
|
|
}
|
|
out.WriteString(rendered)
|
|
offset = end + 2
|
|
}
|
|
}
|
|
|
|
type wikiTemplateEachItem struct {
|
|
row map[string]any
|
|
dot any
|
|
}
|
|
|
|
func (ctx *wikiContext) resolveWikiTemplateEachItems(name string, state *wikiTemplateRenderState) ([]wikiTemplateEachItem, error) {
|
|
if value, ok := ctx.resolveWikiTemplateScopedValue(name, state); ok {
|
|
return wikiTemplateEachItemsFromValue(value), nil
|
|
}
|
|
rows, err := ctx.wikiDataProviderRows(name, state.page)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]wikiTemplateEachItem, 0, len(rows))
|
|
for _, row := range rows {
|
|
items = append(items, wikiTemplateEachItem{row: row, dot: row})
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func wikiTemplateEachItemsFromValue(value any) []wikiTemplateEachItem {
|
|
switch typed := value.(type) {
|
|
case []map[string]any:
|
|
items := make([]wikiTemplateEachItem, 0, len(typed))
|
|
for _, row := range typed {
|
|
items = append(items, wikiTemplateEachItem{row: row, dot: row})
|
|
}
|
|
return items
|
|
case []any:
|
|
items := make([]wikiTemplateEachItem, 0, len(typed))
|
|
for _, value := range typed {
|
|
if row, ok := value.(map[string]any); ok {
|
|
items = append(items, wikiTemplateEachItem{row: row, dot: row})
|
|
continue
|
|
}
|
|
items = append(items, wikiTemplateEachItem{dot: value})
|
|
}
|
|
return items
|
|
case []string:
|
|
items := make([]wikiTemplateEachItem, 0, len(typed))
|
|
for _, value := range typed {
|
|
items = append(items, wikiTemplateEachItem{dot: value})
|
|
}
|
|
return items
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (ctx *wikiContext) renderWikiTemplateTokenWithState(token string, state *wikiTemplateRenderState) (string, string, error) {
|
|
if strings.HasPrefix(token, "format:") || strings.HasPrefix(token, "table:") || strings.HasPrefix(token, "field:") || strings.HasPrefix(token, "section:") {
|
|
rendered, sectionID, err := ctx.renderWikiTemplateToken(state.path, token, state.page)
|
|
return rendered, sectionID, err
|
|
}
|
|
if strings.Contains(token, "|") || strings.Contains(token, "(") || token == "." || strings.HasPrefix(token, "page.") {
|
|
value, err := ctx.evalWikiTemplateExpression(token, state)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return stringifyWikiTemplateOutput(value), "", nil
|
|
}
|
|
if state.row != nil {
|
|
if _, ok := ctx.resolveWikiTemplateScopedValue(token, state); ok {
|
|
value, err := ctx.evalWikiTemplateExpression(token, state)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return stringifyWikiTemplateOutput(value), "", nil
|
|
}
|
|
}
|
|
rendered, sectionID, err := ctx.renderWikiTemplateToken(state.path, token, state.page)
|
|
return rendered, sectionID, err
|
|
}
|
|
|
|
func (ctx *wikiContext) renderWikiTemplateToken(path, token string, page wikiTemplatePage) (string, string, error) {
|
|
switch token {
|
|
case "title":
|
|
return html.EscapeString(page.Title), "", nil
|
|
case "page_id":
|
|
return html.EscapeString(page.PageID), "", nil
|
|
case "status":
|
|
return html.EscapeString(page.Status), "", nil
|
|
}
|
|
if section, ok := wikiManualSectionForAlias(page.ManualSections, token); ok {
|
|
return renderWikiManualSection(section), section.ID, nil
|
|
}
|
|
if strings.HasPrefix(token, "section:") {
|
|
sectionName := strings.TrimSpace(strings.TrimPrefix(token, "section:"))
|
|
if section, ok := wikiManualSectionForAlias(page.ManualSections, sectionName); ok {
|
|
return renderWikiManualSection(section), section.ID, nil
|
|
}
|
|
return "", "", nil
|
|
}
|
|
if strings.HasPrefix(token, "format:") {
|
|
rendered, err := ctx.renderWikiFormatter(strings.TrimSpace(strings.TrimPrefix(token, "format:")), page)
|
|
return rendered, "", err
|
|
}
|
|
if strings.HasPrefix(token, "table:") {
|
|
rendered, err := ctx.renderConfiguredWikiTable(path, strings.TrimSpace(strings.TrimPrefix(token, "table:")), page)
|
|
return rendered, "", err
|
|
}
|
|
if strings.HasPrefix(token, "field:") {
|
|
rendered, err := ctx.renderExplicitWikiField(path, strings.TrimSpace(strings.TrimPrefix(token, "field:")), page)
|
|
return rendered, "", err
|
|
}
|
|
rendered, err := ctx.renderRequiredWikiField(path, token, page)
|
|
return rendered, "", err
|
|
}
|
|
|
|
func wikiManualSectionForAlias(sections []wikiManualSection, alias string) (wikiManualSection, bool) {
|
|
for _, section := range sections {
|
|
if section.Alias == alias {
|
|
return section, true
|
|
}
|
|
}
|
|
switch alias {
|
|
case "UserTopSection":
|
|
for _, section := range sections {
|
|
if section.ID == "user_top" {
|
|
return section, true
|
|
}
|
|
}
|
|
case "UserBottomSection":
|
|
for _, section := range sections {
|
|
if section.ID == "user_bottom" {
|
|
return section, true
|
|
}
|
|
}
|
|
}
|
|
return wikiManualSection{}, false
|
|
}
|
|
|
|
func renderWikiManualSection(section wikiManualSection) string {
|
|
return strings.Join([]string{
|
|
"<!-- sow-topdata-wiki:manual:start id=\"" + html.EscapeString(section.ID) + "\" -->",
|
|
strings.TrimSpace(section.InitialHTML),
|
|
"<!-- sow-topdata-wiki:manual:end id=\"" + html.EscapeString(section.ID) + "\" -->",
|
|
}, "\n")
|
|
}
|
|
|
|
func (ctx *wikiContext) renderExplicitWikiField(path, spec string, page wikiTemplatePage) (string, error) {
|
|
fieldSpec, defaultValue, hasDefault := strings.Cut(spec, "|default=")
|
|
fieldSpec = strings.TrimSpace(fieldSpec)
|
|
value, ok := ctx.resolveWikiTemplateField(fieldSpec, page)
|
|
if !ok {
|
|
if hasDefault {
|
|
return html.EscapeString(defaultValue), nil
|
|
}
|
|
return "", fmt.Errorf("render wiki template %s for %s: missing field %q", path, page.PageID, fieldSpec)
|
|
}
|
|
return html.EscapeString(value), nil
|
|
}
|
|
|
|
func (ctx *wikiContext) renderRequiredWikiField(path, fieldSpec string, page wikiTemplatePage) (string, error) {
|
|
value, ok := ctx.resolveWikiTemplateField(fieldSpec, page)
|
|
if !ok {
|
|
return "", fmt.Errorf("render wiki template %s for %s: missing field %q", path, page.PageID, fieldSpec)
|
|
}
|
|
return html.EscapeString(value), nil
|
|
}
|
|
|
|
func (ctx *wikiContext) resolveWikiTemplateField(fieldSpec string, page wikiTemplatePage) (string, bool) {
|
|
fieldSpec = strings.TrimSpace(fieldSpec)
|
|
if strings.HasSuffix(fieldSpec, ".text") {
|
|
base := strings.TrimSuffix(fieldSpec, ".text")
|
|
value, ok := lookupField(page.Row, base)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
text := ctx.resolveTextValue(value, nil)
|
|
return text, text != ""
|
|
}
|
|
value, ok := lookupField(page.Row, fieldSpec)
|
|
if !ok || value == nil {
|
|
return "", false
|
|
}
|
|
switch typed := value.(type) {
|
|
case string:
|
|
if typed == "" || typed == nullValue {
|
|
return "", false
|
|
}
|
|
return typed, true
|
|
case int:
|
|
return fmt.Sprintf("%d", typed), true
|
|
case float64:
|
|
return fmt.Sprintf("%d", int(typed)), true
|
|
default:
|
|
text := ctx.resolveTextValue(typed, nil)
|
|
return text, text != ""
|
|
}
|
|
}
|