Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,41 @@ fixing that is a separate change.
`pageRoutePath` in `urls.go`), like the SPA's own page read: `/<page>/` gets the page's
meta with the canonical link to `/<page>`, and its Markdown. `/` and `//` stay the root;
empty segments inside the path (`/a//b`) find no page and get noindex.
- **2026-11 (#1292, rendered body for search engines):** details fixed while implementing it:
- A contributor behind `PublicOnly`, registered after the page meta contributor, puts
`<article><h1>title</h1>` + the page rendered by `internal/render`
([ADR-0020](./0020-server-rendered-page-html-for-crawlers.md)) + `<nav><ul>` links to the
page's direct children into `#root`. Title and child titles are escaped, child links are
escaped wiki paths below the base path; wikilinks resolve by the rule stored links follow
(`links.ResolveWikiLinkTarget`, behind `pages.ResolveWikiLinkUseCase`), children come
from one locked tree read (`TreeService.ChildRoutes`). No breadcrumbs. The SPA mounts with `createRoot`, which
replaces the content, so the page is shown once (pinned by
`e2e/tests/seo-crawler-body.spec.ts`).
- Only for user agents containing (case-insensitive) `googlebot`, `google-inspectiontool`
(Search Console's URL Inspection and the Rich Results Test), `bingbot`, `duckduckbot`,
`yandex`, `baiduspider` or `applebot`; everyone else, unknown pages, the root, SPA routes
and `/p/:id` permalinks (their canonical link points at the page route) get the plain
shell. Anyone can send such a user agent; the body is the content the SPA shows anyway.
- Every shell of a public wiki carries `Vary: User-Agent` (through `ShellParts.Vary`, merged
with the Markdown responder's `Vary: Accept`) and `Cache-Control: no-cache`, crawler or
not, so caches never mix the variants. A private wiki's shell has neither.
- Renders are cached in memory per page version (key: base path, route path and content;
a hit is reused only while every wikilink in it still resolves the same way), at most
16 MB of HTML, least recently used dropped first. At most two renders run at once; a request beyond
that gets the plain shell instead of waiting.
- A render error (`render.ErrTooComplex` or a page outside the policy) means the plain
shell, never a 500. The failure is cached for that page version and logged once as a
warning with the route and without page content; refused renders are logged once per
process. A client that has gone away by the time a render slot is free gets no render.
- Cost: child links and wikilink targets (also a `[[Folder/Title]]` resolved through the
route-path fallback) come from locked tree lookups without file reads. The page itself is
read twice per crawler request, once by the page meta contributor and once by the crawler
body, each through the page-view read (which reads the file for the content and again for
the frontmatter). Sharing that read would need request-scoped state across contributors,
which the shell seam does not have; it is left as is because only crawler requests pay
it. Browser requests read nothing more than before: the user agent is checked first.
- Reads go through the pages slice's use cases (`ListChildRoutesUseCase`,
`ResolveWikiLinkUseCase`). Once anonymous readers see only part of a wiki
(`internal/scopedread`, [ADR-0015](./0015-scoped-read-boundary.md)), the reader must drop
hidden children and resolve wikilinks against visible pages only, before ambiguity is
counted; otherwise a link that is ambiguous only because of a hidden page would reveal it.
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

## Status

Proposed (2026-10-29)
Accepted (2026-11-09; proposed 2026-10-29).

Open: whether this decision is folded into ADR-0018 (search-engine rendering for public wikis,
proposed in the same series) while that one is still Proposed.
It stays separate from ADR-0018: that one decides when and where the wiki answers search
engines, this one how page HTML is rendered and sanitized, which a later non-crawler user of
`internal/render` (exports, link previews) depends on without the SEO decisions.

## Context

Expand Down Expand Up @@ -104,3 +105,11 @@ type Options struct { BasePath, RoutePath string; ResolveWikiLink func(target st
- Every new tag or attribute the crawler HTML needs is a policy change with an XSS-corpus
test (`policy_test.go`). Revisit when the shell gets a CSP, or when the frontend schema
changes in a way the pinned hash flags.

## Later changes

- **2026-11 (#1292, crawler body):** the `seo` slice's crawler body contributor wires the
renderer in (see [ADR-0018](./0018-search-engine-rendering-for-public-wikis.md), "Later
changes"): renders are cached per page version in a 16 MB LRU cache, at most two run at
once, and `ErrTooComplex` or any other render error means the plain shell, logged once
without page content.
2 changes: 1 addition & 1 deletion docs/development/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,5 +64,5 @@ Status values: `Proposed`, `Accepted`, `Rejected`, `Deprecated`, `Superseded by
| [0017](./0017-spa-shell-contributors.md) | The SPA shell is rendered through registered responders and contributors | Proposed |
| [0018](./0018-search-engine-rendering-for-public-wikis.md) | Public wikis are rendered for search engines through the `seo` slice, gated on public access | Accepted |
| [0019](./0019-capability-routes-without-identity.md) | Capability-URL routes run without identity, in their own route group | Proposed |
| [0020](./0020-server-rendered-page-html-for-crawlers.md) | Page HTML for crawlers is rendered in `internal/render` and sanitized by a stricter server policy | Proposed |
| [0020](./0020-server-rendered-page-html-for-crawlers.md) | Page HTML for crawlers is rendered in `internal/render` and sanitized by a stricter server policy | Accepted |
| [0021](./0021-page-side-panel-sections.md) | The page side panel renders sections composed by the page viewer | Proposed |
12 changes: 8 additions & 4 deletions docs/development/source-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ resync-derived state.
`router.go` builds the `gin.Engine`, embeds the built frontend (`//go:embed dist/**`, gated by
the `EmbedFrontend` build-time flag), sets up request logging via `slog`. `shell.go` serves
the SPA fallback route: it runs the registered `ShellResponder`s and renders `index.html`
from the `ShellContributor`s on `FrontendConfig` (title, head, `#root` markup; built-ins for
from the `ShellContributor`s on `FrontendConfig` (title, head, `#root` markup, `Vary` fields; built-ins for
the custom stylesheet and `InjectCodeInHeader` last), see
[ADR-0017](./adr/0017-spa-shell-contributors.md). `registrar.go` defines
the `RouteRegistrar` interface and `RouterContext` (engine, auth cookie manager, CSRF cookie
Expand Down Expand Up @@ -270,7 +270,7 @@ shape for that domain.

- **`internal/wiki/pages`** — the largest domain slice: CRUD + tree operations exposed over HTTP.
One file per use case — `create_page.go`, `update_page.go`, `move_page.go`, `copy_page.go`,
`delete_page.go`, `convert_page.go` (page↔section), `get_page_view.go` (`GetPageViewByIDUseCase`/`GetPageViewByPathUseCase` — the single-page view shared by API and SEO: page plus frontmatter tags and properties via `page_metadata.go`; not a visibility layer), `get_first_page_path.go` (`GetFirstPagePathUseCase` — the first top-level page or section in navigation order, the target of the root redirect), `list_page_routes.go` (`ListPageRoutesUseCase` — route path, kind and last update of every page and section in navigation order, from one locked tree read; feeds the sitemap), `get_page.go` (`PageQueries` — the remaining read-only lookups: path lookup, title, permalink), `pin_page.go` (global editorial pin, see
`delete_page.go`, `convert_page.go` (page↔section), `get_page_view.go` (`GetPageViewByIDUseCase`/`GetPageViewByPathUseCase` — the single-page view shared by API and SEO: page plus frontmatter tags and properties via `page_metadata.go`; not a visibility layer), `get_first_page_path.go` (`GetFirstPagePathUseCase` — the first top-level page or section in navigation order, the target of the root redirect), `list_page_routes.go` (`ListPageRoutesUseCase` — route path, kind and last update of every page and section in navigation order, from one locked tree read; feeds the sitemap; `ListChildRoutesUseCase`, a page's direct children for the crawler body), `resolve_wiki_link.go` (`ResolveWikiLinkUseCase` — a `[[target]]` to its page's route by the rule stored links follow, for the crawler body), `get_page.go` (`PageQueries` — the remaining read-only lookups: path lookup, title, permalink), `pin_page.go` (global editorial pin, see
distinction from favorites below), `ensure_path.go`, `refactor.go` (link-refactor
trigger), `add_favorite.go`/`remove_favorite.go`/`list_favorites.go` (per-user favorites, note the explicit distinction: pin is global/editorial via
`tree.SetPinned` in page frontmatter, favorites is per-user via `internal/favorites`, deliberately
Expand Down Expand Up @@ -325,7 +325,8 @@ shape for that domain.
file. `shell.go`: `PublicOnly` (every registration and route of the slice runs only while
public access is on; `Handler(on, off)` for routes), the root redirect (`GET /` → 302 to the
first page), the page Markdown responder (`Accept: text/markdown` → the page's Markdown) and
the page meta contributor (robots `noindex` for routes without a page); `routes.go`:
the page meta contributor (robots `noindex` for routes without a page), then the crawler
body contributor (`Vary: User-Agent` on every public shell); `routes.go`:
`Routes` (`robots.txt`, `sitemap.xml`, GET and HEAD); `root_target.go`:
`GetRootTargetUseCase` behind the narrow `FirstPageReader`; `urls.go`: the URL helpers the
features share (escaped wiki path, absolute URL from `--public-url`, the page read's route
Expand All @@ -335,7 +336,10 @@ shape for that domain.
`NoIndex` when no page is at the route; `PermalinkResolver` for permalinks) and
`markdown.go` (`GetPageMarkdownUseCase` and the `Accept` negotiation); `robots.go`:
`GetRobotsUseCase` and `PrivateRobots`; `sitemap.go`: `BuildSitemapUseCase` behind
`PageRouteLister`, streamed by `WriteXML`. The readers are implemented in
`PageRouteLister`, streamed by `WriteXML`; `crawler_body.go`: `GetCrawlerBodyUseCase`
(title, `internal/render` HTML and child links for search-engine user agents, behind
`PageReader` and `CrawlerBodyReader`; renders cached per page version, parallel renders
capped, least recently used dropped first). The readers are implemented in
`internal/wiki/seo_adapter.go` with the pages slice's read use cases.

## Frontend — `ui/leafwiki-ui/src/`
Expand Down
72 changes: 72 additions & 0 deletions e2e/tests/seo-crawler-body.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { APIRequestContext, expect, request, test } from '@playwright/test';
import { createPageViaApi, csrfHeader, loggedIn } from '../helpers/apiContext';

const baseURL = process.env.E2E_BASE_URL || 'http://localhost:8080';
const googlebot = 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)';
const chrome =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36';

async function setPublicAccess(admin: APIRequestContext, enabled: boolean) {
const resp = await admin.put('/api/admin/settings/public-access', {
headers: await csrfHeader(admin),
data: { enabled },
});
expect(resp.ok(), `public access ${enabled}`).toBeTruthy();
}

// Search-engine crawlers get the rendered page inside #root of a public
// wiki's page source (#1292). The SPA mounts with createRoot, which replaces
// that markup, so a crawler that runs JavaScript still sees the page once.
test('crawler gets the rendered page in #root, the SPA shows it once', async ({ browser }) => {
const admin = await loggedIn();
const slug = `seo-crawler-${Date.now()}`;
const title = `SEO Crawler ${slug}`;
const paragraph = `Crawler body paragraph ${slug}.`;
await createPageViaApi(admin, { title, slug, content: paragraph });

const pageSource = async (userAgent: string) => {
const ctx = await request.newContext({ baseURL, userAgent });
try {
const resp = await ctx.get(`/${slug}`);
expect(resp.status()).toBe(200);
return { html: await resp.text(), vary: resp.headers()['vary'] ?? '' };
} finally {
await ctx.dispose();
}
};

try {
await setPublicAccess(admin, true);

const crawler = await pageSource(googlebot);
expect(crawler.html).toContain(`<div id="root"><article><h1>${title}</h1><p>${paragraph}</p>`);
expect(crawler.vary).toContain('User-Agent');

const visitor = await pageSource(chrome);
expect(visitor.html).toContain('<div id="root"></div>');
expect(visitor.vary).toContain('User-Agent');

const context = await browser.newContext({ baseURL, userAgent: googlebot });
try {
const page = await context.newPage();
await page.goto(`/${slug}`);
// The server's article is a direct child of #root; the SPA's sits
// inside its layout, so this waits until the SPA has replaced it.
await expect(page.locator('#root > article')).toHaveCount(0);
await expect(page.getByText(paragraph, { exact: true })).toBeVisible();
await expect(page.getByText(paragraph, { exact: true })).toHaveCount(1);
// The server's <h1> is gone; the SPA shows the title at most once.
expect(await page.locator('h1', { hasText: title }).count()).toBeLessThanOrEqual(1);
} finally {
await context.close();
}

await setPublicAccess(admin, false);
const privateSource = await pageSource(googlebot);
expect(privateSource.html).toContain('<div id="root"></div>');
expect(privateSource.vary).not.toContain('User-Agent');
} finally {
await setPublicAccess(admin, false);
await admin.dispose();
}
});
28 changes: 28 additions & 0 deletions internal/core/tree/tree_reads.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,34 @@ func (t *TreeService) PageRoutes(limit int) (entries []RouteEntry, truncated boo
return entries, truncated, err
}

// ChildRoutes returns the direct children of the node with id ("root" for
// the top level), pages and sections, in the order the navigation shows. Like
// PageRoutes it is one locked read without file reads. It returns
// ErrTreeNotLoaded before the tree is loaded and ErrPageNotFound for an
// unknown id.
func (t *TreeService) ChildRoutes(id string) ([]RouteEntry, error) {
var entries []RouteEntry
err := t.withRLockedTree(func() error {
if t.tree == nil {
return ErrTreeNotLoaded
}
node := t.getNodeByIDLocked(id)
if node == nil {
return ErrPageNotFound
}
parentPath := strings.TrimPrefix(node.CalculatePath(), "/") // root-relative, like PageRoutes
entries = make([]RouteEntry, 0, len(node.Children))
for _, child := range node.Children {
entries = append(entries, newRouteEntry(child, parentPath))
}
return nil
})
if err != nil {
return nil, err
}
return entries, nil
}

// RoutesByTitle returns the route of every node whose title matches title
// (trimmed, case-insensitive, like FindPagesByTitle). Unlike the nodes
// FindPagesByTitle returns, the route paths are computed under the read
Expand Down
69 changes: 69 additions & 0 deletions internal/core/tree/tree_reads_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -305,3 +305,72 @@ func TestTreeService_RouteLookups_ConcurrentWithMoves(t *testing.T) {
}
<-done
}

func TestTreeService_ChildRoutes_ListsDirectChildrenInNavigationOrder(t *testing.T) {
svc, ids := subtreeFixture(t)
if err := svc.SortPages(ids["docs"], []string{ids["faq"], ids["guide"]}); err != nil {
t.Fatalf("SortPages failed: %v", err)
}

tests := []struct {
name string
id string
want []string
}{
{name: "section", id: ids["docs"], want: []string{"docs/faq", "docs/guide"}},
{name: "nested page", id: ids["guide"], want: []string{"docs/guide/intro"}},
{name: "leaf page", id: ids["intro"], want: []string{}},
{name: "root", id: "root", want: []string{"docs", "other"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
entries, err := svc.ChildRoutes(tc.id)
if err != nil {
t.Fatalf("ChildRoutes failed: %v", err)
}
got := make([]string, len(entries))
for i, e := range entries {
got[i] = e.RoutePath
}
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
t.Errorf("route paths = %v, want %v", got, tc.want)
}
})
}
}

func TestTreeService_ChildRoutes_CopiesNodeFields(t *testing.T) {
svc, ids := subtreeFixture(t)

entries, err := svc.ChildRoutes(ids["guide"])
if err != nil || len(entries) != 1 {
t.Fatalf("ChildRoutes = %v, %v, want one entry", entries, err)
}
node := svc.GetTree().Children[0].Children[0].Children[0]
want := RouteEntry{ID: ids["intro"], Title: "intro", RoutePath: "docs/guide/intro", Kind: NodeKindPage, UpdatedAt: node.Metadata.UpdatedAt}
if entries[0] != want || want.UpdatedAt.IsZero() {
t.Errorf("entry = %+v, want %+v", entries[0], want)
}
}

func TestTreeService_ChildRoutes_Errors(t *testing.T) {
loaded, _ := subtreeFixture(t)
tests := []struct {
name string
svc *TreeService
id string
want error
}{
{name: "tree not loaded", svc: NewTreeService(t.TempDir()), id: "root", want: ErrTreeNotLoaded},
{name: "unknown id", svc: loaded, id: "missing", want: ErrPageNotFound},
{name: "empty id", svc: loaded, id: "", want: ErrPageNotFound},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
entries, err := tc.svc.ChildRoutes(tc.id)
if !errors.Is(err, tc.want) || entries != nil {
t.Fatalf("ChildRoutes = %v, %v, want nil, %v", entries, err, tc.want)
}
})
}
}
Loading
Loading