diff --git a/internal/topdata/wiki_data_providers.go b/internal/topdata/wiki_data_providers.go
new file mode 100644
index 0000000..05d2798
--- /dev/null
+++ b/internal/topdata/wiki_data_providers.go
@@ -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
+}
diff --git a/internal/topdata/wiki_native.go b/internal/topdata/wiki_native.go
index 1a7a425..db7ac1e 100644
--- a/internal/topdata/wiki_native.go
+++ b/internal/topdata/wiki_native.go
@@ -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
diff --git a/internal/topdata/wiki_native_test.go b/internal/topdata/wiki_native_test.go
index c1532d5..baf9158 100644
--- a/internal/topdata/wiki_native_test.go
+++ b/internal/topdata/wiki_native_test.go
@@ -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 := `
+
+ | Lvl | BAB | HP |
+
+
+ {{#each ClassProgression}}
+ | {{Level|ordinal}} | {{BAB}} | {{HPMin}}-{{HPMax}} |
+ {{/each}}
+
+
`
+
+ got, err := ctx.renderWikiTemplateString("classes.html", template, page)
+ if err != nil {
+ t.Fatalf("render template: %v", err)
+ }
+ for _, want := range []string{
+ ``,
+ `| 1st | +1 | 1-10 |
`,
+ `| 2nd | +2 | 2-20 |
`,
+ } {
+ 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 := `
+
+ | Ability Adjustments | {{ability_adjustments()}} |
+ {{#if present(Favored)}}
+ | Favored Class | {{Favored|ref:"classes"|wiki}} |
+ {{/if}}
+
+
+
+{{#each RaceFeats}}
+ - {{FeatKey|link:FeatName|wiki}}
+{{/each}}
+
`
+
+ got, err := ctx.renderWikiTemplateString("racialtypes.html", template, page)
+ if err != nil {
+ t.Fatalf("render template: %v", err)
+ }
+ for _, want := range []string{
+ `Ability Adjustments | +2 Wisdom, +2 Charisma | `,
+ `Favored Class | [[classes/paladin|Paladin]] | `,
+ `[[feat/darkvision|Darkvision]]`,
+ `[[feat/daylight-aasimar|Daylight (aasimar)]]`,
+ } {
+ 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: `{{DESCRIPTION|text|paragraph:first}}
`,
+ want: `Type of Feat: General
`,
+ reject: `Prerequisite`,
+ },
+ {
+ name: "specifics after marker as html",
+ template: `{{DESCRIPTION|text|after:"Specifics:"|before:"Use:"|trim|html}}`,
+ want: `You gain a +2 dodge bonus.
The bonus applies only while unarmored.
`,
+ reject: `Use: Automatic`,
+ },
+ {
+ name: "first and last paragraphs",
+ template: `{{DESCRIPTION|text|paragraphs:"first,last"|html}}
`,
+ want: `Type of Feat: General
Use: Automatic.
`,
+ 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:
diff --git a/internal/topdata/wiki_tables.go b/internal/topdata/wiki_tables.go
index 8a8c377..093bf61 100644
--- a/internal/topdata/wiki_tables.go
+++ b/internal/topdata/wiki_tables.go
@@ -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")
diff --git a/internal/topdata/wiki_template.go b/internal/topdata/wiki_template.go
index 46bda2c..085ff2e 100644
--- a/internal/topdata/wiki_template.go
+++ b/internal/topdata/wiki_template.go
@@ -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
diff --git a/internal/topdata/wiki_template_expr.go b/internal/topdata/wiki_template_expr.go
new file mode 100644
index 0000000..a7d9f96
--- /dev/null
+++ b/internal/topdata/wiki_template_expr.go
@@ -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", "
")), 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", "
")
+ out.WriteString("")
+ out.WriteString(escaped)
+ out.WriteString("
")
+ }
+ 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)
+}