Phase 1 Wiki Template refactor
This commit is contained in:
@@ -702,6 +702,7 @@ func computeManagedHash(content string) string {
|
||||
if !ok {
|
||||
managed = content
|
||||
}
|
||||
managed = normalizePreservedSectionBodies(managed)
|
||||
h := sha256.Sum256([]byte(strings.TrimSpace(managed)))
|
||||
return fmt.Sprintf("%x", h[:])
|
||||
}
|
||||
|
||||
@@ -147,6 +147,49 @@ func TestDeployWikiCreatesNodeBBTopicAndWritesManifest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeManagedContentPreservesUserTopAndBottomSections(t *testing.T) {
|
||||
existing := strings.Join([]string{
|
||||
`<!-- sow-topdata-wiki:page=classes:acolyte -->`,
|
||||
`<!-- sow-topdata-wiki:managed:start hash="sha256:old" -->`,
|
||||
`<h1>Acolyte</h1>`,
|
||||
`<!-- sow-topdata-wiki:manual:start id="user_top" -->`,
|
||||
`<p>User-written overview.</p>`,
|
||||
`<!-- sow-topdata-wiki:manual:end id="user_top" -->`,
|
||||
`<p>Old generated body.</p>`,
|
||||
`<!-- sow-topdata-wiki:manual:start id="notes" -->`,
|
||||
`<h2>Notes</h2><p>User note.</p>`,
|
||||
`<!-- sow-topdata-wiki:manual:end id="notes" -->`,
|
||||
`<!-- sow-topdata-wiki:managed:end -->`,
|
||||
}, "\n")
|
||||
generated := strings.Join([]string{
|
||||
`<!-- sow-topdata-wiki:page=classes:acolyte -->`,
|
||||
`<!-- sow-topdata-wiki:managed:start hash="sha256:new" -->`,
|
||||
`<h1>Acolyte</h1>`,
|
||||
`<!-- sow-topdata-wiki:manual:start id="user_top" -->`,
|
||||
`<p></p>`,
|
||||
`<!-- sow-topdata-wiki:manual:end id="user_top" -->`,
|
||||
`<p>New generated body.</p>`,
|
||||
`<!-- sow-topdata-wiki:manual:start id="notes" -->`,
|
||||
`<h2>Notes</h2><p></p>`,
|
||||
`<!-- sow-topdata-wiki:manual:end id="notes" -->`,
|
||||
`<!-- sow-topdata-wiki:managed:end -->`,
|
||||
}, "\n")
|
||||
|
||||
merged := mergeManagedContent(existing, generated)
|
||||
for _, expected := range []string{
|
||||
"<p>User-written overview.</p>",
|
||||
"<h2>Notes</h2><p>User note.</p>",
|
||||
"<p>New generated body.</p>",
|
||||
} {
|
||||
if !strings.Contains(merged, expected) {
|
||||
t.Fatalf("expected merged content to contain %q:\n%s", expected, merged)
|
||||
}
|
||||
}
|
||||
if strings.Contains(merged, "Old generated body") {
|
||||
t.Fatalf("expected old generated body to be replaced:\n%s", merged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployWikiCreatesNodeBBTopicWithoutFallbackForDefaultThreeCharacterTitle(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sourceDir := filepath.Join(root, "pages")
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (ctx *wikiContext) renderWikiFormatter(spec string, page wikiTemplatePage) (string, error) {
|
||||
name := strings.Fields(spec)
|
||||
if len(name) == 0 {
|
||||
return "", fmt.Errorf("render wiki template for %s: empty formatter", page.PageID)
|
||||
}
|
||||
switch name[0] {
|
||||
case "Description":
|
||||
return dokuWikiToNodeBBHTML(toDokuWiki(ctx.resolveRowDescription(page.Category, page.Row))), nil
|
||||
case "FactsTable":
|
||||
return dokuWikiToNodeBBHTML(ctx.renderFacts(page.Category, page.Key, page.Row)), nil
|
||||
case "Prerequisites":
|
||||
return dokuWikiToNodeBBHTML(formatFact("Prerequisites", ctx.renderFeatPrerequisites(page.Row))), nil
|
||||
case "SkillList":
|
||||
if page.Category != "classes" {
|
||||
return "", nil
|
||||
}
|
||||
return dokuWikiToNodeBBHTML(ctx.renderClassSkillList(page.Row)), nil
|
||||
case "ClassFeatureTable":
|
||||
if page.Category != "classes" {
|
||||
return "", nil
|
||||
}
|
||||
return dokuWikiToNodeBBHTML(ctx.renderClassFeatureTable(page.Row)), nil
|
||||
case "FeatProgression":
|
||||
if page.Category != "classes" {
|
||||
return "", nil
|
||||
}
|
||||
return dokuWikiToNodeBBHTML(ctx.renderClassFeatProgression(page.Row)), nil
|
||||
case "SpellTable", "SavesProgression", "BABProgression":
|
||||
return "", nil
|
||||
case "RaceFeatList":
|
||||
return dokuWikiToNodeBBHTML(formatFact("Racial Feats", ctx.renderRaceFeatList(page.Row))), nil
|
||||
case "StatusCallout":
|
||||
return buildWikiStatusBlock(page.Status, page.Category), nil
|
||||
case "RaceNameForms":
|
||||
return dokuWikiToNodeBBHTML(ctx.renderRaceNameForms(page.Row)), nil
|
||||
case "UserBottomFallback":
|
||||
return "", nil
|
||||
default:
|
||||
return "", fmt.Errorf("render wiki template for %s: unknown formatter %q", page.PageID, name[0])
|
||||
}
|
||||
}
|
||||
|
||||
func (ctx *wikiContext) renderClassSkillList(row map[string]any) string {
|
||||
table := ctx.tableForValue(fieldValue(row, "SkillsTable"), ctx.classSkillTables)
|
||||
if table == nil {
|
||||
return ""
|
||||
}
|
||||
skills := []string{}
|
||||
for _, skillRow := range table.Rows {
|
||||
if stringValue(skillRow, "ClassSkill") != "1" {
|
||||
continue
|
||||
}
|
||||
name := ctx.renderReference(fieldValue(skillRow, "SkillIndex"), ctx.skillIDToKey, ctx.resolveSkillName)
|
||||
if name != "" {
|
||||
skills = append(skills, name)
|
||||
}
|
||||
}
|
||||
if len(skills) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "==== Class Skills ====\n\n * " + strings.Join(skills, ", ")
|
||||
}
|
||||
|
||||
func (ctx *wikiContext) renderClassFeatProgression(row map[string]any) string {
|
||||
table := ctx.tableForValue(fieldValue(row, "FeatsTable"), ctx.classFeatTables)
|
||||
if table == nil {
|
||||
return ""
|
||||
}
|
||||
lines := []string{}
|
||||
byLevel := map[string][]string{}
|
||||
selectable := []string{}
|
||||
for _, featRow := range table.Rows {
|
||||
name := ctx.renderReference(fieldValue(featRow, "FeatIndex"), ctx.featIDToKey, ctx.resolveFeatName)
|
||||
if name == "" {
|
||||
name = ctx.renderReference(fieldValue(featRow, "FeatIndex"), nil, func(string) string { return "" })
|
||||
}
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
level := stringValue(featRow, "GrantedOnLevel")
|
||||
if level == "" || strings.HasPrefix(level, "-") {
|
||||
selectable = append(selectable, name)
|
||||
continue
|
||||
}
|
||||
byLevel[level] = append(byLevel[level], name)
|
||||
}
|
||||
if len(byLevel) > 0 {
|
||||
lines = append(lines, "==== Granted Feats ====", "")
|
||||
for _, level := range sortedKeys(byLevel) {
|
||||
lines = append(lines, " * Level "+level+": "+strings.Join(byLevel[level], ", "))
|
||||
}
|
||||
lines = append(lines, "")
|
||||
}
|
||||
if len(selectable) > 0 {
|
||||
lines = append(lines, "==== Selectable Feats ====", "", " * "+strings.Join(selectable, ", "), "")
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func (ctx *wikiContext) renderClassFeatureTable(row map[string]any) string {
|
||||
table := ctx.tableForValue(fieldValue(row, "BonusFeatsTable"), ctx.classBonusTables)
|
||||
if table == nil || len(table.Rows) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "==== Bonus Feats ====\n\n * Bonus feat table: " + table.OutputStem
|
||||
}
|
||||
@@ -26,7 +26,7 @@ const (
|
||||
legacyWikiRootDirName = ".wiki"
|
||||
wikiPagesDirName = "pages"
|
||||
wikiStateFileName = "state.json"
|
||||
wikiGeneratorVersion = "nodebb-tiptap-html-v1"
|
||||
wikiGeneratorVersion = "nodebb-tiptap-html-v2"
|
||||
wikiGeneratedStatus = "generated"
|
||||
wikiSkippedStatus = "skipped"
|
||||
)
|
||||
@@ -68,6 +68,7 @@ type wikiManualSectionsDocument struct {
|
||||
|
||||
type wikiManualSection struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Alias string `json:"alias" yaml:"alias"`
|
||||
Heading string `json:"heading" yaml:"heading"`
|
||||
Placement string `json:"placement" yaml:"placement"`
|
||||
InitialHTML string `json:"initial_html" yaml:"initial_html"`
|
||||
@@ -107,6 +108,8 @@ type wikiContext struct {
|
||||
raceFeatTables map[string]wikiTable
|
||||
statuses map[string]map[string]string
|
||||
implementedFeats map[string]struct{}
|
||||
manualSections []wikiManualSection
|
||||
templateDir string
|
||||
}
|
||||
|
||||
func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progress func(string)) (wikiResult, error) {
|
||||
@@ -137,6 +140,9 @@ func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progres
|
||||
if err != nil {
|
||||
return wikiResult{}, err
|
||||
}
|
||||
manualSections := loadWikiManualSections(p)
|
||||
ctx.manualSections = manualSections
|
||||
ctx.templateDir = filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.TemplatesDir), "pages")
|
||||
|
||||
if err := os.RemoveAll(outputDir); err != nil {
|
||||
return wikiResult{}, fmt.Errorf("clean wiki output: %w", err)
|
||||
@@ -154,7 +160,7 @@ func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progres
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
content = renderNodeBBManagedHTML(pageID, content, loadWikiManualSections(p))
|
||||
content = renderNodeBBManagedHTML(pageID, content, manualSections)
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -182,7 +188,7 @@ func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progres
|
||||
if err := generateEntityPages(outputDir, ctx, writePage); err != nil {
|
||||
return wikiResult{}, err
|
||||
}
|
||||
if err := generateStatusPages(outputDir, pageStatuses, pageTitles, p.EffectiveConfig().TopData.Wiki.ManagedNamespaces, loadWikiManualSections(p)); err != nil {
|
||||
if err := generateStatusPages(outputDir, pageStatuses, pageTitles, p.EffectiveConfig().TopData.Wiki.ManagedNamespaces, manualSections); err != nil {
|
||||
return wikiResult{}, err
|
||||
}
|
||||
if err := indexUnregisteredWikiPages(rootDir, outputDir, p.EffectiveConfig().TopData.Wiki.StalePages.Default, &pageIndex); err != nil {
|
||||
@@ -209,10 +215,7 @@ func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progres
|
||||
}
|
||||
|
||||
func loadWikiManualSections(p *project.Project) []wikiManualSection {
|
||||
defaults := []wikiManualSection{
|
||||
{ID: "notes", Heading: "Notes", InitialHTML: "<h2>Notes</h2><p></p>"},
|
||||
{ID: "see_also", Heading: "See Also", InitialHTML: "<h2>See Also</h2><p></p>"},
|
||||
}
|
||||
defaults := defaultWikiManualSections()
|
||||
cfg := p.EffectiveConfig().TopData.Wiki
|
||||
path := filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(cfg.ManualSectionsDir), "default.yaml")
|
||||
raw, err := os.ReadFile(path)
|
||||
@@ -229,6 +232,15 @@ func loadWikiManualSections(p *project.Project) []wikiManualSection {
|
||||
if section.ID == "" {
|
||||
continue
|
||||
}
|
||||
section.Alias = strings.TrimSpace(section.Alias)
|
||||
if section.Alias == "" {
|
||||
switch section.ID {
|
||||
case "user_top":
|
||||
section.Alias = "UserTopSection"
|
||||
case "notes":
|
||||
section.Alias = "UserBottomSection"
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(section.InitialHTML) == "" {
|
||||
heading := strings.TrimSpace(section.Heading)
|
||||
if heading == "" {
|
||||
@@ -358,13 +370,15 @@ func computeWikiSourceDigest(sourceDir string) (string, error) {
|
||||
filepath.Join(sourceDir, "data", "baseitems"),
|
||||
filepath.Join(sourceDir, "data", "classes"),
|
||||
filepath.Join(sourceDir, "data", "racialtypes"),
|
||||
filepath.Join(sourceDir, "wiki"),
|
||||
}
|
||||
for _, dir := range relevantSourceDirs {
|
||||
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
if strings.EqualFold(filepath.Ext(path), ".json") {
|
||||
switch strings.ToLower(filepath.Ext(path)) {
|
||||
case ".json", ".yaml", ".yml", ".html":
|
||||
files = append(files, path)
|
||||
}
|
||||
return nil
|
||||
@@ -760,20 +774,20 @@ func (ctx *wikiContext) shouldGeneratePage(category, key string, row map[string]
|
||||
}
|
||||
|
||||
func (ctx *wikiContext) renderPage(category, key string, row map[string]any, title string) (string, error) {
|
||||
sections := []string{
|
||||
"====== " + title + " ======",
|
||||
"",
|
||||
buildWikiStatusBlock(ctx.pageStatus(category, key, row), category),
|
||||
"",
|
||||
toDokuWiki(ctx.resolveRowDescription(category, row)),
|
||||
"",
|
||||
ctx.renderFacts(category, key, row),
|
||||
"",
|
||||
ctx.renderProgression(category, row),
|
||||
"",
|
||||
ctx.renderRaceNameForms(row),
|
||||
manualSections := ctx.manualSections
|
||||
if len(manualSections) == 0 {
|
||||
manualSections = defaultWikiManualSections()
|
||||
}
|
||||
return ensureTrailingNewline(strings.Join(sections, "\n")), nil
|
||||
page := wikiTemplatePage{
|
||||
PageID: wikiPageIDForKey(key),
|
||||
Category: category,
|
||||
Key: key,
|
||||
Title: title,
|
||||
Status: ctx.pageStatus(category, key, row),
|
||||
Row: row,
|
||||
ManualSections: manualSections,
|
||||
}
|
||||
return ctx.renderWikiPageTemplate(category, page)
|
||||
}
|
||||
|
||||
func (ctx *wikiContext) renderFacts(category, key string, row map[string]any) string {
|
||||
@@ -1787,11 +1801,13 @@ func renderNodeBBManagedHTML(pageID, sourceText string, manualSections []wikiMan
|
||||
strings.TrimSpace(body),
|
||||
"<!-- sow-topdata-wiki:managed:end -->",
|
||||
}
|
||||
renderedSections := extractManualRegions(body)
|
||||
for _, section := range manualSections {
|
||||
if _, ok := renderedSections[section.ID]; ok {
|
||||
continue
|
||||
}
|
||||
parts = append(parts,
|
||||
"<!-- sow-topdata-wiki:manual:start id=\""+html.EscapeString(section.ID)+"\" -->",
|
||||
strings.TrimSpace(section.InitialHTML),
|
||||
"<!-- sow-topdata-wiki:manual:end id=\""+html.EscapeString(section.ID)+"\" -->",
|
||||
renderWikiManualSection(section),
|
||||
)
|
||||
}
|
||||
return ensureTrailingNewline(strings.Join(parts, "\n"))
|
||||
|
||||
@@ -141,6 +141,111 @@ func TestWikiClassNamePrefersFullNameInsteadOfShortCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiTemplateRendersFieldsFormattersAndPreservedTopSection(t *testing.T) {
|
||||
ctx := &wikiContext{}
|
||||
page := wikiTemplatePage{
|
||||
PageID: "classes:acolyte",
|
||||
Category: "classes",
|
||||
Key: "classes:acolyte",
|
||||
Title: "Acolyte",
|
||||
Status: wikiStatusNew,
|
||||
Row: map[string]any{
|
||||
"HitDie": 6,
|
||||
"SkillPointBase": 4,
|
||||
"Description": map[string]any{"tlk": map[string]any{
|
||||
"text": "Acolyte description.",
|
||||
}},
|
||||
},
|
||||
ManualSections: []wikiManualSection{
|
||||
{ID: "user_top", Alias: "UserTopSection", InitialHTML: "<p></p>"},
|
||||
{ID: "notes", Alias: "UserBottomSection", InitialHTML: "<h2>Notes</h2><p></p>"},
|
||||
},
|
||||
}
|
||||
|
||||
got, err := ctx.renderWikiTemplateString("classes.html", `<h1>{{title}}</h1>
|
||||
{{UserTopSection}}
|
||||
<p>Hit Die: {{HitDie}}</p>
|
||||
<p>Skill Points: {{SkillPointBase}}</p>
|
||||
{{format:StatusCallout}}
|
||||
{{format:Description}}
|
||||
{{UserBottomSection}}`, page)
|
||||
if err != nil {
|
||||
t.Fatalf("render template: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(got, "<h1>Acolyte</h1>\n<!-- sow-topdata-wiki:manual:start id=\"user_top\" -->") {
|
||||
t.Fatalf("expected preserved top section directly after title, got:\n%s", got)
|
||||
}
|
||||
for _, expected := range []string{
|
||||
"<p>Hit Die: 6</p>",
|
||||
"<p>Skill Points: 4</p>",
|
||||
"This is a new class!",
|
||||
"<p>Acolyte description.</p>",
|
||||
`<!-- sow-topdata-wiki:manual:start id="notes" -->`,
|
||||
} {
|
||||
if !strings.Contains(got, expected) {
|
||||
t.Fatalf("expected %q in rendered template:\n%s", expected, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiTemplateMissingFieldFailsUnlessDefaultProvided(t *testing.T) {
|
||||
ctx := &wikiContext{}
|
||||
page := wikiTemplatePage{PageID: "classes:acolyte", Category: "classes", Title: "Acolyte", Row: map[string]any{}}
|
||||
|
||||
if _, err := ctx.renderWikiTemplateString("classes.html", `<p>{{HitDie}}</p>`, page); err == nil || !strings.Contains(err.Error(), `missing field "HitDie"`) {
|
||||
t.Fatalf("expected missing field error, got %v", err)
|
||||
}
|
||||
|
||||
got, err := ctx.renderWikiTemplateString("classes.html", `<p>{{field:HitDie|default=unknown}}</p>`, page)
|
||||
if err != nil {
|
||||
t.Fatalf("expected defaulted field to render, got %v", err)
|
||||
}
|
||||
if got != "<p>unknown</p>" {
|
||||
t.Fatalf("expected default value, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiManagedHashIgnoresPreservedSectionBody(t *testing.T) {
|
||||
first := strings.Join([]string{
|
||||
`<!-- sow-topdata-wiki:page=classes:acolyte -->`,
|
||||
`<!-- sow-topdata-wiki:managed:start hash="sha256:first" -->`,
|
||||
`<h1>Acolyte</h1>`,
|
||||
`<!-- sow-topdata-wiki:manual:start id="user_top" -->`,
|
||||
`<p>Original user edit.</p>`,
|
||||
`<!-- sow-topdata-wiki:manual:end id="user_top" -->`,
|
||||
`<p>Generated.</p>`,
|
||||
`<!-- sow-topdata-wiki:managed:end -->`,
|
||||
}, "\n")
|
||||
second := strings.Replace(first, "<p>Original user edit.</p>", "<p>Changed by a user.</p>", 1)
|
||||
|
||||
if computeManagedHash(first) != computeManagedHash(second) {
|
||||
t.Fatalf("expected preserved section body not to affect managed hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiSourceDigestIncludesTemplates(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mkdirAll(t, filepath.Join(root, "data", "skills"))
|
||||
mkdirAll(t, filepath.Join(root, "wiki", "templates", "pages"))
|
||||
writeFile(t, filepath.Join(root, "data", "skills", "base.json"), `{"rows":[]}`+"\n")
|
||||
templatePath := filepath.Join(root, "wiki", "templates", "pages", "skills.html")
|
||||
writeFile(t, templatePath, `<h1>{{title}}</h1>`+"\n")
|
||||
|
||||
before, err := computeWikiSourceDigest(root)
|
||||
if err != nil {
|
||||
t.Fatalf("compute initial digest: %v", err)
|
||||
}
|
||||
writeFile(t, templatePath, `<h1>{{title}}</h1><p>Changed</p>`+"\n")
|
||||
after, err := computeWikiSourceDigest(root)
|
||||
if err != nil {
|
||||
t.Fatalf("compute changed digest: %v", err)
|
||||
}
|
||||
if before == after {
|
||||
t.Fatalf("expected template edit to change wiki source digest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiPageIDForKeyNormalizesSlashAndUnderscoreSeparators(t *testing.T) {
|
||||
got := wikiPageIDForKey("feat:special/attacks_bull_rush")
|
||||
if want := "feat:special:attacks:bull:rush"; got != want {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package topdata
|
||||
|
||||
import "strings"
|
||||
|
||||
func normalizePreservedSectionBodies(content string) string {
|
||||
var out strings.Builder
|
||||
offset := 0
|
||||
for {
|
||||
startRel := strings.Index(content[offset:], manualStartMarkerPrefix)
|
||||
if startRel < 0 {
|
||||
out.WriteString(content[offset:])
|
||||
break
|
||||
}
|
||||
start := offset + startRel
|
||||
startEndRel := strings.Index(content[start:], "-->")
|
||||
if startEndRel < 0 {
|
||||
out.WriteString(content[offset:])
|
||||
break
|
||||
}
|
||||
startMarker := content[start : start+startEndRel+len("-->")]
|
||||
id := markerID(startMarker)
|
||||
if id == "" {
|
||||
out.WriteString(content[offset : start+len(startMarker)])
|
||||
offset = start + len(startMarker)
|
||||
continue
|
||||
}
|
||||
endPrefix := manualEndMarkerPrefix + " id=\"" + id + "\""
|
||||
endRel := strings.Index(content[start+len(startMarker):], endPrefix)
|
||||
if endRel < 0 {
|
||||
out.WriteString(content[offset:])
|
||||
break
|
||||
}
|
||||
end := start + len(startMarker) + endRel
|
||||
endEndRel := strings.Index(content[end:], "-->")
|
||||
if endEndRel < 0 {
|
||||
out.WriteString(content[offset:])
|
||||
break
|
||||
}
|
||||
endMarker := content[end : end+endEndRel+len("-->")]
|
||||
out.WriteString(content[offset:start])
|
||||
out.WriteString(startMarker)
|
||||
out.WriteString("\n<!-- preserved-section-body -->\n")
|
||||
out.WriteString(endMarker)
|
||||
offset = end + len(endMarker)
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func defaultWikiManualSections() []wikiManualSection {
|
||||
return []wikiManualSection{
|
||||
{ID: "user_top", Alias: "UserTopSection", Heading: "", InitialHTML: "<p></p>"},
|
||||
{ID: "notes", Alias: "UserBottomSection", Heading: "Notes", InitialHTML: "<h2>Notes</h2><p></p>"},
|
||||
{ID: "see_also", Heading: "See Also", InitialHTML: "<h2>See Also</h2><p></p>"},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type wikiTemplatePage struct {
|
||||
PageID string
|
||||
Category string
|
||||
Key string
|
||||
Title string
|
||||
Status string
|
||||
Row map[string]any
|
||||
ManualSections []wikiManualSection
|
||||
}
|
||||
|
||||
func (ctx *wikiContext) renderWikiPageTemplate(category string, page wikiTemplatePage) (string, error) {
|
||||
name := wikiTemplateName(category)
|
||||
source, path := ctx.loadWikiTemplateSource(name)
|
||||
if source == "" && name != "default.html" {
|
||||
source, path = ctx.loadWikiTemplateSource("default.html")
|
||||
}
|
||||
if source == "" {
|
||||
source = builtinWikiTemplate(category)
|
||||
path = "builtin:" + name
|
||||
}
|
||||
return ctx.renderWikiTemplateString(path, source, page)
|
||||
}
|
||||
|
||||
func wikiTemplateName(category string) string {
|
||||
switch category {
|
||||
case "classes", "feat", "skills", "spells", "racialtypes", "baseitems":
|
||||
return category + ".html"
|
||||
default:
|
||||
return "default.html"
|
||||
}
|
||||
}
|
||||
|
||||
func (ctx *wikiContext) loadWikiTemplateSource(name string) (string, string) {
|
||||
if strings.TrimSpace(ctx.templateDir) == "" {
|
||||
return "", ""
|
||||
}
|
||||
path := filepath.Join(ctx.templateDir, filepath.FromSlash(name))
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", path
|
||||
}
|
||||
return string(raw), path
|
||||
}
|
||||
|
||||
func builtinWikiTemplate(category string) string {
|
||||
switch category {
|
||||
case "classes":
|
||||
return `<h1>{{title}}</h1>
|
||||
{{UserTopSection}}
|
||||
{{format:StatusCallout}}
|
||||
{{format:Description}}
|
||||
{{format:FactsTable}}
|
||||
{{format:SkillList}}
|
||||
{{format:FeatProgression}}
|
||||
{{format:UserBottomFallback}}`
|
||||
case "racialtypes":
|
||||
return `<h1>{{title}}</h1>
|
||||
{{UserTopSection}}
|
||||
{{format:StatusCallout}}
|
||||
{{format:Description}}
|
||||
{{format:FactsTable}}
|
||||
{{format:RaceNameForms}}
|
||||
{{format:UserBottomFallback}}`
|
||||
default:
|
||||
return `<h1>{{title}}</h1>
|
||||
{{UserTopSection}}
|
||||
{{format:StatusCallout}}
|
||||
{{format:Description}}
|
||||
{{format:FactsTable}}
|
||||
{{format:UserBottomFallback}}`
|
||||
}
|
||||
}
|
||||
|
||||
func (ctx *wikiContext) renderWikiTemplateString(path, source string, page wikiTemplatePage) (string, error) {
|
||||
var out strings.Builder
|
||||
offset := 0
|
||||
seenSections := map[string]struct{}{}
|
||||
for {
|
||||
start := strings.Index(source[offset:], "{{")
|
||||
if start < 0 {
|
||||
out.WriteString(source[offset:])
|
||||
break
|
||||
}
|
||||
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)
|
||||
}
|
||||
end += start + 2
|
||||
out.WriteString(source[offset:start])
|
||||
token := strings.TrimSpace(source[start+2 : end])
|
||||
rendered, sectionID, err := ctx.renderWikiTemplateToken(path, token, page)
|
||||
if err != nil {
|
||||
return "", 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)
|
||||
}
|
||||
seenSections[sectionID] = struct{}{}
|
||||
}
|
||||
out.WriteString(rendered)
|
||||
offset = end + 2
|
||||
}
|
||||
return strings.TrimSpace(out.String()), nil
|
||||
}
|
||||
|
||||
func (ctx *wikiContext) renderWikiTemplateToken(path, token string, page wikiTemplatePage) (string, string, error) {
|
||||
switch token {
|
||||
case "title":
|
||||
return html.EscapeString(page.Title), "", nil
|
||||
case "page_id":
|
||||
return html.EscapeString(page.PageID), "", nil
|
||||
case "status":
|
||||
return html.EscapeString(page.Status), "", nil
|
||||
}
|
||||
if section, ok := wikiManualSectionForAlias(page.ManualSections, token); ok {
|
||||
return renderWikiManualSection(section), section.ID, nil
|
||||
}
|
||||
if strings.HasPrefix(token, "format:") {
|
||||
rendered, err := ctx.renderWikiFormatter(strings.TrimSpace(strings.TrimPrefix(token, "format:")), page)
|
||||
return rendered, "", err
|
||||
}
|
||||
if strings.HasPrefix(token, "field:") {
|
||||
rendered, err := ctx.renderExplicitWikiField(path, strings.TrimSpace(strings.TrimPrefix(token, "field:")), page)
|
||||
return rendered, "", err
|
||||
}
|
||||
rendered, err := ctx.renderRequiredWikiField(path, token, page)
|
||||
return rendered, "", err
|
||||
}
|
||||
|
||||
func wikiManualSectionForAlias(sections []wikiManualSection, alias string) (wikiManualSection, bool) {
|
||||
for _, section := range sections {
|
||||
if section.Alias == alias {
|
||||
return section, true
|
||||
}
|
||||
}
|
||||
switch alias {
|
||||
case "UserTopSection":
|
||||
for _, section := range sections {
|
||||
if section.ID == "user_top" {
|
||||
return section, true
|
||||
}
|
||||
}
|
||||
case "UserBottomSection":
|
||||
for _, section := range sections {
|
||||
if section.ID == "notes" {
|
||||
return section, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return wikiManualSection{}, false
|
||||
}
|
||||
|
||||
func renderWikiManualSection(section wikiManualSection) string {
|
||||
return strings.Join([]string{
|
||||
"<!-- sow-topdata-wiki:manual:start id=\"" + html.EscapeString(section.ID) + "\" -->",
|
||||
strings.TrimSpace(section.InitialHTML),
|
||||
"<!-- sow-topdata-wiki:manual:end id=\"" + html.EscapeString(section.ID) + "\" -->",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func (ctx *wikiContext) renderExplicitWikiField(path, spec string, page wikiTemplatePage) (string, error) {
|
||||
fieldSpec, defaultValue, hasDefault := strings.Cut(spec, "|default=")
|
||||
fieldSpec = strings.TrimSpace(fieldSpec)
|
||||
value, ok := ctx.resolveWikiTemplateField(fieldSpec, page)
|
||||
if !ok {
|
||||
if hasDefault {
|
||||
return html.EscapeString(defaultValue), nil
|
||||
}
|
||||
return "", fmt.Errorf("render wiki template %s for %s: missing field %q", path, page.PageID, fieldSpec)
|
||||
}
|
||||
return html.EscapeString(value), nil
|
||||
}
|
||||
|
||||
func (ctx *wikiContext) renderRequiredWikiField(path, fieldSpec string, page wikiTemplatePage) (string, error) {
|
||||
value, ok := ctx.resolveWikiTemplateField(fieldSpec, page)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("render wiki template %s for %s: missing field %q", path, page.PageID, fieldSpec)
|
||||
}
|
||||
return html.EscapeString(value), nil
|
||||
}
|
||||
|
||||
func (ctx *wikiContext) resolveWikiTemplateField(fieldSpec string, page wikiTemplatePage) (string, bool) {
|
||||
fieldSpec = strings.TrimSpace(fieldSpec)
|
||||
if strings.HasSuffix(fieldSpec, ".text") {
|
||||
base := strings.TrimSuffix(fieldSpec, ".text")
|
||||
value, ok := lookupField(page.Row, base)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
text := ctx.resolveTextValue(value, nil)
|
||||
return text, text != ""
|
||||
}
|
||||
value, ok := lookupField(page.Row, fieldSpec)
|
||||
if !ok || value == nil {
|
||||
return "", false
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
if typed == "" || typed == nullValue {
|
||||
return "", false
|
||||
}
|
||||
return typed, true
|
||||
case int:
|
||||
return fmt.Sprintf("%d", typed), true
|
||||
case float64:
|
||||
return fmt.Sprintf("%d", int(typed)), true
|
||||
default:
|
||||
text := ctx.resolveTextValue(typed, nil)
|
||||
return text, text != ""
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user