Files
aego/tools/gendocs/site/site.go
sanspie 5eb1832907
ci-platforms / darwin amd64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 15m28s
ci / release (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 7m47s
Complete Stage 12 for v0.12.0
2026-11-05 11:46:53 +03:00

1438 lines
41 KiB
Go

package site
import (
"crypto/sha256"
"embed"
"encoding/base64"
hexenc "encoding/hex"
"encoding/json"
"html"
"html/template"
"io/fs"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
"aego/app/brand"
)
//go:embed templates/layout.html
var layoutSrc string
//go:embed static
var static embed.FS
const (
SectionStart = "start"
SectionGuides = "guides"
SectionReference = "reference"
otherGroup = "other"
heroAsset = "assets/hero.png"
posterAsset = "assets/demo-poster.png"
shotsDir = "assets/shots/"
scriptSection = "## From a script"
homeLead = "A 2D game engine built in Go."
llmsNote = "Every page below is Markdown; /llms-full.txt has all of them in one file."
installCmd = "curl -fsSL https://aego.akarpov.ru/install.sh | sh"
installPage = "install"
)
var sectionOrder = []string{SectionStart, SectionGuides, SectionReference}
var sectionTitles = map[string]string{
SectionStart: "Getting started",
SectionGuides: "Guides",
SectionReference: "Reference",
}
func sectionRank(s string) int {
for i, x := range sectionOrder {
if x == s {
return i
}
}
return len(sectionOrder)
}
func sectionTitle(s string) string {
if t, ok := sectionTitles[s]; ok {
return t
}
return s
}
type Options struct {
Playground bool
Repo string
Edit string
Version string
Commit string
Hero string
Poster string
Shots string
Demo bool
PlayVersion string
TryVersion string
Release *Release
Icons []string
Time time.Time
}
type Report struct {
Pages int
Entries int
Broken []string
Outline string
}
type layoutData struct {
AssetV string
Background template.CSS
LightBack template.CSS
CSS template.CSS
Root string
Title string
PageTitle string
Description string
Site string
Playground bool
PlayVersion string
TryVersion string
PlaySize int64
TrySize int64
PlayGzip int64
TryGzip int64
Repo string
Demo bool
Home bool
Lang string
Nav template.HTML
Content template.HTML
Toc template.HTML
Pager template.HTML
Footer template.HTML
// Markdown is the page's Markdown copy, for rel="alternate".
Markdown string
// T translates the layout's own text; Search is the search index; HrefEN and HrefRU are the page in both languages, Alt the other one.
T func(string) string
Search string
HrefEN, HrefRU string
Alt, AltName, AltLang string
}
type searchItem struct {
Title string `json:"t"`
URL string `json:"u"`
Summary string `json:"s,omitempty"`
Kind string `json:"k"`
ID string `json:"i"`
Group string `json:"g,omitempty"`
Section string `json:"c,omitempty"`
Icon string `json:"ic,omitempty"`
}
type navGroup struct {
Group
roots []string
}
type builder struct {
// lightShots names the screenshots that also come in the light theme, as name-light.png (D345).
lightShots map[string]bool
exp *Export
out string
assetV string
missing map[string]bool
opt Options
byID map[string]*Entry
kids map[string][]string
groups []navGroup
groupOf map[string]int
own map[string]bool
urls map[string]string
order []string
pos map[string]int
hero bool
poster bool
icons iconSet
conv *converter
layout *template.Template
css string
report Report
// lang is the language the builder writes; "" and en are English, ru pages live under ru/ (D348).
lang string
// has lists the entries with a translation in lang; en is the English builder it was made from.
has map[string]bool
en *builder
}
func Build(exp *Export, out string, opt Options) (Report, error) {
exp.Entries = siteEntries(exp.Entries)
keep := make(map[string]bool, len(exp.Entries))
for _, e := range exp.Entries {
keep[e.ID] = true
}
exp.Nav = pruneNav(exp.Nav, keep)
for i := range exp.Groups {
exp.Groups[i].Nav = pruneNav(exp.Groups[i].Nav, keep)
}
if opt.Edit == "" && opt.Repo != "" {
opt.Edit = strings.TrimRight(opt.Repo, "/") + "/src/branch/main"
}
b := &builder{
exp: exp,
out: out,
opt: opt,
byID: map[string]*Entry{},
missing: map[string]bool{},
kids: map[string][]string{},
groupOf: map[string]int{},
own: map[string]bool{},
urls: map[string]string{},
pos: map[string]int{},
conv: newConverter(),
icons: loadIcons(opt.Icons),
}
b.lightShots = lightShots(opt.Shots)
layout, err := template.New("layout").Funcs(template.FuncMap{
"icon": func(root, name string) template.HTML { return template.HTML(b.icon(root, name, "")) },
}).Parse(layoutSrc)
if err != nil {
return Report{}, err
}
b.layout = layout
style, err := static.ReadFile("static/style.css")
if err != nil {
return Report{}, err
}
b.css = codeCSS() + string(style)
b.assetV = b.assetVersion()
b.hero = isFile(opt.Hero)
b.poster = isFile(opt.Poster)
b.index()
b.route()
b.report.Outline = b.outline()
if err := b.write(); err != nil {
return Report{}, err
}
if err := b.writeBrand(); err != nil {
return Report{}, err
}
return b.report, nil
}
func (b *builder) writeBrand() error {
for _, f := range []struct {
size int
name string
}{{32, "favicon.png"}, {180, "apple-touch-icon.png"}} {
data, ok := brand.PNG(f.size)
if !ok {
continue
}
dst := filepath.Join(b.out, "assets", f.name)
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
if err := os.WriteFile(dst, data, 0o644); err != nil {
return err
}
}
return nil
}
func (b *builder) index() {
for i := range b.exp.Entries {
e := &b.exp.Entries[i]
b.byID[e.ID] = e
}
seen := map[string]bool{}
var walk func(gi int, parent string, nodes []Node)
walk = func(gi int, parent string, nodes []Node) {
for _, n := range nodes {
if _, ok := b.byID[n.ID]; !ok || seen[n.ID] {
continue
}
seen[n.ID] = true
b.groupOf[n.ID] = gi
if parent == "" {
b.groups[gi].roots = append(b.groups[gi].roots, n.ID)
} else {
b.kids[parent] = append(b.kids[parent], n.ID)
}
walk(gi, n.ID, n.Children)
}
}
gs := append([]GroupNav(nil), b.exp.Groups...)
sort.SliceStable(gs, func(i, j int) bool {
if ri, rj := sectionRank(gs[i].Section), sectionRank(gs[j].Section); ri != rj {
return ri < rj
}
return gs[i].Order < gs[j].Order
})
for _, g := range gs {
b.groups = append(b.groups, navGroup{Group: g.Group})
walk(len(b.groups)-1, "", g.Nav)
}
var rest []Node
for _, n := range b.exp.Nav {
if !seen[n.ID] {
rest = append(rest, n)
}
}
if len(rest) > 0 {
b.groups = append(b.groups, navGroup{Group: Group{ID: otherGroup, Title: "Other", Section: SectionReference, Order: 1 << 20}})
walk(len(b.groups)-1, "", rest)
}
}
func (b *builder) ownPage(e *Entry) bool {
switch e.Kind {
case KindModule, KindType:
return true
case KindPage:
return e.Parent == "" || titled(e.Body) || len(b.kids[e.ID]) > 0
}
return e.Parent == ""
}
func pagePath(e *Entry) string {
if e.Kind == KindPage {
return cleanPath(e.ID) + ".html"
}
return "ref/" + cleanPath(e.ID) + ".html"
}
func (b *builder) route() {
var walk func(owner string, ids []string)
walk = func(owner string, ids []string) {
for _, id := range ids {
e := b.byID[id]
next := owner
if owner == "" || b.ownPage(e) {
b.own[id] = true
b.urls[id] = pagePath(e)
b.pos[id] = len(b.order)
b.order = append(b.order, id)
next = b.urls[id]
} else {
b.urls[id] = owner + "#" + id
}
walk(next, b.kids[id])
}
}
for _, g := range b.groups {
walk("", g.roots)
}
}
var linkAttr = regexp.MustCompile(`(href|data-fallback)="([^"]*)"`)
// cleanURL drops .html from a site-relative link; the server maps /page to page.html.
func cleanURL(u string) string {
if u == "" || strings.Contains(u, "://") || strings.HasPrefix(u, "#") || strings.HasPrefix(u, "mailto:") || strings.HasPrefix(u, "data:") {
return u
}
path, rest := u, ""
if i := strings.IndexAny(u, "?#"); i >= 0 {
path, rest = u[:i], u[i:]
}
switch {
case path == "index.html":
path = "./"
case strings.HasSuffix(path, "/index.html"):
path = strings.TrimSuffix(path, "index.html")
case strings.HasSuffix(path, ".html"):
path = strings.TrimSuffix(path, ".html")
default:
return u
}
return path + rest
}
func cleanLinks(page string) string {
return linkAttr.ReplaceAllStringFunc(page, func(m string) string {
sub := linkAttr.FindStringSubmatch(m)
return sub[1] + `="` + cleanURL(sub[2]) + `"`
})
}
func rootOf(p string) string {
return strings.Repeat("../", strings.Count(p, "/"))
}
func esc(s string) string {
return html.EscapeString(s)
}
var (
mdCode = regexp.MustCompile("`([^`]+)`")
mdStrong = regexp.MustCompile(`\*\*([^*]+)\*\*|__([^_]+)__`)
mdEm = regexp.MustCompile(`(^|[^\w*])\*([^*\s][^*]*)\*|(^|[^\w])_([^_\s][^_]*)_`)
headRe = regexp.MustCompile(`<h([23]) id="([^"]+)"( class="m")?[^>]*>(.*?)</h[23]>`)
kindRe = regexp.MustCompile(`<span class="kind[^"]*">[^<]*</span>`)
tagRe = regexp.MustCompile(`<[^>]+>`)
mdImage = regexp.MustCompile(`!\[[^\]]*\]\([^)]*\)`)
mdLink = regexp.MustCompile(`\[([^\]]*)\]\([^)]*\)`)
)
func inlineMD(s string) string {
parts := mdCode.Split(s, -1)
codes := mdCode.FindAllStringSubmatch(s, -1)
var b strings.Builder
for i, part := range parts {
t := esc(part)
t = mdStrong.ReplaceAllString(t, "<strong>$1$2</strong>")
t = mdEm.ReplaceAllString(t, "$1$3<em>$2$4</em>")
b.WriteString(t)
if i < len(codes) {
b.WriteString("<code>" + esc(codes[i][1]) + "</code>")
}
}
return b.String()
}
// metaDescription turns a summary into a plain sentence of at most about 160 characters.
func metaDescription(s string) string {
s = mdLink.ReplaceAllString(mdImage.ReplaceAllString(s, ""), "$1")
s = strings.Join(strings.Fields(plainMD(s)), " ")
if strings.HasSuffix(s, ":") {
s = strings.TrimSuffix(s, ":") + "."
}
if len(s) <= 160 {
return s
}
cut := strings.LastIndexByte(s[:157], ' ')
if cut < 80 {
cut = 157
}
return strings.TrimRight(s[:cut], ",;:") + "…"
}
func plainMD(s string) string {
s = mdCode.ReplaceAllString(s, "$1")
s = mdStrong.ReplaceAllString(s, "$1$2")
return mdEm.ReplaceAllString(s, "$1$3$2$4")
}
func (b *builder) resolve(id string) (string, bool) {
if u, ok := b.urls[id]; ok {
return u, true
}
id = strings.TrimSuffix(strings.TrimSpace(id), "()")
if u, ok := b.urls[id]; ok {
return u, true
}
if real, ok := b.exp.Aliases[id]; ok {
u, ok := b.urls[real]
return u, ok
}
return "", false
}
func (b *builder) code(root, src string) string {
return highlightLinked(src, func(name string) string {
if u, ok := b.resolve(name); ok {
return root + u
}
return ""
})
}
func (b *builder) markdown(root, id, md string) string {
p := &pageCtx{
root: root,
id: id,
play: b.opt.Playground,
resolve: b.resolve,
broken: func(target string) { b.report.Broken = append(b.report.Broken, id+" -> ref:"+target) },
}
out, err := b.conv.render(p, md)
if err != nil {
return "<p>" + esc(err.Error()) + "</p>"
}
return b.themedShots(out)
}
var shotImg = regexp.MustCompile(`<img src="([^"]*` + regexp.QuoteMeta(shotsDir) + `)([^"/]+)\.png"([^>]*)>`)
// lightShots lists the screenshots that have a light theme variant next to them.
func lightShots(dir string) map[string]bool {
out := map[string]bool{}
if dir == "" {
return out
}
files, _ := filepath.Glob(filepath.Join(dir, "*-light.png"))
for _, f := range files {
out[strings.TrimSuffix(filepath.Base(f), "-light.png")] = true
}
return out
}
// themedShots shows the light variant of a screenshot in the light theme; site.js keeps the source in step with the theme switch.
func (b *builder) themedShots(html string) string {
return shotImg.ReplaceAllStringFunc(html, func(m string) string {
x := shotImg.FindStringSubmatch(m)
if !b.lightShots[x[2]] {
return m
}
return `<picture class="themed"><source media="(prefers-color-scheme: light)" srcset="` + x[1] + x[2] + `-light.png">` + m + "</picture>"
})
}
func kindName(e *Entry) string {
if e.Kind == KindType && e.Component != nil {
return "component"
}
return e.Kind
}
func (b *builder) kindLabel(e *Entry) string {
if e.Kind == KindPage {
return ""
}
return ` <span class="kind kind-` + esc(e.Kind) + `">` + esc(b.tr(kindName(e))) + `</span>`
}
func (b *builder) details(c *strings.Builder, e *Entry, root string) {
if e.Signature != "" {
c.WriteString(`<pre class="sig"><code>` + b.code(root, e.Signature) + "</code></pre>\n")
}
body := e.Body
if b.own[e.ID] {
body = stripTitle(body)
}
if strings.TrimSpace(body) != "" {
c.WriteString(b.markdown(root, e.ID, body))
}
if e.Example != "" {
c.WriteString(`<p class="label">` + b.tr("Example") + `</p>` + "\n")
c.WriteString(`<pre class="code" data-lang="aes"><code class="lang-aes">` + b.code(root, e.Example) + "</code></pre>\n")
} else if u := b.moduleExample(e); u != "" {
c.WriteString(`<p class="see-example">` + b.tr("See the") + ` <a href="` + esc(root+u) + `">` + b.tr("module example") + `</a> ` + b.tr("for this function in use.") + `</p>` + "\n")
}
if strings.HasPrefix(e.ID, ScriptsPrefix) && e.Location.Path != "" {
where := e.Location.Path
if e.Location.Line > 0 {
where += ":" + strconv.Itoa(e.Location.Line)
}
c.WriteString(`<p class="where">` + esc(where) + "</p>\n")
}
}
func (b *builder) ref(root, id, text string) string {
if u, ok := b.urls[id]; ok {
ic := ""
if e, ok := b.byID[id]; ok {
ic = b.icon(root, b.entryIcon(e), "")
}
return `<a class="xref" href="` + esc(root+u) + `">` + ic + esc(text) + "</a>"
}
return "<code>" + esc(text) + "</code>"
}
func (b *builder) titleOf(id string) string {
if e, ok := b.byID[id]; ok {
return e.Title
}
return id
}
func (b *builder) componentMeta(e *Entry, root string) string {
ci := e.Component
var parts []string
if ci.Create != "" {
parts = append(parts, strings.Replace(b.tr("Add it with %s."), "%s", `<code>`+esc(ci.Create)+"</code>", 1))
}
if len(ci.Requires) > 0 {
links := make([]string, len(ci.Requires))
for i, id := range ci.Requires {
links[i] = b.ref(root, id, b.titleOf(id))
}
parts = append(parts, strings.Replace(b.tr("Needs %s on the same node."), "%s", strings.Join(links, ", "), 1))
}
if ci.Unique {
parts = append(parts, b.tr("One per scene."))
}
if len(parts) == 0 {
return ""
}
return `<h2 id="` + esc(e.ID) + `-usage">` + b.tr("Usage") + `</h2>` + "\n<p>" + strings.Join(parts, " ") + "</p>\n"
}
func (b *builder) fieldTable(c *strings.Builder, e *Entry, root string, heading bool) {
var set, live []*Entry
for _, k := range b.kids[e.ID] {
ke := b.byID[k]
switch {
case ke.Field == nil:
case ke.Field.Runtime:
live = append(live, ke)
default:
set = append(set, ke)
}
}
byIndex := func(rows []*Entry) {
sort.SliceStable(rows, func(i, j int) bool { return rows[i].Field.Index < rows[j].Field.Index })
}
byIndex(set)
byIndex(live)
b.rows(c, e, root, set, heading, "fields", b.tr("Fields"), "")
b.rows(c, e, root, live, heading, "runtime", b.tr("Runtime state"), b.tr("Values the component has while the game runs. They are not saved with the scene."))
}
func (b *builder) rows(c *strings.Builder, e *Entry, root string, rows []*Entry, heading bool, anchor, title, lead string) {
if len(rows) == 0 {
return
}
if heading {
c.WriteString(`<h2 id="` + esc(e.ID) + `-` + anchor + `">` + title + `</h2>` + "\n")
} else {
c.WriteString(`<p class="label">` + title + `</p>` + "\n")
}
if lead != "" {
c.WriteString("<p>" + lead + "</p>\n")
}
c.WriteString(`<table class="fields"><thead><tr><th>` + b.tr("Name") + `</th><th>` + b.tr("Type") + `</th><th>` + b.tr("Default") + `</th><th>` + b.tr("Description") + `</th></tr></thead><tbody>` + "\n")
for _, f := range rows {
fi := f.Field
def := "—"
if fi.Default != "" {
def = "<code>" + esc(fi.Default) + "</code>"
}
var notes []string
if fi.Range != "" {
notes = append(notes, `<span class="range">`+esc(fi.Range)+"</span>")
}
for _, n := range fi.Notes {
notes = append(notes, `<span class="note">`+esc(b.note(n))+"</span>")
}
c.WriteString(`<tr id="` + esc(f.ID) + `"><td class="fname"><a class="anchor" href="#` + esc(f.ID) + `">` + b.icon(root, b.entryIcon(f), "") + fieldName(f) + "</a></td>")
c.WriteString(`<td class="ftype"><code>` + esc(fi.Type) + `</code></td><td class="fdef">` + def + "</td>")
desc := ""
if strings.TrimSpace(f.Body) != "" {
desc = b.markdown(root, f.ID, f.Body)
}
if len(notes) > 0 {
desc += `<div class="fnotes">` + strings.Join(notes, " ") + "</div>"
}
c.WriteString(`<td class="fdesc">` + desc + "</td></tr>\n")
}
c.WriteString("</tbody></table>\n")
}
// fieldName shows a field as the Inspector names it, with the script name under it when that differs.
func fieldName(f *Entry) string {
l := f.Field.Label
if l == "" || strings.ReplaceAll(l, " ", "") == f.Title {
if l == "" {
l = f.Title
}
return `<span class="fnames"><span class="flabel">` + esc(l) + `</span></span>`
}
return `<span class="fnames"><span class="flabel">` + esc(l) + `</span><code class="fscript">` + esc(f.Title) + `</code></span>`
}
func (b *builder) entryList(c *strings.Builder, ids []string, root string) {
c.WriteString(`<ul class="contents">` + "\n")
for _, id := range ids {
ke, ok := b.byID[id]
if !ok {
continue
}
c.WriteString("<li>" + b.ref(root, id, ke.Title))
if ke.Summary != "" {
c.WriteString(` <span class="summary">` + inlineMD(ke.Summary) + "</span>")
}
c.WriteString("</li>\n")
}
c.WriteString("</ul>\n")
}
// eventList shows each script event as its name in code, a dash and its summary.
func (b *builder) eventList(c *strings.Builder, ids []string, root string) {
c.WriteString(`<ul class="events">` + "\n")
for _, id := range ids {
ke, ok := b.byID[id]
if !ok {
continue
}
name := `<code>` + esc(ke.Title) + `</code>`
if u, ok := b.urls[id]; ok {
name = `<a class="xref" href="` + esc(root+u) + `">` + name + "</a>"
}
c.WriteString("<li>" + name)
if ke.Summary != "" {
c.WriteString(" — " + inlineMD(ke.Summary))
}
c.WriteString("</li>\n")
}
c.WriteString("</ul>\n")
}
func (b *builder) section(c *strings.Builder, id, root string, depth int) {
e := b.byID[id]
h := "h" + strconv.Itoa(min(depth, 4))
c.WriteString(`<section class="member" data-name="` + esc(strings.ToLower(e.Title)) + `">` + "\n")
c.WriteString("<" + h + ` id="` + esc(e.ID) + `" class="m"><a class="anchor" href="#` + esc(e.ID) + `">` + b.icon(root, b.entryIcon(e), "") + esc(e.Title) + "</a>" + b.kindLabel(e) + "</" + h + ">\n")
b.details(c, e, root)
b.fieldTable(c, e, root, false)
b.children(c, e, root, depth+1)
c.WriteString("</section>\n")
}
func (b *builder) children(c *strings.Builder, e *Entry, root string, depth int) {
var pages []string
var members [2][]string
for _, k := range b.kids[e.ID] {
ke := b.byID[k]
switch {
case ke.Field != nil:
case b.own[k]:
pages = append(pages, k)
case e.Component != nil && ke.Kind == "method":
members[0] = append(members[0], k)
case e.Component != nil && ke.Kind == "signal":
members[1] = append(members[1], k)
default:
b.section(c, k, root, depth)
}
}
for i, title := range []string{b.tr("Methods"), b.tr("Signals")} {
if len(members[i]) == 0 {
continue
}
c.WriteString(`<h2 id="` + esc(e.ID) + `-` + strings.ToLower(title) + `">` + title + "</h2>\n")
for _, k := range members[i] {
b.section(c, k, root, depth+1)
}
}
if len(pages) == 0 {
return
}
c.WriteString(`<ul class="contents">` + "\n")
for _, k := range pages {
ke := b.byID[k]
c.WriteString(`<li><a href="` + esc(root+b.urls[k]) + `">` + b.icon(root, b.entryIcon(ke), "") + esc(ke.Title) + "</a>" + b.kindLabel(ke))
if ke.Summary != "" {
c.WriteString(` <span class="summary">` + inlineMD(ke.Summary) + "</span>")
}
c.WriteString("</li>\n")
}
c.WriteString("</ul>\n")
}
func (b *builder) crumbs(e *Entry, root string) string {
var trail, titles []string
for p := e.Parent; p != ""; {
pe, ok := b.byID[p]
if !ok {
break
}
if b.own[p] {
trail = append([]string{`<a href="` + esc(root+b.urls[p]) + `">` + esc(pe.Title) + "</a>"}, trail...)
titles = append([]string{pe.Title}, titles...)
}
p = pe.Parent
}
if gi, ok := b.groupOf[e.ID]; ok {
g := b.groups[gi]
head := []string{"<span>" + esc(b.tr(sectionTitle(g.Section))) + "</span>"}
next := e.Title
if len(titles) > 0 {
next = titles[0]
}
if g.Title != b.tr(sectionTitle(g.Section)) && g.Title != next {
head = append(head, "<span>"+esc(g.Title)+"</span>")
}
trail = append(head, trail...)
}
if e.Kind != KindPage {
trail = append([]string{`<b class="page-kind">` + esc(b.tr(kindName(e))) + "</b>"}, trail...)
}
if len(trail) == 0 {
return ""
}
return `<nav class="crumbs">` + strings.Join(trail, `<span class="sep">/</span>`) + "</nav>\n"
}
// soundButton toggles the sound of a try scene that plays any; try.js shows it once the scene runs.
func (b *builder) soundButton(e *Entry, root string) string {
if !strings.Contains(e.Try, `component "audio.`) && !strings.Contains(e.TryScript, `"aego/audio"`) {
return ""
}
return `<button type="button" class="try-sound" aria-pressed="false" hidden>` + b.icon(root, "cat.audio", "") + `<span>` + b.tr("Sound") + `</span></button>`
}
func (b *builder) tryBlock(c *strings.Builder, e *Entry, root string) {
name := e.ID
if i := strings.LastIndexByte(name, '.'); i >= 0 {
name = name[i+1:]
}
prefabs := ""
if len(e.TryPrefabs) > 0 {
data, _ := json.Marshal(e.TryPrefabs)
prefabs = string(data)
}
artifacts := ""
openLabel := "Open in editor"
if len(e.TryArtifacts) > 0 {
artifacts = base64.StdEncoding.EncodeToString(e.TryArtifacts)
openLabel = "Download scene only"
}
input := ""
if e.Input {
input = ` data-input="` + esc(strings.Join(e.TryKeys, ", ")) + `"`
}
c.WriteString(`<section class="try" data-select="` + esc(e.TrySelect) + `" data-name="` + esc(name) + `" data-scene="` + esc(e.Try) + `" data-code="` + esc(e.TryCode) + `" data-prefabs="` + esc(prefabs) + `" data-artifacts="` + esc(artifacts) + `"` + input + `>` + "\n")
c.WriteString(`<h2 id="` + esc(e.ID) + `-try">` + b.tr("Try it") + `</h2>` + "\n")
c.WriteString(`<p class="try-caption">` + esc(b.tr("A live scene with")) + ` ` + esc(e.Title) + `: ` + esc(b.tr("edit its fields in the Inspector on the right and the scene restarts with them.")) + `</p>` + "\n")
c.WriteString(`<div class="try-stage"><button type="button" class="try-start">` + b.icon(root, "play.start", "") + b.tr("Try it") + `</button><span class="try-hint">` + b.tr("Runs this scene in the browser, with the Inspector next to it") + `</span></div>` + "\n")
c.WriteString(`<div class="try-bar"><button type="button" class="try-open">` + b.icon(root, "file.save", "") + b.tr(openLabel) + `</button><button type="button" class="try-copy">` + b.icon(root, "edit.copy", "") + b.tr("Copy as .aescene") + `</button><button type="button" class="try-reset" disabled>` + b.icon(root, "ui.reset", "") + b.tr("Reset") + `</button>` + b.soundButton(e, root) + `<span class="try-status" role="status"></span></div>` + "\n")
c.WriteString(b.sceneTree(e.Try, root))
if e.TryScript != "" {
c.WriteString(`<details class="scene-tree try-script"><summary>` + b.icon(root, "file.script", "") + `<span>` + b.tr("Script") + `</span><span class="count">main.aes</span>` + b.icon(root, "ui.chevron_right", "chev") + `</summary>`)
c.WriteString(b.markdown(root, e.ID, "```aes\n"+strings.TrimRight(e.TryScript, "\n")+"\n```\n"))
c.WriteString("</details>\n")
}
c.WriteString("</section>\n")
}
func (b *builder) content(e *Entry, root string) string {
var c strings.Builder
c.WriteString(b.crumbs(e, root))
c.WriteString(`<h1 id="` + esc(e.ID) + `">` + b.icon(root, b.entryIcon(e), "ic-title") + esc(e.Title) + "</h1>\n")
if e.ID == installPage {
c.WriteString(b.downloadBlock(root))
}
scripted := ""
if e.Component != nil {
if i := strings.Index(e.Body, scriptSection); i >= 0 {
head := *e
head.Body, scripted = e.Body[:i], e.Body[i:]
e = &head
}
}
if e.Component != nil {
head := *e
if strings.TrimSpace(head.Body) != "" {
c.WriteString(`<div class="lead">` + b.markdown(root, e.ID, head.Body) + "</div>\n")
}
head.Body = ""
b.details(&c, &head, root)
c.WriteString(b.componentMeta(e, root))
} else {
b.details(&c, e, root)
}
if e.Try != "" && b.opt.Playground {
b.tryBlock(&c, e, root)
}
b.fieldTable(&c, e, root, true)
if scripted != "" {
c.WriteString(b.markdown(root, e.ID, scripted))
}
if e.Kind == KindModule && e.Group == groupScriptAPI {
b.apiIndex(&c, e, root)
}
if !b.diagnostics(&c, e, root) {
b.children(&c, e, root, 2)
}
if e.Component != nil && len(e.Component.Events) > 0 {
c.WriteString(`<h2 id="` + esc(e.ID) + `-events">` + b.tr("Script events") + `</h2>` + "\n")
c.WriteString("<p>" + strings.Replace(b.tr("Scripts on a node with this component receive these events from the %s module."), "%s", esc(b.moduleName(e.Component.Module)), 1) + "</p>\n")
b.eventList(&c, e.Component.Events, root)
}
if len(e.Related) > 0 {
c.WriteString(`<h2 id="` + esc(e.ID) + `-related">` + b.tr("Related guides") + `</h2>` + "\n")
b.entryList(&c, e.Related, root)
}
return c.String()
}
// tocMembers is how many member headings a toc lists before it leaves them to the page's index.
const tocMembers = 8
func (b *builder) toc(content string) string {
var heads [][]string
members := 0
for _, x := range headRe.FindAllStringSubmatch(content, -1) {
if x[1] != "2" {
continue
}
if x[3] != "" {
members++
}
heads = append(heads, x)
}
var c strings.Builder
n := 0
for _, x := range heads {
if x[3] != "" && members > tocMembers {
continue
}
text := strings.TrimSpace(html.UnescapeString(tagRe.ReplaceAllString(kindRe.ReplaceAllString(x[4], ""), "")))
c.WriteString(`<li><a href="#` + x[2] + `">` + esc(text) + "</a></li>")
n++
}
if n < 2 {
return ""
}
return `<p class="toc-title">` + b.tr("On this page") + `</p><ul>` + c.String() + "</ul>"
}
func (b *builder) pager(id, root string) string {
i, ok := b.pos[id]
if !ok {
return ""
}
var c strings.Builder
c.WriteString(`<nav class="pager">`)
if i > 0 {
p := b.order[i-1]
c.WriteString(`<a class="prev" href="` + esc(root+b.urls[p]) + `"><span>` + b.tr("Previous") + `</span>` + esc(b.byID[p].Title) + "</a>")
} else {
c.WriteString("<span></span>")
}
if i+1 < len(b.order) {
n := b.order[i+1]
c.WriteString(`<a class="next" href="` + esc(root+b.urls[n]) + `"><span>` + b.tr("Next") + `</span>` + esc(b.byID[n].Title) + "</a>")
}
c.WriteString("</nav>")
return c.String()
}
func (b *builder) footer(e *Entry, root string) string {
o := b.opt
name := "Aego"
if o.Version != "" {
name += " " + o.Version
}
left := []string{esc(name)}
if o.Commit != "" {
c := esc(o.Commit)
if o.Repo != "" {
c = `<a href="` + esc(strings.TrimRight(o.Repo, "/")+"/commit/"+o.Commit) + `">` + c + "</a>"
}
left = append(left, c)
}
if !o.Time.IsZero() {
left = append(left, b.tr("built")+" "+o.Time.UTC().Format("2006-01-02"))
}
var right []string
if e != nil && e.Location.Source != "" && o.Edit != "" {
u := strings.TrimRight(o.Edit, "/") + "/" + e.Location.Source
if e.Location.Line > 0 {
u += "#L" + strconv.Itoa(e.Location.Line)
}
label := b.tr("Edit this page")
if b.lang == "ru" && !b.has[e.ID] {
label = b.tr("Edit the English page")
}
right = append(right, `<a href="`+esc(u)+`">`+label+`</a>`)
}
if o.Playground {
right = append(right, `<a href="`+root+`play/">`+b.tr("Playground")+`</a>`)
}
if o.Repo != "" {
src := b.tr("Source")
if u, err := url.Parse(o.Repo); err == nil && u.Host != "" {
src = b.tr("Source on") + " " + u.Host
}
right = append(right, `<a href="`+esc(o.Repo)+`">`+esc(src)+`</a>`)
}
right = append(right, `<a href="`+root+b.langPath("llms.txt")+`" title="`+esc(b.tr("These docs as Markdown, for AI agents"))+`">llms.txt</a>`)
return `<footer class="foot"><span>` + strings.Join(left, " · ") + `</span><span class="links">` + strings.Join(right, "") + "</span></footer>"
}
func (b *builder) navTree(c *strings.Builder, ids []string, root, current string, open map[string]bool) {
c.WriteString(`<ul class="tree">`)
chev := b.icon(root, "ui.chevron_right", "chev")
for _, id := range ids {
if !b.own[id] {
continue
}
sub := b.ownKids(id)
link := b.navLink(id, root, current, b.icon(root, b.entryIcon(b.byID[id]), ""))
if len(sub) == 0 {
c.WriteString("<li>" + link + "</li>")
continue
}
attr := ""
if open[id] {
attr = " open"
}
c.WriteString(`<li><details` + attr + `><summary>` + link + chev + "</summary>")
b.navTree(c, sub, root, current, open)
c.WriteString("</details></li>")
}
c.WriteString("</ul>")
}
func (b *builder) ownKids(id string) []string {
var sub []string
for _, k := range b.kids[id] {
if b.own[k] {
sub = append(sub, k)
}
}
return sub
}
func (b *builder) navLink(id, root, current, ic string) string {
cls := "item"
aria := ""
if id == current {
cls += " current"
aria = ` aria-current="page"`
}
return `<a class="` + cls + `"` + aria + ` href="` + esc(root+b.urls[id]) + `">` + ic + `<span>` + esc(b.byID[id].Title) + "</span></a>"
}
// head returns the page that stands for a group in the nav and the entries listed under it.
func (b *builder) head(g navGroup) (string, []string) {
if len(g.roots) == 0 {
return "", nil
}
first := g.roots[0]
if len(g.roots) > 1 && b.byID[first].Title != g.Title {
return "", g.roots
}
return first, append(b.ownKids(first), g.roots[1:]...)
}
func (b *builder) nav(root, current string) string {
open := map[string]bool{}
for p := current; p != ""; {
open[p] = true
e, ok := b.byID[p]
if !ok {
break
}
p = e.Parent
}
cur := -1
if gi, ok := b.groupOf[current]; ok {
cur = gi
}
chev := b.icon(root, "ui.chevron_right", "chev")
var c strings.Builder
last := ""
for gi, g := range b.groups {
if g.Section != last {
if last != "" {
c.WriteString("</div>")
}
c.WriteString(`<div class="nav-section"><p class="section">` + esc(b.tr(sectionTitle(g.Section))) + "</p>")
last = g.Section
}
ic := b.icon(root, g.Icon, "")
head, list := b.head(g)
if head != "" && len(list) == 0 {
c.WriteString(strings.Replace(b.navLink(head, root, current, ic), `class="item`, `class="item head`, 1))
continue
}
attr := ""
if gi == cur {
attr = ` open data-current="true"`
}
summary := `<span class="item head">` + ic + "<span>" + esc(g.Title) + "</span></span>"
if head != "" {
summary = strings.Replace(b.navLink(head, root, current, ic), `class="item`, `class="item head`, 1)
}
c.WriteString(`<details class="group" data-group="` + esc(g.ID) + `"` + attr + "><summary>" + summary + chev + "</summary>")
b.navTree(&c, list, root, current, open)
c.WriteString("</details>")
}
if last != "" {
c.WriteString("</div>")
}
return c.String()
}
func (b *builder) firstOf(section string) string {
for _, g := range b.groups {
if g.Section == section && len(g.roots) > 0 {
return b.urls[g.roots[0]]
}
}
return ""
}
func (b *builder) emit(rel, title, current, content string, e *Entry) error {
root := rootOf(rel)
data := layoutData{
AssetV: b.assetV,
Background: template.CSS(pageBackground()),
LightBack: template.CSS(pageBackgroundLight()),
CSS: template.CSS(strings.ReplaceAll(b.css, "url(fonts/", "url("+root+"assets/fonts/")),
Root: root,
Title: title,
PageTitle: title + " · " + b.exp.Title,
Description: b.tr(homeLead),
Site: b.exp.Title,
Playground: b.opt.Playground,
PlayVersion: b.opt.PlayVersion,
TryVersion: b.opt.TryVersion,
PlaySize: fileSize(filepath.Join(b.out, "play", "aego.wasm")),
TrySize: fileSize(filepath.Join(b.out, "try", "aego-docs.wasm")),
PlayGzip: fileSize(filepath.Join(b.out, "play", "aego.wasm.gz")),
TryGzip: fileSize(filepath.Join(b.out, "try", "aego-docs.wasm.gz")),
Repo: b.opt.Repo,
Demo: b.opt.Demo,
Home: current == "",
Lang: b.pageLang(),
Nav: template.HTML(b.nav(root, current)),
Content: template.HTML(content),
Footer: template.HTML(b.footer(e, root)),
}
data.T = b.tr
data.Search = b.langPath("search.json")
if current != "" {
data.Markdown = mdURL(rel)
}
en := strings.TrimPrefix(rel, "ru/")
data.HrefEN, data.HrefRU = en, "ru/"+en
if data.Lang == "ru" {
data.Alt, data.AltName, data.AltLang = en, "English", "en"
} else {
data.Alt, data.AltName, data.AltLang = "ru/"+en, "Русский", "ru"
}
if current == "" {
data.PageTitle = b.exp.Title + " — 2D game engine in Go"
if data.Lang == "ru" {
data.PageTitle = b.exp.Title + " — 2D-движок на Go"
data.Description = landingRU.tagline
}
} else if e != nil {
if d := metaDescription(e.Summary); d != "" {
data.Description = d
}
}
if current != "" {
if t := b.toc(content); t != "" {
data.Toc = template.HTML(t)
inline := `<details class="toc-inline"><summary>` + b.tr("On this page") + `</summary>` + strings.TrimPrefix(t, `<p class="toc-title">`+b.tr("On this page")+`</p>`) + "</details>\n"
if i := strings.Index(content, "<h2"); i >= 0 {
data.Content = template.HTML(content[:i] + inline + content[i:])
}
}
data.Pager = template.HTML(b.pager(current, root))
}
var buf strings.Builder
if err := b.layout.Execute(&buf, data); err != nil {
return err
}
dst := filepath.Join(b.out, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
b.report.Pages++
page := buf.String()
if b.lang != "" && b.lang != "en" {
page = b.langLinks(page)
}
return os.WriteFile(dst, []byte(cleanLinks(page)), 0o644)
}
func (b *builder) walkOwn(ids []string, depth int, fn func(id string, depth int)) {
for _, id := range ids {
if !b.own[id] {
continue
}
fn(id, depth)
b.walkOwn(b.kids[id], depth+1, fn)
}
}
func (b *builder) outline() string {
var c strings.Builder
last := ""
for _, g := range b.groups {
if g.Section != last {
c.WriteString(b.tr(sectionTitle(g.Section)) + "\n")
last = g.Section
}
head, list := b.head(g)
if head != "" {
c.WriteString(" " + b.byID[head].Title + " -> " + b.urls[head] + "\n")
} else {
c.WriteString(" " + g.Title + "\n")
}
b.walkOwn(list, 2, func(id string, depth int) {
c.WriteString(strings.Repeat(" ", depth) + b.byID[id].Title + " -> " + b.urls[id] + "\n")
})
}
return c.String()
}
func (b *builder) llms() string {
var c strings.Builder
c.WriteString("# " + b.exp.Title + "\n\n> " + b.tr(homeLead) + "\n\n" + b.tr(llmsNote) + "\n")
last := ""
for _, g := range b.groups {
if g.Section != last {
c.WriteString("\n## " + b.tr(sectionTitle(g.Section)) + "\n")
last = g.Section
}
c.WriteString("\n### " + g.Title + "\n\n")
b.walkOwn(g.roots, 0, func(id string, _ int) {
e := b.byID[id]
line := "- [" + e.Title + "](/" + b.langPath(mdURL(b.urls[id])) + ")"
if s := plainMD(e.Summary); s != "" {
line += ": " + s
}
c.WriteString(line + "\n")
})
}
return c.String()
}
func (b *builder) write() error {
if err := os.MkdirAll(b.out, 0o755); err != nil {
return err
}
var search []searchItem
for i := range b.exp.Entries {
e := &b.exp.Entries[i]
u, ok := b.urls[e.ID]
if !ok {
continue
}
b.report.Entries++
item := searchItem{Title: e.Title, URL: cleanURL(u), Summary: plainMD(e.Summary), Kind: e.Kind, ID: e.ID, Icon: b.entryIcon(e)}
if gi, ok := b.groupOf[e.ID]; ok {
item.Group, item.Section = b.groups[gi].Title, b.groups[gi].Section
}
search = append(search, item)
}
for _, id := range b.order {
e := b.byID[id]
rel := b.urls[id]
if err := b.emit(rel, e.Title, id, b.content(e, rootOf(rel)), e); err != nil {
return err
}
}
if err := b.emit("index.html", b.exp.Title, "", b.home(landingEN), nil); err != nil {
return err
}
rb := b.forLang("ru")
if err := rb.emit("ru/index.html", b.exp.Title, "", rb.home(landingRU), nil); err != nil {
return err
}
if err := rb.writeLang(); err != nil {
return err
}
data, err := json.Marshal(search)
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(b.out, "search.json"), data, 0o644); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(b.out, "llms.txt"), []byte(b.llms()), 0o644); err != nil {
return err
}
if err := b.writeMarkdown(); err != nil {
return err
}
return b.assets()
}
func (b *builder) assets() error {
err := fs.WalkDir(static, "static", func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
data, err := static.ReadFile(p)
if err != nil {
return err
}
dst := filepath.Join(b.out, "assets", filepath.FromSlash(strings.TrimPrefix(p, "static/")))
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
return os.WriteFile(dst, data, 0o644)
})
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(b.out, "assets", "icons.svg"), []byte(b.icons.sprite), 0o644); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(b.out, "assets", "code.css"), []byte(codeCSS()), 0o644); err != nil {
return err
}
if b.hero {
if err := copyFile(b.opt.Hero, filepath.Join(b.out, filepath.FromSlash(heroAsset))); err != nil {
return err
}
}
if b.poster {
if err := copyFile(b.opt.Poster, filepath.Join(b.out, filepath.FromSlash(posterAsset))); err != nil {
return err
}
}
if b.opt.Shots == "" {
return nil
}
shots, _ := filepath.Glob(filepath.Join(b.opt.Shots, "*.png"))
if len(shots) > 0 {
if err := os.MkdirAll(filepath.Join(b.out, filepath.FromSlash(shotsDir)), 0o755); err != nil {
return err
}
}
for _, f := range shots {
if err := copyFile(f, filepath.Join(b.out, filepath.FromSlash(shotsDir), filepath.Base(f))); err != nil {
return err
}
}
return nil
}
func isFile(path string) bool {
if path == "" {
return false
}
st, err := os.Stat(path)
return err == nil && !st.IsDir()
}
func copyFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, data, 0o644)
}
func fileSize(path string) int64 {
st, err := os.Stat(path)
if err != nil {
return 0
}
return st.Size()
}
// moduleExample links a Script API function without its own example to its module's runnable example.
func (b *builder) moduleExample(e *Entry) string {
if e.Kind != KindFunction && e.Kind != KindMethod {
return ""
}
m, ok := b.byID[e.Parent]
if !ok || m.Kind != KindModule {
return ""
}
for _, s := range m.Snippets {
if (s.Run || s.Check) && s.Lang == "aes" {
if u, ok := b.urls[m.ID]; ok {
return u + "#" + exampleAnchor(m.ID, 1)
}
}
}
return ""
}
const groupScriptAPI = "ref-script"
// apiIndex lists a Script API module's functions and types with their signatures, above the full sections.
func (b *builder) apiIndex(c *strings.Builder, e *Entry, root string) {
var rows []*Entry
consts := 0
for _, k := range b.kids[e.ID] {
ke := b.byID[k]
switch ke.Kind {
case KindFunction, KindMethod, KindType:
rows = append(rows, ke)
default:
consts++
}
}
if len(rows) == 0 {
return
}
c.WriteString(`<h2 id="` + esc(e.ID) + `-index">` + b.tr("Functions") + `</h2>` + "\n")
c.WriteString(`<div class="api-index"><label class="member-filter">` + b.icon(root, "ui.search", "") + `<input type="search" placeholder="` + esc(b.tr("Filter")) + ` ` + strconv.Itoa(len(rows)+consts) + ` ` + esc(b.tr("members of")) + ` ` + esc(e.Title) + `" aria-label="` + esc(b.tr("Filter members")) + `"></label>` + "\n")
c.WriteString(`<table class="api">` + "\n")
for _, r := range rows {
sig := r.Signature
if i := strings.IndexByte(sig, '('); i >= 0 && r.Kind != KindType {
sig = sig[i:]
} else {
sig = ""
}
href := "#" + r.ID
if b.own[r.ID] {
href = root + b.urls[r.ID]
}
c.WriteString(`<tr data-name="` + esc(strings.ToLower(r.Title)) + `"><td class="fname"><a href="` + esc(href) + `">` + b.icon(root, b.entryIcon(r), "") + esc(r.Title) + `</a></td><td><code class="lang-aes">` + b.code(root, sig) + `</code></td><td>` + inlineMD(r.Summary) + "</td></tr>\n")
}
c.WriteString("</table>")
if consts > 0 {
c.WriteString(`<p class="api-more">` + strings.Replace(b.tr("%d constants and enum values are listed below."), "%d", strconv.Itoa(consts), 1) + `</p>`)
}
c.WriteString("</div>\n")
}
// siteEntries drops editor-only pages and everything under them.
func siteEntries(all []Entry) []Entry {
parent := make(map[string]string, len(all))
only := map[string]bool{}
for _, e := range all {
parent[e.ID] = e.Parent
if e.EditorOnly {
only[e.ID] = true
}
}
hidden := func(id string) bool {
for n := 0; id != "" && n < 64; n++ {
if only[id] {
return true
}
id = parent[id]
}
return false
}
out := all[:0:0]
for _, e := range all {
if !hidden(e.ID) {
out = append(out, e)
}
}
return out
}
func pruneNav(nodes []Node, keep map[string]bool) []Node {
var out []Node
for _, n := range nodes {
if keep[n.ID] {
n.Children = pruneNav(n.Children, keep)
out = append(out, n)
}
}
return out
}
// assetVersion names the current styles, scripts and icons, so a new build gets new asset URLs.
func (b *builder) assetVersion() string {
h := sha256.New()
_ = fs.WalkDir(static, "static", func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
data, err := static.ReadFile(p)
if err == nil {
h.Write([]byte(p))
h.Write(data)
}
return err
})
h.Write([]byte(b.icons.sprite))
h.Write([]byte(codeCSS()))
return hexenc.EncodeToString(h.Sum(nil))[:12]
}