Phase 1 Wiki Template refactor
This commit is contained in:
@@ -702,6 +702,7 @@ func computeManagedHash(content string) string {
|
|||||||
if !ok {
|
if !ok {
|
||||||
managed = content
|
managed = content
|
||||||
}
|
}
|
||||||
|
managed = normalizePreservedSectionBodies(managed)
|
||||||
h := sha256.Sum256([]byte(strings.TrimSpace(managed)))
|
h := sha256.Sum256([]byte(strings.TrimSpace(managed)))
|
||||||
return fmt.Sprintf("%x", h[:])
|
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) {
|
func TestDeployWikiCreatesNodeBBTopicWithoutFallbackForDefaultThreeCharacterTitle(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
sourceDir := filepath.Join(root, "pages")
|
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"
|
legacyWikiRootDirName = ".wiki"
|
||||||
wikiPagesDirName = "pages"
|
wikiPagesDirName = "pages"
|
||||||
wikiStateFileName = "state.json"
|
wikiStateFileName = "state.json"
|
||||||
wikiGeneratorVersion = "nodebb-tiptap-html-v1"
|
wikiGeneratorVersion = "nodebb-tiptap-html-v2"
|
||||||
wikiGeneratedStatus = "generated"
|
wikiGeneratedStatus = "generated"
|
||||||
wikiSkippedStatus = "skipped"
|
wikiSkippedStatus = "skipped"
|
||||||
)
|
)
|
||||||
@@ -68,6 +68,7 @@ type wikiManualSectionsDocument struct {
|
|||||||
|
|
||||||
type wikiManualSection struct {
|
type wikiManualSection struct {
|
||||||
ID string `json:"id" yaml:"id"`
|
ID string `json:"id" yaml:"id"`
|
||||||
|
Alias string `json:"alias" yaml:"alias"`
|
||||||
Heading string `json:"heading" yaml:"heading"`
|
Heading string `json:"heading" yaml:"heading"`
|
||||||
Placement string `json:"placement" yaml:"placement"`
|
Placement string `json:"placement" yaml:"placement"`
|
||||||
InitialHTML string `json:"initial_html" yaml:"initial_html"`
|
InitialHTML string `json:"initial_html" yaml:"initial_html"`
|
||||||
@@ -107,6 +108,8 @@ type wikiContext struct {
|
|||||||
raceFeatTables map[string]wikiTable
|
raceFeatTables map[string]wikiTable
|
||||||
statuses map[string]map[string]string
|
statuses map[string]map[string]string
|
||||||
implementedFeats map[string]struct{}
|
implementedFeats map[string]struct{}
|
||||||
|
manualSections []wikiManualSection
|
||||||
|
templateDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progress func(string)) (wikiResult, error) {
|
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 {
|
if err != nil {
|
||||||
return wikiResult{}, err
|
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 {
|
if err := os.RemoveAll(outputDir); err != nil {
|
||||||
return wikiResult{}, fmt.Errorf("clean wiki output: %w", err)
|
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 {
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
content = renderNodeBBManagedHTML(pageID, content, loadWikiManualSections(p))
|
content = renderNodeBBManagedHTML(pageID, content, manualSections)
|
||||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -182,7 +188,7 @@ func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progres
|
|||||||
if err := generateEntityPages(outputDir, ctx, writePage); err != nil {
|
if err := generateEntityPages(outputDir, ctx, writePage); err != nil {
|
||||||
return wikiResult{}, err
|
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
|
return wikiResult{}, err
|
||||||
}
|
}
|
||||||
if err := indexUnregisteredWikiPages(rootDir, outputDir, p.EffectiveConfig().TopData.Wiki.StalePages.Default, &pageIndex); err != nil {
|
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 {
|
func loadWikiManualSections(p *project.Project) []wikiManualSection {
|
||||||
defaults := []wikiManualSection{
|
defaults := defaultWikiManualSections()
|
||||||
{ID: "notes", Heading: "Notes", InitialHTML: "<h2>Notes</h2><p></p>"},
|
|
||||||
{ID: "see_also", Heading: "See Also", InitialHTML: "<h2>See Also</h2><p></p>"},
|
|
||||||
}
|
|
||||||
cfg := p.EffectiveConfig().TopData.Wiki
|
cfg := p.EffectiveConfig().TopData.Wiki
|
||||||
path := filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(cfg.ManualSectionsDir), "default.yaml")
|
path := filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(cfg.ManualSectionsDir), "default.yaml")
|
||||||
raw, err := os.ReadFile(path)
|
raw, err := os.ReadFile(path)
|
||||||
@@ -229,6 +232,15 @@ func loadWikiManualSections(p *project.Project) []wikiManualSection {
|
|||||||
if section.ID == "" {
|
if section.ID == "" {
|
||||||
continue
|
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) == "" {
|
if strings.TrimSpace(section.InitialHTML) == "" {
|
||||||
heading := strings.TrimSpace(section.Heading)
|
heading := strings.TrimSpace(section.Heading)
|
||||||
if heading == "" {
|
if heading == "" {
|
||||||
@@ -358,13 +370,15 @@ func computeWikiSourceDigest(sourceDir string) (string, error) {
|
|||||||
filepath.Join(sourceDir, "data", "baseitems"),
|
filepath.Join(sourceDir, "data", "baseitems"),
|
||||||
filepath.Join(sourceDir, "data", "classes"),
|
filepath.Join(sourceDir, "data", "classes"),
|
||||||
filepath.Join(sourceDir, "data", "racialtypes"),
|
filepath.Join(sourceDir, "data", "racialtypes"),
|
||||||
|
filepath.Join(sourceDir, "wiki"),
|
||||||
}
|
}
|
||||||
for _, dir := range relevantSourceDirs {
|
for _, dir := range relevantSourceDirs {
|
||||||
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||||
if err != nil || d.IsDir() {
|
if err != nil || d.IsDir() {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if strings.EqualFold(filepath.Ext(path), ".json") {
|
switch strings.ToLower(filepath.Ext(path)) {
|
||||||
|
case ".json", ".yaml", ".yml", ".html":
|
||||||
files = append(files, path)
|
files = append(files, path)
|
||||||
}
|
}
|
||||||
return nil
|
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) {
|
func (ctx *wikiContext) renderPage(category, key string, row map[string]any, title string) (string, error) {
|
||||||
sections := []string{
|
manualSections := ctx.manualSections
|
||||||
"====== " + title + " ======",
|
if len(manualSections) == 0 {
|
||||||
"",
|
manualSections = defaultWikiManualSections()
|
||||||
buildWikiStatusBlock(ctx.pageStatus(category, key, row), category),
|
|
||||||
"",
|
|
||||||
toDokuWiki(ctx.resolveRowDescription(category, row)),
|
|
||||||
"",
|
|
||||||
ctx.renderFacts(category, key, row),
|
|
||||||
"",
|
|
||||||
ctx.renderProgression(category, row),
|
|
||||||
"",
|
|
||||||
ctx.renderRaceNameForms(row),
|
|
||||||
}
|
}
|
||||||
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 {
|
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),
|
strings.TrimSpace(body),
|
||||||
"<!-- sow-topdata-wiki:managed:end -->",
|
"<!-- sow-topdata-wiki:managed:end -->",
|
||||||
}
|
}
|
||||||
|
renderedSections := extractManualRegions(body)
|
||||||
for _, section := range manualSections {
|
for _, section := range manualSections {
|
||||||
|
if _, ok := renderedSections[section.ID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
parts = append(parts,
|
parts = append(parts,
|
||||||
"<!-- sow-topdata-wiki:manual:start id=\""+html.EscapeString(section.ID)+"\" -->",
|
renderWikiManualSection(section),
|
||||||
strings.TrimSpace(section.InitialHTML),
|
|
||||||
"<!-- sow-topdata-wiki:manual:end id=\""+html.EscapeString(section.ID)+"\" -->",
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return ensureTrailingNewline(strings.Join(parts, "\n"))
|
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) {
|
func TestWikiPageIDForKeyNormalizesSlashAndUnderscoreSeparators(t *testing.T) {
|
||||||
got := wikiPageIDForKey("feat:special/attacks_bull_rush")
|
got := wikiPageIDForKey("feat:special/attacks_bull_rush")
|
||||||
if want := "feat:special:attacks:bull:rush"; got != want {
|
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