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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user