Templates and components
How a page's template extends goapplib's layout, the template functions, and the components that ship with goapplib.
goapplib’s templates are Go’s html/template, loaded through templar, which adds three directives: include to pull in another file, namespace to load one under a prefix, and extend to copy a template with some of its blocks swapped. Every page here is from docsite/examples/listing in goapplib’s repo, a list of projects with search, paging and an htmx delete, plus an editor page in a BorderLayout. Its tests render each page on every pull request, so the templates on this page are ones that work.
Extending BasePage ¶
A page’s template doesn’t copy goapplib’s layout. It extends it, swapping in its own body:
{{# namespace "Goal" "BasePage.html" #}}
{{# namespace "EL" "components/EntityListing.html" #}}
{{# include "components/Pagination.html" #}}
{{ define "Results" }}
<div id="results">
<p id="project-count">{{ .TotalCount }} projects</p>
{{ template "EL:EntityListing" .Listing }}
{{ template "Pagination" . }}
</div>
{{ end }}
{{ define "ProjectsBody" }}<main>{{ template "Results" . }}</main>{{ end }}
{{# extend "Goal:BasePage" "ProjectsLayout" "Goal:BodySection" "ProjectsBody" #}}
{{ define "ProjectsPage" }}{{ template "ProjectsLayout" . }}{{ end }}
{{ define "DeleteResponse" }}<p id="project-count" hx-swap-oob="true">{{ .Count }} projects</p>{{ end }}
namespace "Goal" "BasePage.html" loads goapplib’s BasePage.html with every template in it prefixed Goal:. extend "Goal:BasePage" "ProjectsLayout" "Goal:BodySection" "ProjectsBody" makes ProjectsLayout, a copy of BasePage that uses ProjectsBody wherever it used BodySection. The page’s own template (ProjectsPage, named after the Go type) then renders ProjectsLayout.
It has to be done this way, which is a bit roundabout the first time. Including BasePage.html and defining BodySection again fails with multiple definition of template "BodySection", because templar parses a file and what it includes as one template set. That’s also why the Results block can sit in the same file: it’s a separate template, rendered inside the body for the whole page, and on its own for an htmx search (see the htmx page).
BasePage has these blocks to swap: TitleSection, CSSSection, HTMXSection, ExtraHeadSection, SplashScreenSection, HeaderSection, BodySection, ModalSection, ToastSection, FooterSection, AppContainerSection, AppScriptSection, PreScriptsSection and PostScriptsSection. Extend swaps several at once by listing more pairs. It reads .Header and .NavigationItems from the page as well as BasePage’s fields, and CustomHeader: true skips goapplib’s header, which the editor page below does to put its own toolbar there.
Template functions ¶
goapplib.SetupTemplates adds goapplib.DefaultFuncMap():
safeHTML,safeHTMLAttr,safeJSandsafeURLmark a value as trusted, sohtml/templatedoesn’t escape it.defaultfalls back when a value is nil or an empty string, with the fallback first ({{ default "entity-grid" .ContainerId }}, or{{ .ContainerId | default "entity-grid" }}).dictandlistbuild a map or a slice in a template ((dict "ContentId" "canvas")).addandsubare integer arithmetic, which goapplib’s ownPaginationandSearchFilterneed. They arrived in v0.7.2, and before that those two components failed to render with only goapplib’s functions.eq,ne,or,andandnotreplace Go’s built-in ones.eqandnecompare with Go’s==on whatever they’re given, so anintand anint64holding 1 aren’t equal, andorandandtreat an empty string or a zero value as false.dsetandlsetset a map key or a slice element and return it.ToJsonwrites a value as JSON for a<script>, andIndentedturns a block of text’s newlines into<br/>(its first argument, a number of spaces, isn’t used).
Components ¶
goapplib’s components live in templates/components/. They’re styled with Tailwind classes, so an app that uses them builds Tailwind over goapplib’s templates as well as its own. Each one reads the fields listed here from what you pass it.
EntityListingdraws a whole listing (a header, search and sort controls, a grid or table of items, and an empty state) from agoapplib.EntityListingData[T].NewEntityListingData[Project]("Projects", "/projects/%s")sets up the defaults, andWithCreate(url, label),WithEdit(format),WithView(format),WithDelete(format)andWithHtmx(searchUrl)turn on the rest. Each item needsId,NameandDescription. Load it with a namespace, as the example does, since it defines its ownEmptyState, and so doesEntityGrid.Paginationtakes the page itself, since it readsCurrentPage,HasPrevPage,HasNextPage,PrevPage,NextPage,Pages,TotalCountandTotalPagesfromWithPagination, andQueryandSortfromWithFiltering, to keep them in its links. It draws nothing when there’s only one page.PaginationHtmxdoes the same with htmx, and also readsBaseUrlandTargetfrom what you pass it.SearchFilteris a search box, a sort menu and a view toggle. It readsQuery,Placeholder,SearchId,SortOptions,SortId,ViewModes,CreateUrlandCreateLabel, and withHtmxEnabled,SearchUrl,TargetandIndicator.SearchFilterSimpleis a plain form version.EntityGridandEntityTableare the grid and the table on their own. The grid readsItems(each withId,Name,Description,PreviewUrlandUpdatedAt) and has blocks (GridItem,GridItemTitle,GridItemDescription,GridItemImage,GridItemMeta,GridItemActions) to override. The table readsColumns(each withKey,LabelandSortable) andItems, with blocksTableHeaders,TableRow,TableCellsandTableActions.ModalandConfirmModal, andToast, are whatBasePage’sModalContainerandToastContainerhold. A modal readsId,Title,MaxWidth,ShowCloseandShowFooter, with blocksModalBodyandModalFooter. A toast readsType,Title,Message,Dismissible,AutoDismissandDismissAfter.Drawer,DrawerRightandBottomBarare for small screens: a drawer from the bottom (Id,Title,Height,Position, with aDrawerContentblock) or the right (Id,Title,Width), and a bar ofItems(eachLabel,Icon,HreforActionorDrawerId, andActive).
BorderLayout ¶
BorderLayout fills its parent with a center and up to four regions around it, in plain CSS flexbox. The editor page uses it for a toolbar and a status bar:
{{# namespace "Goal" "BasePage.html" #}}
{{# namespace "BL" "components/BorderLayout.html" #}}
{{ define "Toolbar" }}<div class="p-2 border-b"><b>Editor</b> <button>Save</button></div>{{ end }}
{{ define "StatusBar" }}<div class="p-1 text-xs border-t">Ready</div>{{ end }}
{{# extend "BL:BorderLayout" "EditorLayout" "BL:BorderLayout_North" "Toolbar" "BL:BorderLayout_South" "StatusBar" #}}
{{ define "EditorBody" }}{{ template "EditorLayout" (dict "ContentId" "canvas" "FlexMode" "fill") }}{{ end }}
{{# extend "Goal:BasePage" "EditorShell" "Goal:BodySection" "EditorBody" #}}
{{ define "EditorPage" }}{{ template "EditorShell" . }}{{ end }}
The regions are blocks named BorderLayout_North, BorderLayout_South, BorderLayout_East and BorderLayout_West, empty unless you swap them in with extend, as above. It reads ContentId (the id of the center’s content, border-layout-content by default), WrapperClass, CenterClass and FlexMode, which is fill (take the rest of the parent, the default), fixed (100% width and height) or auto (as big as the content). The wrapper’s id is border-layout-wrapper and the center’s is border-layout-center. The regions get no element or id of their own, so give whatever you put in one an id if a script needs it.
We found, while writing this page, that until v0.7.2 every BorderLayout without its own North rendered “My Toolbar”, because an example in the template’s comment was a live define (Go’s templates parse what’s inside an HTML comment). Upgrading fixes it, if you saw that in an app.
Different layouts for different screens ¶
Most of the time, and mostly by design, Tailwind’s breakpoints (sm:, md:, lg:) are enough, with Drawer and BottomBar for what only small screens show. Register the same page more than once when a layout differs enough to want a different template, with WithTemplate, at different routes, or pick in a small handler that serves the right registration:
mobile := goapplib.Register[*EditorPage](app, nil, "/editor", goapplib.WithTemplate("EditorPageMobile"))
desktop := goapplib.Register[*EditorPage](app, nil, "/editor")
mux.HandleFunc("/editor", func(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie("layout"); err == nil && c.Value == "mobile" {
mobile.ServeHTTP(w, r)
return
}
desktop.ServeHTTP(w, r)
})