import * as cheerio from 'cheerio'
import type { FastifyRequest } from 'fastify'
import type { AnalyticsInjections } from '../models/analytics.ts'
import type { PageDescription } from '../models/pages.ts'
import { htmlEscape, isPageUrl, normalizePagePath, originOf, splitLocalePath } from './common.ts'
/**
* What the app shell is enriched with before it is handed to a client that will not run it.
*
* The compiled SPA is one document for every path on the wiki: a `
` reading `Wiki.js`, no
* description, and an empty `
` that only means something once a browser has run the
* bundle. Anything that does not — a chat client building an unfurl card, an AI crawler, a search
* engine that does not render, a reader with JavaScript off — sees exactly that, for every page.
*
* This is the cheap half of the fix, and it is cheap because there is nothing to render: a page's
* HTML is already a string in `pages.render`, produced once in the editor at save time. So a document
* for a crawler is the shell plus that string plus a handful of meta tags, and the server never runs
* a renderer, a component tree or a second build to produce one. See the note on caching below for
* what a scrape actually costs.
*
* There are two kinds of document, and which one a request gets turns on whether it will run the app:
*
* - **A client that will not** — `fragmentsForCrawler` — gets the public's view of the page: a head
* describing it, its markup appended, and 404 where the public may read nothing there. What goes
* in is what the GUESTS group may read and nothing else, which is what makes it the same document
* for whoever asked, and therefore the half that is cached and handed on.
* - **A browser that will** — `fragmentsForBrowser` — gets the head alone, describing the page as
* THAT reader may see it. It still matters that the title is right: the document's own title is
* what the tab reads while the bundle loads and what a bookmark made before it finishes keeps.
*
* Two further rules hold across both:
*
* - **Nothing is rendered, and nothing runs.** The injected copy is the stored render with its
* scripts and styles taken out — see `stripActiveMarkup`.
* - **Nothing a requester holds reaches the cache.** Only the public half is cached, keyed by origin
* and path with no session dimension, because there is nothing in it that varies by requester.
*/
/** Namespaced so the whole lot can be dropped without knowing which hosts or paths are in it. */
const SHELL_CACHE_PREFIX = 'appShell:'
/**
* How long an assembled fragment set is held, in seconds.
*
* The same figure as the sitemap's, for the same reason: a title or a description a few minutes out
* of date misleads nobody, and the alternative is reading a page out of the database for every scrape
* of it. What waits for the TTL is an edit showing up in an unfurl card — the wiki itself never shows
* a stale page, since everyone who can edit one is logged in and is served the live app.
*/
const SHELL_CACHE_TTL = 600
/**
* How many fragment sets are held at once.
*
* A crude ceiling rather than an eviction order: at the limit the whole namespace goes and fills
* again. Nothing here is worth the bookkeeping a least-recently-used cache would need — a wiki whose
* public traffic fits in this many pages gets a perfect hit rate, and one being crawled end to end
* gets little from caching either way, since a crawler fetches each page once.
*
* Having a ceiling at all is the point, and it is not about a wiki's size: the key carries the
* request's own host (see `cacheKeyFor`), so without one anybody could grow this without limit by
* asking for the same page under a made-up hostname.
*/
const SHELL_CACHE_MAX_ENTRIES = 500
/** The site root, as the path below a locale prefix comes back — `/fr` alone is `/` in French. */
const ROOT_PATHS = new Set(['', '/'])
/** The page a site's root addresses. Mirrors `normalizePath` in the frontend's page store. */
const HOME_PATH = 'home'
/** The element the injected copy is wrapped in. `frontend/index.html` styles it; `main.js` removes it. */
const PRERENDER_ID = 'wiki-prerender'
/**
* The prefix of every URL belonging to the administration area.
*
* Documents served for a path below it carry no analytics tag — see `analyticsInjections`. Only the
* path matters, not who is asking: the question is which document is being built, and a reader with
* no access to the admin area gets the same document at that URL as an administrator does.
*/
const ADMIN_PATH_PREFIX = '/_admin'
/**
* The `` : ''
]
.filter(Boolean)
.join('\n '),
body: theme?.injectBody?.trim() ?? ''
}
}
/**
* The document to answer a request for the app shell with.
*
* Every request gets a head describing the page at its URL; which page that is, and what else travels
* with it, is what `fragmentsForCrawler` and `fragmentsForBrowser` differ about.
*
* The site's own theme injections travel with it (`themeInjections`), which is what puts the CSS
* override and the head and body HTML from **Admin → Theme** into the document — the head ones after
* everything describing the page, so that an override is the last stylesheet in the document. So do
* the analytics tags of whichever providers the site has turned on (`analyticsInjections`), for the
* same reasons and read the same way — except in the administration area, which is configuration
* rather than reading and is left out of a site's traffic entirely.
*
* The analytics head goes in FIRST, ahead of the theme's own head injection. A tracking tag is meant
* to run as early as it can, and the theme field is the operator's own markup — last is where an
* override belongs.
*
* Only the public half is cached, and the shell is never cached: the shell is re-read per request so
* that `npm run build` in `frontend/` takes effect immediately, which a cached whole document would
* have delayed by the TTL, and the string insertions that combine them are nothing next to a database
* read. Every insertion uses a replacer function rather than a replacement string — a page, or an
* injection, containing `$&` would otherwise rewrite itself as it was inserted.
*
* @param shell The compiled `assets/index.html`, as read for this request
*/
export async function renderAppShell(
req: FastifyRequest,
siteId: string | undefined,
shell: string
): Promise {
const urlPath = req.raw.url!.split('?')[0]!
const fragments = isAnonymous(req)
? await publicFragments(req, siteId, urlPath)
: await fragmentsForBrowser(req, siteId, urlPath)
/*
The head replaces the shell's own `` where there is one and is appended to its ``
where there is not, so that a shell built without one is enriched rather than silently skipped.
*/
const withoutTitle = shell.replace(/[ \t]*[\s\S]*?<\/title>\n?/i, '')
const injected = themeInjections(siteId)
const tags = analyticsInjections(siteId, urlPath)
const head = [fragments.head, tags.head, injected.head].filter(Boolean).join('\n ')
/*
Immediately after the opening ``, which is the one slot that is not the end of something:
Google Tag Manager's `