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,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