Wiki Template-First Rendering (#9)

Reviewed-on: https://gitea.westgate.pw/ShadowsOverWestgate/sow-tools/pulls/9
Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
Co-committed-by: vickydotbat <vickydotbat@tutamail.com>
This commit is contained in:
2026-05-21 21:45:21 +02:00
committed by archvillainette
parent 6790274d95
commit 4495feb128
6 changed files with 904 additions and 12 deletions
+136
View File
@@ -0,0 +1,136 @@
package topdata
import (
"fmt"
"os"
"strings"
"gopkg.in/yaml.v3"
)
type wikiDataProvidersDocument struct {
Providers map[string]wikiDataProviderDefinition `json:"providers" yaml:"providers"`
}
type wikiDataProviderDefinition struct {
Source string `json:"source" yaml:"source"`
Levels string `json:"levels" yaml:"levels"`
}
func loadWikiDataProviders(path string) (map[string]wikiDataProviderDefinition, error) {
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return map[string]wikiDataProviderDefinition{}, nil
}
return nil, fmt.Errorf("read wiki data providers %s: %w", path, err)
}
var doc wikiDataProvidersDocument
if err := yaml.Unmarshal(raw, &doc); err != nil {
return nil, fmt.Errorf("parse wiki data providers %s: %w", path, err)
}
for name, def := range doc.Providers {
if strings.TrimSpace(name) == "" {
return nil, fmt.Errorf("wiki data providers %s: provider name must not be empty", path)
}
if strings.TrimSpace(def.Source) == "" {
return nil, fmt.Errorf("wiki data providers %s: provider %s missing source", path, name)
}
switch strings.TrimSpace(def.Source) {
case "class_progression", "class_feats", "class_summary", "race_feats", "race_name_forms":
default:
return nil, fmt.Errorf("wiki data providers %s: provider %s has unknown source %q", path, name, def.Source)
}
}
if doc.Providers == nil {
return map[string]wikiDataProviderDefinition{}, nil
}
return doc.Providers, nil
}
func (ctx *wikiContext) wikiDataProviderRows(name string, page wikiTemplatePage) ([]map[string]any, error) {
def, ok := ctx.wikiDataProviders[name]
if !ok {
return nil, fmt.Errorf("missing wiki data provider %q in %s", name, ctx.wikiDataPath)
}
switch strings.TrimSpace(def.Source) {
case "class_progression":
if page.Category != "classes" {
return nil, nil
}
return ctx.classProgressionRows(wikiTableDefinition{Rows: wikiTableRowsConfig{Levels: def.Levels}}, page.Row)
case "class_feats":
if page.Category != "classes" {
return nil, nil
}
return ctx.classFeatRows(page.Row), nil
case "class_summary":
if page.Category != "classes" {
return nil, nil
}
return ctx.classSummaryRows(page.Row), nil
case "race_feats":
if page.Category != "racialtypes" {
return nil, nil
}
return ctx.raceFeatRows(page.Row), nil
case "race_name_forms":
if page.Category != "racialtypes" {
return nil, nil
}
return ctx.raceNameFormRows(page.Row), nil
default:
return nil, fmt.Errorf("unknown wiki data provider source %q", def.Source)
}
}
func (ctx *wikiContext) raceFeatRows(raceRow map[string]any) []map[string]any {
values := []any{}
switch typed := fieldValue(raceRow, "FeatsTable").(type) {
case []any:
values = append(values, typed...)
case []string:
for _, value := range typed {
values = append(values, value)
}
default:
if table := ctx.tableForValue(typed, ctx.raceFeatTables); table != nil {
for _, row := range table.Rows {
values = append(values, fieldValue(row, "FeatIndex"))
}
}
}
rows := []map[string]any{}
for _, value := range values {
key := resolveReferenceKey(value, ctx.featIDToKey)
if key == "" {
key = resolveReferenceKey(value, nil)
}
if key == "" || !ctx.canReferenceWikiKey(key) {
continue
}
rows = append(rows, map[string]any{
"FeatKey": key,
"FeatName": ctx.resolveFeatName(key),
})
}
return rows
}
func (ctx *wikiContext) raceNameFormRows(raceRow map[string]any) []map[string]any {
candidates := []struct {
label string
field string
}{
{"Plural", "NamePlural"},
{"Converted Name", "ConverName"},
{"Lower Name", "ConverNameLower"},
}
rows := []map[string]any{}
for _, candidate := range candidates {
if value := ctx.resolveTextValue(fieldValue(raceRow, candidate.field), nil); value != "" {
rows = append(rows, map[string]any{"Label": candidate.label, "Value": value})
}
}
return rows
}
+8
View File
@@ -115,6 +115,8 @@ type wikiContext struct {
templateDir string
wikiTablesPath string
wikiTableDefinitions map[string]wikiTableDefinition
wikiDataPath string
wikiDataProviders map[string]wikiDataProviderDefinition
visibilityPolicy *wikiVisibilityDefinitions
visibleWikiKeys map[string]bool
pageSlugOverrides map[string]string
@@ -157,6 +159,12 @@ func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progres
return wikiResult{}, err
}
ctx.wikiTableDefinitions = tableDefinitions
ctx.wikiDataPath = filepath.Join(p.TopDataWikiSourceDir(), "data.yaml")
dataProviders, err := loadWikiDataProviders(ctx.wikiDataPath)
if err != nil {
return wikiResult{}, err
}
ctx.wikiDataProviders = dataProviders
visibilityPath := filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.VisibilityFile))
if err := ctx.loadWikiVisibility(visibilityPath); err != nil {
return wikiResult{}, err
+171
View File
@@ -268,6 +268,177 @@ func TestWikiTemplateMissingFieldFailsUnlessDefaultProvided(t *testing.T) {
}
}
func TestWikiTemplateEachBlockRendersExplicitClassProgressionTable(t *testing.T) {
ctx := &wikiContext{
wikiDataProviders: map[string]wikiDataProviderDefinition{
"ClassProgression": {Source: "class_progression", Levels: "1-2"},
},
}
page := wikiTemplatePage{
PageID: "classes:fighter",
Category: "classes",
Key: "classes:fighter",
Title: "Fighter",
Row: map[string]any{
"HitDie": "10",
"AttackBonusTable": "cls_atk_1",
},
}
template := `<table class="wiki-table wiki-class-progression">
<thead>
<tr><th>Lvl</th><th>BAB</th><th>HP</th></tr>
</thead>
<tbody>
{{#each ClassProgression}}
<tr><td>{{Level|ordinal}}</td><td>{{BAB}}</td><td>{{HPMin}}-{{HPMax}}</td></tr>
{{/each}}
</tbody>
</table>`
got, err := ctx.renderWikiTemplateString("classes.html", template, page)
if err != nil {
t.Fatalf("render template: %v", err)
}
for _, want := range []string{
`<table class="wiki-table wiki-class-progression">`,
`<tr><td>1st</td><td>+1</td><td>1-10</td></tr>`,
`<tr><td>2nd</td><td>+2</td><td>2-20</td></tr>`,
} {
if !strings.Contains(got, want) {
t.Fatalf("expected rendered output to contain %q:\n%s", want, got)
}
}
}
func TestWikiTemplateCanRenderRacialFactsAndFeatListInHTML(t *testing.T) {
ctx := &wikiContext{
wikiDataProviders: map[string]wikiDataProviderDefinition{
"RaceFeats": {Source: "race_feats"},
},
classIDToKey: map[int]string{7: "classes:paladin"},
featIDToKey: map[int]string{
10: "feat:darkvision",
11: "feat:daylight:aasimar",
},
classRows: map[string]map[string]any{
"classes:paladin": {"Name": "Paladin"},
},
featRows: map[string]map[string]any{
"feat:darkvision": {"FEAT": "Darkvision"},
"feat:daylight:aasimar": {"FEAT": "Daylight (aasimar)"},
},
visibleWikiKeys: map[string]bool{
"classes:paladin": true,
"feat:darkvision": true,
"feat:daylight:aasimar": true,
},
}
page := wikiTemplatePage{
PageID: "racialtypes:aasimar",
Category: "racialtypes",
Key: "racialtypes:aasimar",
Title: "Aasimar",
Row: map[string]any{
"WISAdjust": "2",
"CHAAdjust": "2",
"Favored": "7",
"FeatsTable": []any{"10", "11"},
},
}
template := `<table class="wiki-facts">
<tbody>
<tr><th scope="row">Ability Adjustments</th><td>{{ability_adjustments()}}</td></tr>
{{#if present(Favored)}}
<tr><th scope="row">Favored Class</th><td>{{Favored|ref:"classes"|wiki}}</td></tr>
{{/if}}
</tbody>
</table>
<ul class="wiki-race-feats">
{{#each RaceFeats}}
<li>{{FeatKey|link:FeatName|wiki}}</li>
{{/each}}
</ul>`
got, err := ctx.renderWikiTemplateString("racialtypes.html", template, page)
if err != nil {
t.Fatalf("render template: %v", err)
}
for _, want := range []string{
`<th scope="row">Ability Adjustments</th><td>+2 Wisdom, +2 Charisma</td>`,
`<th scope="row">Favored Class</th><td>[[classes/paladin|Paladin]]</td>`,
`<li>[[feat/darkvision|Darkvision]]</li>`,
`<li>[[feat/daylight-aasimar|Daylight (aasimar)]]</li>`,
} {
if !strings.Contains(got, want) {
t.Fatalf("expected rendered output to contain %q:\n%s", want, got)
}
}
}
func TestWikiTemplateDescriptionFiltersExtractParagraphsAndSections(t *testing.T) {
ctx := &wikiContext{}
page := wikiTemplatePage{
PageID: "feat:test",
Category: "feat",
Key: "feat:test",
Title: "Test Feat",
Row: map[string]any{
"DESCRIPTION": map[string]any{"tlk": map[string]any{"text": strings.Join([]string{
"Type of Feat: General",
"",
"Prerequisite: Dexterity 13+",
"",
"Specifics: You gain a +2 dodge bonus.",
"The bonus applies only while unarmored.",
"",
"Use: Automatic.",
}, "\n")}},
},
}
tests := []struct {
name string
template string
want string
reject string
}{
{
name: "first paragraph",
template: `<div>{{DESCRIPTION|text|paragraph:first}}</div>`,
want: `<div>Type of Feat: General</div>`,
reject: `Prerequisite`,
},
{
name: "specifics after marker as html",
template: `<section>{{DESCRIPTION|text|after:"Specifics:"|before:"Use:"|trim|html}}</section>`,
want: `<section><p>You gain a +2 dodge bonus.<br>The bonus applies only while unarmored.</p></section>`,
reject: `Use: Automatic`,
},
{
name: "first and last paragraphs",
template: `<div>{{DESCRIPTION|text|paragraphs:"first,last"|html}}</div>`,
want: `<div><p>Type of Feat: General</p><p>Use: Automatic.</p></div>`,
reject: `Specifics`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ctx.renderWikiTemplateString("feat.html", tt.template, page)
if err != nil {
t.Fatalf("render template: %v", err)
}
if !strings.Contains(got, tt.want) {
t.Fatalf("expected output to contain %q:\n%s", tt.want, got)
}
if tt.reject != "" && strings.Contains(got, tt.reject) {
t.Fatalf("expected output not to contain %q:\n%s", tt.reject, got)
}
})
}
}
func TestWikiTemplateRendersConfiguredClassProgressionTable(t *testing.T) {
ctx := testWikiTableContext(t, `
tables:
+5
View File
@@ -915,6 +915,11 @@ func (p *wikiExprParser) callHelper(name string, args []any) (any, error) {
}
}
return false, nil
case "ability_adjustments":
if len(args) != 0 {
return nil, fmt.Errorf("ability_adjustments expects 0 arguments")
}
return formatAbilityAdjustments(p.renderCtx.Page.Row), nil
case "hidden_ref":
if len(args) != 2 {
return nil, fmt.Errorf("hidden_ref expects 2 arguments")
+168 -12
View File
@@ -82,37 +82,186 @@ func builtinWikiTemplate(category string) string {
}
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
offset := 0
seenSections := map[string]struct{}{}
for {
start := strings.Index(source[offset:], "{{")
if start < 0 {
out.WriteString(source[offset:])
break
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 "", fmt.Errorf("render wiki template %s for %s: unclosed placeholder", path, page.PageID)
return "", offset, false, fmt.Errorf("render wiki template %s for %s: unclosed placeholder", state.path, state.page.PageID)
}
end += start + 2
out.WriteString(source[offset:start])
if state.active {
out.WriteString(source[offset:start])
}
token := strings.TrimSpace(source[start+2 : end])
rendered, sectionID, err := ctx.renderWikiTemplateToken(path, token, page)
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 {
rows, err := ctx.wikiDataProviderRows(providerName, state.page)
if err != nil {
return "", offset, false, fmt.Errorf("render wiki template %s for %s: %w", state.path, state.page.PageID, err)
}
for _, row := range rows {
child := *state
child.row = row
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 "", err
return "", offset, false, err
}
if sectionID != "" {
if _, exists := seenSections[sectionID]; exists {
return "", fmt.Errorf("render wiki template %s for %s: duplicate preserved section %q", path, page.PageID, 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)
}
seenSections[sectionID] = struct{}{}
state.seenSections[sectionID] = struct{}{}
}
out.WriteString(rendered)
offset = end + 2
}
return strings.TrimSpace(out.String()), 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) {
@@ -127,6 +276,13 @@ func (ctx *wikiContext) renderWikiTemplateToken(path, token string, page wikiTem
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
+416
View File
@@ -0,0 +1,416 @@
package topdata
import (
"fmt"
"html"
"strconv"
"strings"
"unicode"
)
type wikiTemplateHTML string
func extractWikiTemplateBlock(source string, offset int, blockName, path, pageID string) (string, int, error) {
depth := 1
bodyStart := offset
for offset < len(source) {
start := strings.Index(source[offset:], "{{")
if start < 0 {
break
}
start += offset
end := strings.Index(source[start+2:], "}}")
if end < 0 {
return "", offset, fmt.Errorf("render wiki template %s for %s: unclosed placeholder", path, pageID)
}
end += start + 2
token := strings.TrimSpace(source[start+2 : end])
if token == "#"+blockName || strings.HasPrefix(token, "#"+blockName+" ") {
depth++
} else if token == "/"+blockName {
depth--
if depth == 0 {
return source[bodyStart:start], end + 2, nil
}
}
offset = end + 2
}
return "", offset, fmt.Errorf("render wiki template %s for %s: missing {{/%s}}", path, pageID, blockName)
}
func (ctx *wikiContext) evalWikiTemplateExpression(expr string, state *wikiTemplateRenderState) (any, error) {
parts := splitWikiTemplatePipes(expr)
if len(parts) == 0 {
return "", nil
}
value, err := ctx.evalWikiTemplateBase(strings.TrimSpace(parts[0]), state)
if err != nil {
return nil, err
}
for _, rawFilter := range parts[1:] {
value, err = ctx.applyWikiTemplateFilter(value, strings.TrimSpace(rawFilter), state)
if err != nil {
return nil, err
}
}
return value, nil
}
func splitWikiTemplatePipes(expr string) []string {
parts := []string{}
start := 0
inQuote := false
escaped := false
for i := 0; i < len(expr); i++ {
switch {
case escaped:
escaped = false
case expr[i] == '\\' && inQuote:
escaped = true
case expr[i] == '"':
inQuote = !inQuote
case expr[i] == '|' && !inQuote:
parts = append(parts, expr[start:i])
start = i + 1
}
}
parts = append(parts, expr[start:])
return parts
}
func (ctx *wikiContext) evalWikiTemplateBase(expr string, state *wikiTemplateRenderState) (any, error) {
if expr == "." {
return state.dot, nil
}
if value, ok := ctx.resolveWikiTemplateScopedValue(expr, state); ok {
return value, nil
}
renderCtx := wikiTableRenderContext{Page: state.page, Path: state.path}
row := state.page.Row
if state.row != nil {
row = state.row
}
parser := wikiExprParser{tokens: tokenizeWikiExpr(expr), row: row, ctx: ctx, renderCtx: renderCtx}
value, err := parser.parseComparison()
if err != nil {
return nil, fmt.Errorf("render wiki template %s for %s expression %q: %w", state.path, state.page.PageID, expr, err)
}
if parser.peek().kind != wikiExprEOF {
return nil, fmt.Errorf("render wiki template %s for %s expression %q: unexpected token %q", state.path, state.page.PageID, expr, parser.peek().text)
}
return value, nil
}
func (ctx *wikiContext) resolveWikiTemplateScopedValue(name string, state *wikiTemplateRenderState) (any, bool) {
name = strings.TrimSpace(name)
if strings.HasPrefix(name, "page.") {
field := strings.TrimPrefix(name, "page.")
switch field {
case "title", "Title":
return state.page.Title, true
case "page_id", "PageID":
return state.page.PageID, true
case "status", "Status":
return state.page.Status, true
case "category", "Category":
return state.page.Category, true
case "key", "Key":
return state.page.Key, true
default:
if value, ok := lookupField(state.page.Row, field); ok {
return value, true
}
return nil, false
}
}
if state.row != nil {
if value, ok := lookupField(state.row, name); ok {
return value, true
}
}
if value, ok := lookupField(state.page.Row, name); ok {
return value, true
}
switch name {
case "title":
return state.page.Title, true
case "page_id":
return state.page.PageID, true
case "status":
return state.page.Status, true
}
return nil, false
}
func (ctx *wikiContext) applyWikiTemplateFilter(value any, filter string, state *wikiTemplateRenderState) (any, error) {
name, argText, hasArg := strings.Cut(filter, ":")
name = strings.TrimSpace(name)
args, err := parseWikiTemplateFilterArgs(argText)
if hasArg && err != nil {
return nil, fmt.Errorf("render wiki template %s for %s filter %q: %w", state.path, state.page.PageID, filter, err)
}
switch name {
case "text":
return ctx.resolveTextValue(value, nil), nil
case "trim":
return strings.TrimSpace(stringifyWikiTemplatePlain(value)), nil
case "after":
return afterFirstMarker(stringifyWikiTemplatePlain(value), firstArg(args)), nil
case "before":
return beforeFirstMarker(stringifyWikiTemplatePlain(value), firstArg(args)), nil
case "between":
text := stringifyWikiTemplatePlain(value)
return beforeFirstMarker(afterFirstMarker(text, firstArg(args)), secondArg(args)), nil
case "paragraph":
return selectWikiTemplateParagraphs(stringifyWikiTemplatePlain(value), firstArg(args)), nil
case "paragraphs":
return selectWikiTemplateParagraphs(stringifyWikiTemplatePlain(value), firstArg(args)), nil
case "linebreaks":
return wikiTemplateHTML(strings.ReplaceAll(html.EscapeString(stringifyWikiTemplatePlain(value)), "\n", "<br>")), nil
case "html":
return wikiTemplateHTML(renderWikiTemplatePlainHTML(stringifyWikiTemplatePlain(value))), nil
case "wiki":
return wikiTemplateHTML(renderWikiLinks(stringifyWikiTemplatePlain(value))), nil
case "lower":
return strings.ToLower(stringifyWikiTemplatePlain(value)), nil
case "title":
return strings.Title(stringifyWikiTemplatePlain(value)), nil
case "sentence":
text := stringifyWikiTemplatePlain(value)
for i, r := range text {
if unicode.IsLetter(r) {
return text[:i] + string(unicode.ToUpper(r)) + text[i+len(string(r)):], nil
}
}
return text, nil
case "join":
return joinWikiTemplateValue(value, firstArg(args)), nil
case "ordinal":
return ordinalWikiTableValue(numericWikiTableValue(value)), nil
case "default":
if strings.TrimSpace(stringifyWikiTemplatePlain(value)) == "" {
return firstArg(args), nil
}
return value, nil
case "ref":
return ctx.renderTemplateReference(value, firstArg(args)), nil
case "link":
return ctx.renderTemplateLink(value, firstArg(args), state), nil
default:
return nil, fmt.Errorf("render wiki template %s for %s: unknown filter %q", state.path, state.page.PageID, name)
}
}
func parseWikiTemplateFilterArgs(raw string) ([]string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
args := []string{}
for len(raw) > 0 {
raw = strings.TrimSpace(raw)
if !strings.HasPrefix(raw, `"`) {
if idx := strings.Index(raw, ","); idx >= 0 {
args = append(args, strings.TrimSpace(raw[:idx]))
raw = raw[idx+1:]
continue
}
args = append(args, strings.TrimSpace(raw))
break
}
var b strings.Builder
escaped := false
i := 1
for ; i < len(raw); i++ {
if escaped {
b.WriteByte(raw[i])
escaped = false
continue
}
if raw[i] == '\\' {
escaped = true
continue
}
if raw[i] == '"' {
break
}
b.WriteByte(raw[i])
}
if i >= len(raw) {
return nil, fmt.Errorf("unterminated quoted argument")
}
args = append(args, b.String())
raw = strings.TrimSpace(raw[i+1:])
if strings.HasPrefix(raw, ",") {
raw = raw[1:]
} else if raw != "" {
return nil, fmt.Errorf("unexpected argument text %q", raw)
}
}
return args, nil
}
func firstArg(args []string) string {
if len(args) == 0 {
return ""
}
return args[0]
}
func secondArg(args []string) string {
if len(args) < 2 {
return ""
}
return args[1]
}
func afterFirstMarker(text, marker string) string {
if marker == "" {
return text
}
idx := strings.Index(text, marker)
if idx < 0 {
return ""
}
return text[idx+len(marker):]
}
func beforeFirstMarker(text, marker string) string {
if marker == "" {
return text
}
idx := strings.Index(text, marker)
if idx < 0 {
return text
}
return text[:idx]
}
func selectWikiTemplateParagraphs(text, selector string) string {
paragraphs := splitWikiTemplateParagraphs(text)
if len(paragraphs) == 0 {
return ""
}
switch strings.TrimSpace(selector) {
case "first":
return paragraphs[0]
case "last":
return paragraphs[len(paragraphs)-1]
case "all", "":
return strings.Join(paragraphs, "\n\n")
default:
selected := []string{}
for _, part := range strings.Split(selector, ",") {
switch strings.TrimSpace(part) {
case "first":
selected = append(selected, paragraphs[0])
case "last":
selected = append(selected, paragraphs[len(paragraphs)-1])
default:
if idx, err := strconv.Atoi(strings.TrimSpace(part)); err == nil && idx > 0 && idx <= len(paragraphs) {
selected = append(selected, paragraphs[idx-1])
}
}
}
return strings.Join(selected, "\n\n")
}
}
func splitWikiTemplateParagraphs(text string) []string {
normalized := strings.ReplaceAll(text, "\r\n", "\n")
chunks := strings.Split(normalized, "\n\n")
out := []string{}
for _, chunk := range chunks {
if trimmed := strings.TrimSpace(chunk); trimmed != "" {
out = append(out, trimmed)
}
}
return out
}
func renderWikiTemplatePlainHTML(text string) string {
paragraphs := splitWikiTemplateParagraphs(text)
if len(paragraphs) == 0 {
return ""
}
out := strings.Builder{}
for _, paragraph := range paragraphs {
escaped := html.EscapeString(paragraph)
escaped = strings.ReplaceAll(escaped, "\n", "<br>")
out.WriteString("<p>")
out.WriteString(escaped)
out.WriteString("</p>")
}
return out.String()
}
func joinWikiTemplateValue(value any, sep string) string {
switch typed := value.(type) {
case []string:
return strings.Join(typed, sep)
case []any:
parts := make([]string, 0, len(typed))
for _, item := range typed {
if text := stringifyWikiTemplatePlain(item); text != "" {
parts = append(parts, text)
}
}
return strings.Join(parts, sep)
default:
return stringifyWikiTemplatePlain(value)
}
}
func (ctx *wikiContext) renderTemplateReference(value any, dataset string) string {
return ctx.renderReference(value, ctx.wikiIDMapForDataset(dataset), ctx.wikiNameResolverForDataset(dataset))
}
func (ctx *wikiContext) wikiNameResolverForDataset(dataset string) func(string) string {
switch dataset {
case "classes":
return ctx.resolveClassName
case "feat":
return ctx.resolveFeatName
case "skills":
return ctx.resolveSkillName
default:
return func(string) string { return "" }
}
}
func (ctx *wikiContext) renderTemplateLink(value any, labelField string, state *wikiTemplateRenderState) string {
key := stringifyWikiTemplatePlain(value)
label := labelField
if state.row != nil {
if raw, ok := lookupField(state.row, labelField); ok {
label = stringifyWikiTemplatePlain(raw)
}
}
if label == "" {
label = key
}
if key == "" {
return label
}
if !ctx.canReferenceWikiKey(key) {
return ""
}
if target := ctx.publicWikiTargetForKey(key, label); target != "" {
key = target
}
return "[[" + key + "|" + label + "]]"
}
func stringifyWikiTemplateOutput(value any) string {
if htmlValue, ok := value.(wikiTemplateHTML); ok {
return string(htmlValue)
}
return html.EscapeString(stringifyWikiTemplatePlain(value))
}
func stringifyWikiTemplatePlain(value any) string {
if htmlValue, ok := value.(wikiTemplateHTML); ok {
return string(htmlValue)
}
return stringifyWikiTableValue(value)
}