Skip to content
internal/blog/shared
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 "/robots.txt" where no crawler reads them. The ones that answer are the ones generated here, once, for the whole site: the project listing, the site-wide blog index, the navigation document, the aggregated Atom feed, the sitemap, robots.txt, llms.txt and the root 404 page.

#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

Go go
const PostsSegment = address.PostsPrefix

PostsSegment is the site-level directory every post is served from: "blog//", at the assembly root and never under a project slug.

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

Go go
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

Go go
const LLMSPath = "llms.txt"

LLMSPath is where the composed, site-wide llms.txt is served from.

#DefaultBlogPath

Go go
const DefaultBlogPath = "/" + PostsSegment + "/"

DefaultBlogPath is the navigation document's link to the site-wide blog.

#DefaultFeedTitle

Go go
const DefaultFeedTitle = "Documentation"

DefaultFeedTitle titles the aggregated feed when the caller names none.

#ErrCSSURLRequired

Go go
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

Go go
type MergedPost struct

MergedPost 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

Go go
func PageTarget(projectSlug, pagePath string, home bool) string

PageTarget 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

Go go
func PostTarget(postSlug string) string

PostTarget 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//" is where the build emits a post and where the assembly serves it. Which project wrote it is metadata the blog index prints, not part of its address.

#TargetOutputPath

Go go
func TargetOutputPath(target string) string

TargetOutputPath 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

Go go
func OutputPathTarget(relOutput string) string

OutputPathTarget 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

Go go
func EscapeHTML(text string) string

EscapeHTML 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

Go go
func GenerateNavJSON(manifests []map[string]any, blogPath string, homeSlug string) string

GenerateNavJSON 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

Go go
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 carries the most recent post's date -- or, for a site with no posts at all, today's, because an Atom feed without an is not a feed.

#GenerateSitemap

Go go
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 , and a crawler reading "/alpha/guide/" where an absolute URL belongs drops the entry. An empty or root-relative base is an error rather than a sitemap that silently indexes nothing.

homeSlug is the roster's home project, whose pages are addressed from the site root rather than from a project segment.

#GenerateRobotsTxt

Go go
func GenerateRobotsTxt(canonicalBase string) string

GenerateRobotsTxt 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 "/robots.txt" where no crawler reads it. The one that is served is this one, and it carries the same crawler policy -- robots.Agents, read from the build that writes the per-project ones, so the site cannot allow a crawler its projects disallow or the other way round.

#GenerateLLMSTxt

Go go
func GenerateLLMSTxt(manifests []map[string]any, canonicalBase string, homeSlug string) string

GenerateLLMSTxt 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 "/llms.txt". The site-wide file links to each of those rather than restating them: an inlined copy would be a second, staler rendering of a document the project already publishes, and it would go out of date on every deploy that is not this one.

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

Go go
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

Go go
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

Go go
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

Go go
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

Go go
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.

Go go
func ValidateCrossProjectLinks(manifests []map[string]any, linkRegistry map[string][]string) []string

ValidateCrossProjectLinks 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.

Search