On this page
Generating the files that belong to an assembled site rather than to any one project: the listing, blog index, navigation, feed, sitemap, robots and 404.
#internal/blog/shared
#internal/blog/shared
Package shared generates the elements of an assembled documentation site that belong to the site rather than to any one project.
Every constituent project's own build writes a full standalone site: its pages, its stylesheet, its robots.txt, its llms.txt, its sitemap, its feed. Grafted under a project slug those site-level files end up at "
#The address authority
[PageTarget] and [PostTarget] are the one place that decides where a manifest entry lives on the assembled site, and [TargetOutputPath] and [OutputPathTarget] convert between that address and the emitted file. The blog index, the sitemap, the feed, the cross-project link check and the deploy-time verifier all address pages through them, so they cannot disagree about where a page is.
#Escaping
Every string this package interpolates into markup goes through [EscapeHTML], which escapes the apostrophe as well as the ampersand, the angle brackets and the double quote. That is what the Python this package replaces emitted, and it is deliberately not util.EscapeHTML, which leaves the apostrophe alone for the page emitters below it.
#PostsSegment
const PostsSegment = address.PostsPrefixPostsSegment is the site-level directory every post is served from: "blog/
It is the build's own posts prefix because the two cannot be allowed to disagree: what a project's build emits under "blog/" is what the assembly serves at "blog/", moved across unchanged.
#SitemapPath
const SitemapPath = "sitemap.xml"SitemapPath is the address of the sitemap the site-wide robots.txt names.
The sitemap is written at the site root by the assembly's shared-file generator and this is the same file, so the two cannot name different documents.
#LLMSPath
const LLMSPath = "llms.txt"LLMSPath is where the composed, site-wide llms.txt is served from.
#DefaultBlogPath
const DefaultBlogPath = "/" + PostsSegment + "/"DefaultBlogPath is the navigation document's link to the site-wide blog.
#DefaultFeedTitle
const DefaultFeedTitle = "Documentation"DefaultFeedTitle titles the aggregated feed when the caller names none.
#ErrCSSURLRequired
var ErrCSSURLRequired = errors.New(ErrCSSURLRequired is returned by [WrapSharedPage] when it is handed no stylesheet reference.
The parameter was optional and no caller ever passed it, so every shared page the assembly published shipped as bare HTML. There is no styling without it and nothing to fall back to.
#MergedPost
type MergedPost structMergedPost is one post as the assembly sees it: the post's own metadata plus the project whose manifest carried it.
It replaces the untyped dictionary the Python merge produced. Every field is the string form of the manifest's value, so a manifest that declares a date as a number still renders.
#PageTarget
func PageTarget(projectSlug, pagePath string, home bool) stringPageTarget is the site-relative address of a project's page ("alpha/guide/").
This and [PostTarget] are the one place that decides where a manifest entry lives on the assembled site. The blog index, the sitemap, the feed, the cross-project link check and the deploy-time verifier all address pages through them, so they cannot disagree about where a page is.
home marks the roster's home project: its content root is the site root, so its pages carry no project segment at all -- "cv.md" is at "cv/" and its "index.md" is the site's front page.
#PostTarget
func PostTarget(postSlug string) stringPostTarget is the site-relative address of a post ("blog/hello").
A post has no project segment: the blog is the site's, one slug namespace shared by every project, and "blog/
#TargetOutputPath
func TargetOutputPath(target string) stringTargetOutputPath is the emitted file a site-relative target names.
Both address forms land on the same file: a directory index. The trailing slash a page target carries and the one a post target does not are a spelling difference, not two addresses.
#OutputPathTarget
func OutputPathTarget(relOutput string) stringOutputPathTarget is the address form an emitted file has, as a link target.
The inverse of [TargetOutputPath], in the spelling [ValidateCrossProjectLinks] recognises: a post keeps no trailing slash, everything else gets one.
#EscapeHTML
func EscapeHTML(text string) stringEscapeHTML escapes text for insertion into HTML: the ampersand, the angle brackets, the double quote as """ and the apostrophe as "'".
It reproduces Python's html.escape(text, quote=True), which is what every emitter in this module used. It is deliberately not util.EscapeHTML: that one leaves the apostrophe alone, because the page chrome it serves was written against a private escaper that does, and Go's own html.EscapeString spells the apostrophe "'" rather than "'". A shared page carrying a project blurb with an apostrophe is the ordinary case, so the difference is on every deploy.
#GenerateNavJSON
func GenerateNavJSON(manifests []map[string]any, blogPath string, homeSlug string) stringGenerateNavJSON produces the navigation document for every project.
The home project is not one of them: it is the site root every nav already points back to, not an entry in the project set.
blogPath is the URL path of the blog link; pass [DefaultBlogPath] for the site's own blog. The document is written in declaration order rather than sorted, with two-space indentation, so the bytes are the ones Python's json.dumps(nav, indent=2) produced.
#GenerateUnifiedFeed
func GenerateUnifiedFeed(manifests []map[string]any, docsBase, feedTitle string) (string, error)GenerateUnifiedFeed produces an Atom XML feed aggregating the posts of every project.
docsBase is the base URL of the documentation site. feedTitle titles the feed; empty means [DefaultFeedTitle].
Entries are ordered newest first, and the feed-level
#GenerateSitemap
func GenerateSitemap(manifests []map[string]any, docsBase string, homeSlug string) (string, error)GenerateSitemap produces a sitemap XML listing every page and post of every project.
docsBase is the absolute base URL of the documentation site. It is required and absolute: the sitemap protocol has no relative
homeSlug is the roster's home project, whose pages are addressed from the site root rather than from a project segment.
#GenerateRobotsTxt
func GenerateRobotsTxt(canonicalBase string) stringGenerateRobotsTxt produces the assembly's robots.txt, naming the site-wide sitemap.
Each constituent project's own build writes a robots.txt at its own output root, which ends up buried at "
#GenerateLLMSTxt
func GenerateLLMSTxt(manifests []map[string]any, canonicalBase string, homeSlug string) stringGenerateLLMSTxt produces the assembly's llms.txt, composed by reference.
Every constituent project's build writes its own llms.txt listing its own pages, and the graft keeps it at "
The home project is left out for the same reason it is left out of the listing: it is the site root the file is served from, not one of the projects it points at.
#WrapSharedPage
func WrapSharedPage(title, bodyHTML, canonicalURL, cssURL, searchPrefix string) (string, error)WrapSharedPage wraps an HTML fragment in a complete HTML page.
The wrapper reuses the theme's own class surface where the theme has one -- ".site-footer" is the theme's footer, and the version badge in the project listing is the theme's badge. The rest of what these pages render (the listing cards, the blog rows, the not-found list) is the assembly's own markup, styled by the shared-page rules appended to the site-level chrome asset by the chrome package.
The theme's three-column ".layout" is deliberately not reused: it reserves a 240px sidebar column and a 200px table-of-contents column, and a shared page has neither, so it would render as a centred column with two empty gutters. ".shared-page" is the container these pages get instead.
title titles the document. bodyHTML is placed inside the body. canonicalURL is the absolute URL for the page's rel=canonical link -- the assembly site is reachable on more than one host, so the shared pages declare which one is canonical, and an empty value emits no canonical link at all.
cssURL is the page's reference to the site-level chrome stylesheet, relative to the page. It is required: an empty value returns [ErrCSSURLRequired].
searchPrefix is the hop from this page back to the site root, where the assembly's one site-wide Pagefind index lives ("" for a page at the root, "../" one level in). A shared page carries the same search as every documentation page, and the hop is a fact about where the page sits.
Which search surface the page carries is read off the stylesheet it already names: a framework theme's sheet is the only one written at themes.FrameworkCSSRel inside its payload directory, and the framework's modules sit beside it. Deriving it beats threading the theme name through every shared-page caller, and it cannot disagree with the stylesheet the page actually loads.
#GenerateHomepage
func GenerateHomepage(manifests []map[string]any, siteHop string, homeSlug string, curated *listing.Listing) (string, error)GenerateHomepage produces the project listing fragment the "/projects/" page serves.
curated is the home project's curated listing, and it is the source: categories, order and blurbs are content the home project authors, and this page is one of its two renderings (the front page's cards directive is the other, which refuses without it too).
A declared home project declares a listing with it, so a nil curated beside a homeSlug is refused rather than rendered around. The name-ordered rendering answers one state: a tree that declares no home project at all, where no curated listing can exist.
The home project itself is never in either rendering: it is the page the listing is reached from, not one of the projects it lists.
siteHop is the hop from the page this fragment is rendered into back to the site root ("" at the root, "../" one level in). Every card's link is written against it, so the listing resolves under any mount -- production, a local preview, a mirror -- rather than only under the deployed base.
#GenerateBlogIndex
func GenerateBlogIndex(manifests []map[string]any, siteHop string) (string, error)GenerateBlogIndex produces an HTML fragment listing every post across projects, newest first.
siteHop is the hop from the page this fragment is rendered into back to the site root. Each entry links a post relative to it, so the index works on every mount rather than on the deployed host alone.
#GenerateNotFoundPage
func GenerateNotFoundPage(cssURL, siteHop string) (string, error)GenerateNotFoundPage produces the assembly's root 404 page.
The only one the site has. "404.html" is answered at the root of what the provider serves, so a copy inside a project's subtree is never reached; the projects stopped emitting one and this page answers every unmatched address on the site.
It is served through the hosting provider's "404.html" convention: a request matching no asset gets this body with a 404 status. That is why its body has to differ from the front page's -- an unknown address that renders the front page is a soft 404, and a crawler reads it as a duplicate of the home page rather than as a dead link.
It declares no canonical, deliberately. A canonical says "this content lives at this address"; an error page is not content and has no address of its own -- it is the answer to every address the site does not serve. Naming one would hand a crawler a real URL for a page that only ever appears under URLs that do not exist.
cssURL is the page's reference to the site-level chrome stylesheet. The 404 sits at the site root, so it is the one shared page whose hop back to the root is empty, and the three links it offers are written against siteHop rather than against the deployed base. An absolute one would send every reader who hit a dead address on a preview or a mirror to production.
#MergeProjectPosts
func MergeProjectPosts(manifests []map[string]any) ([]MergedPost, error)MergeProjectPosts returns every project's posts as one list, refusing a slug collision.
Posts share one slug namespace across the whole assembled site, so two projects publishing "hello" would claim the same address and one would silently overwrite the other. The unified build refuses that at build time; this is the same refusal on the assembly side, where the posts arrive as separate manifests written by separate deploys and no single build ever sees them together.
The error names both projects that claim the slug.
#ValidateCrossProjectLinks
func ValidateCrossProjectLinks(manifests []map[string]any, linkRegistry map[string][]string) []stringValidateCrossProjectLinks checks that every cross-project link resolves to a known page or post, and returns one error string per broken link.
linkRegistry maps a source page path to the list of targets that page links. Both spellings of a target are accepted for every entry: the manifest path ("guide.md") and the site address the addressing authority gives it ("alpha/guide/", "blog/hello").
The returned slice is empty when every link resolves; it is a list of findings rather than an error, because the caller reports all of them at once.