Skip to content

Library: the library is written into the page, not fetched after it - #222

Merged
manager merged 3 commits into
devfrom
feat/library-server-rendered
Sep 10, 2026
Merged

manager merged 3 commits into
devfrom
feat/library-server-rendered

Conversation

@manager

@manager manager commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

/library/wolf answered with a shell: the title, the description and the schema.org list were in the first HTML, and everything a reader could actually read arrived later by script. A crawler that runs no scripts, which is most of them and every AI one, indexed Loading, 0 Books and No tags yet on a page elea.co, the CV and LinkedIn link to as proof of 165 read books. Reported by The Order, 2026-09-10.

What changed

  • The read the browser made is made once on the request, anonymously (getPublicLibraryView), and seeds the providers and the template. Shelves, books, About, Author, counts and tags leave the server in the response. A private shelf never reaches the page source, even on the owner's own request; the browser still re-reads on mount, silently.
  • Every object address carries its own metadata (objectSeo): its own title, a description drawn from the owner's note, its cover as the shared image, and a Book / VideoObject / AudioObject entry whose review holds the note and the rating. Before this, 165 addresses answered with the library's own title and picture.
  • A book's page renders the book: title, author, shelf, rating, difficulty, the note itself, its tags and a way back. ObjectArticle, with a passport in CLAUDE.md.
  • Modal stands down where there is no document. An object address opened its dialog mid-render once the shelves arrived server-side, and createPortal(…, document.body) took the whole response down with it. Notes render through an escape-first, allow-after sanitizer that needs no DOM.
  • /library-sitemap.xml: every public library and every object on a public shelf. Strapi's sitemap plugin knows none of these URLs; on 2026-09-10 not one was in sitemap.xml.

Proof

Built and served locally (next start), read with curl against the staging CMS:

URL Before After
/library/<user> Loading, 0 Books, No tags yet shelves, counts, About, Author, tags, 165 covers with their titles
/library/<user>/<book> the library's title and picture own title, own description from the note, cover as OG image, Book + review + rating, the note in the page
/library-sitemap.xml did not exist 197 URLs on staging

No server errors across five routes including a library that does not exist. Weight: Wolf's page goes from 95 kB / 22.5 kB gzip to 800 kB / 77 kB gzip, which is what 165 server-rendered cards cost. Covers are trimmed to the fields the cards read and notes travel only with the object the URL names, which is what keeps it to that.

NOT DONE: the robots.txt served on keepsimple.io is not this repo's file (it carries a Sitemap: line the repo never had), so the new sitemap reaches production's robots only through The Order. Both lines are in public/robots.txt here. Google Search Console and Bing still need the sitemap submitted once it is live.

🤖 Generated with Claude Code

…r it

/library/wolf answered with a shell. The title, the description and the
schema.org list were in the first HTML; every word a reader could read
arrived later by script. A crawler that runs no scripts, which is most of
them and every AI one, indexed "Loading", "0 Books" and "No tags yet" on a
page elea.co, the CV and LinkedIn link to as proof of 165 read books.

The read the browser made is now made once on the request, anonymously, so
the shelves, the books, the About text and the tags leave the server in the
response. A private shelf never reaches the page source, even on the owner's
own request, and the browser still re-reads on mount, silently, so an owner's
own view arrives without the shelves blinking out.

A book's address is now that book: its own title, a description drawn from
the owner's note, its cover as the shared image, the note itself in the page,
and a Book entry whose review carries the note and the rating. Before this
165 addresses answered with the library's own title and picture.

A dialog cannot be server-rendered, so Modal stands down where there is no
document and the browser opens it on arrival; notes render through an escape
first, allow after sanitizer that needs no DOM. /library-sitemap.xml lists
every public library and every object on a public shelf, none of which
Strapi's own sitemap knows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 10, 2026 •

Copy link
Copy Markdown

Claude finished @manager's task in 8m 56s —— View job


Review: Library server-rendered page (#222)

Overall: solid, well-scoped fix for the stated crawler problem. The anonymous, single-read getPublicLibraryView seeding both GlobalStateProvider/DashboardProvider and LibraryTemplate is a clean way to avoid a second fetch shape; the "silent" first client read (Library.tsx serverPainted ref) correctly avoids a loading flash without introducing a stale-closure or hook-order problem — useLockBodyScroll/other hooks in Modal.tsx all run before the new if (typeof document === 'undefined') return null; early return, so no rules-of-hooks violation. Private-shelf filtering happens once, server-side, before the object lookup (forFirstPaint filters shelves before trimObject/the standing search in the page), so a book on a private shelf correctly never reaches ObjectArticle, objectMeta, or the sitemap. JSON-LD injection is already escaped upstream in SeoGenerator.tsx (unrelated to this diff). The sitemap route escapes XML entities and fails soft (keeps the working URLs) rather than 500ing. Font sizes used in the new SCSS (24/16/12/14px) are all on the font passport, and colors are token-based.

Found one substantive issue (posted inline on src/lib/library/noteHtml.ts):

The new hand-rolled server-side sanitizer's SIMPLE_TAGS allowlist doesn't match the canonical note dialect (EDITOR_TAGS in src/lib/library/richText.ts) that the client dialog renders the same field through (ObjectOverviewModal → toEditorHtml). It's missing strike/del and adds ul/ol/li (unstyled in ObjectArticle.module.scss, not part of the dialect). A note containing <strike>/<del> would render correctly in the client dialog but show up as escaped literal tag text on this new server-rendered page — the two renders of the same note can disagree. I traced several potential bypasses in the regex-based escape/allowlist reconstruction (attribute injection, javascript:/protocol-relative hrefs, double-encoding) and didn't find an actual XSS escape — it fails safe by reconstructing tags from scratch rather than passing attacker attributes through — but it duplicates security-sensitive logic that isomorphic-dompurify (already a dependency, already called from plain lib code server-side in descriptionLinks.ts's linkDescriptionUrls) may already solve without hand-rolling. Worth either deriving SIMPLE_TAGS from EDITOR_TAGS directly, or checking whether isomorphic-dompurify genuinely can't be used from getServerSideProps (the comment asserts it reaches for document and crashes, but the same package apparently already runs server-side elsewhere in this codebase).

Minor, non-blocking observations (not posted inline):

  • findLibraryId's paginated username→id scan is unchanged in shape from the pre-existing getPublicLibrarySeo lookup, just now also exercised for object sub-pages and duplicated again in library-sitemap.xml.ts — not a regression, but three independent O(libraries) scans across the codebase for the same lookup could be worth consolidating at some point.
  • ObjectOverviewModal reads attributes.description directly (falsy check) for its "no description yet" placeholder; since non-requested objects have description stripped from the SSR payload (trimObject), briefly opening a different book's dialog before the background "silent" reload finishes could flash an empty note for a moment. Self-corrects once the reload lands; likely not worth guarding against.

Not verified: I wasn't able to run yarn lint/yarn tsc/yarn check:library — Bash commands required interactive approval that wasn't granted in this run. If you'd like me to actually execute the build/lint/test gates from scripts/release/README.md on a future pass, allow the relevant Bash commands.
· branch feat/library-server-rendered

Comment thread src/lib/library/noteHtml.ts Outdated
Comment on lines +13 to +24
const SIMPLE_TAGS = [
'strong',
'b',
'em',
'i',
's',
'p',
'br',
'ul',
'ol',
'li',
];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SIMPLE_TAGS doesn't actually match the note dialect it claims to implement.

The client renders the same description field via toEditorHtml (src/lib/library/richText.ts), whose EDITOR_TAGS is ['p','br','strong','em','s','b','i','strike','del','a']. This list is missing strike and del but adds ul/ol/li (which aren't in EDITOR_TAGS, and ObjectArticle.module.scss has no list styling for them either).

Concretely: a note that still contains <strike> or <del> (e.g. legacy data saved before toEditorHtml's normalization pass, or any value written some other way) will render as strikethrough in the client dialog (ObjectOverviewModal) but show up as escaped, visible <strike>text</strike> literal text on this server-rendered book page — the two renders of the same note disagree, which undercuts the PR's stated goal that the object page renders exactly what the owner wrote.

Also worth double-checking: isomorphic-dompurify is already a dependency and is already called from plain (non-'use client') lib code in this same folder — descriptionLinks.ts's linkDescriptionUrls calls DOMPurify.sanitize(html, { RETURN_DOM_FRAGMENT: true, ... }), which requires DOM support and evidently works in that context. If it also works from getServerSideProps, reusing it here (with ALLOWED_TAGS sourced from richText.ts's EDITOR_TAGS) would remove the need for a second, hand-maintained regex-based sanitizer for the same security-sensitive content, and the two renders could never drift apart again. I didn't find an actual escape in the regex approach as written, but duplicating an allowlist that's meant to be exhaustive (src/lib/library/richText.ts's comment: "Everything that reaches the editor or leaves it passes through here") is exactly the kind of drift this codebase's own conventions warn about elsewhere (e.g. the tag/description character caps are deliberately read from one shared constant "so the counter and the validator cannot drift apart").

Fix this →

CLAUDE.md had grown to 624 lines, about 430 of them Library passports,
contracts and release gates that every session on this repo paid for
whether or not it touched the Library. They now live in LIBRARY.md at the
root, in their original order and wording, and CLAUDE.md keeps a seven-line
pointer that says when to read it. Not imported on purpose: the file is
read when the Library is the work. Every non-blank line of the old file is
in one of the two new ones; the split was checked line by line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 10, 2026 •

Copy link
Copy Markdown

Claude finished @manager's task in 2m 46s —— View job


Review: Library server-rendered page (#222) — updated after new commit

  • Diffed the new commit against the previously-reviewed state
  • Verified content fidelity of the CLAUDE.md → LIBRARY.md split
  • Re-checked whether prior findings still apply
  • Posted new finding + summary

What changed since the last review: one new commit, b986a4c ("docs: the Library charter moves to LIBRARY.md"). It's docs-only — no files under src/ or elsewhere changed. It cuts ~430 Library-specific lines out of CLAUDE.md into a new root LIBRARY.md, leaving a short pointer behind.

Split fidelity: diffed the pre-split CLAUDE.md (commit 6e03ebb) against the concatenation of the new CLAUDE.md + LIBRARY.md. Every non-blank line from the old file is present in one of the two new files — content is reordered/relocated, not lost, matching the commit message's claim.

New finding (posted inline on CLAUDE.md:76): the new pointer paragraph tells future agents to read LIBRARY.md before touching mcp/library, and mentions "the MCP." No mcp/ directory exists anywhere in this repo (git ls-files | grep '^mcp/' is empty), and neither the old CLAUDE.md nor the new LIBRARY.md document a Library-specific MCP server — this text wasn't moved from the old file, it's new. Either drop the mcp/library/"the MCP" reference or clarify it's describing something outside this repo, so it doesn't send a future agent hunting for a path that isn't there.

Prior findings — still open, unaffected by this commit: since no code files changed, my earlier review stands as-is. In particular, src/lib/library/noteHtml.ts's SIMPLE_TAGS allowlist still doesn't match EDITOR_TAGS in src/lib/library/richText.ts (missing strike/del, adds unstyled ul/ol/li), so a note containing <strike>/<del> will still render differently in the client dialog vs. this server-rendered page. Not re-flagged as a new inline comment since it's unchanged from before.

Not run: yarn lint/yarn tsc/yarn check:library — no node_modules in this checkout and no code changed since the last pass that would need re-verifying, so I didn't install and run them this round.
· branch feat/library-server-rendered

Comment thread CLAUDE.md

Everything about the Library lives in `LIBRARY.md` at the repo root: every
passport, contract, release gate and the MCP. Read it before touching anything
under `src/*/library`, `src/pages/library`, `mcp/library` or the Library CMS

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The allowlist now equals EDITOR_TAGS in richText.ts less the anchor, which
is rebuilt with its address checked: strike and del pass, lists do not, so
the dialog and the book page never read one note two ways.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 10, 2026 •

Copy link
Copy Markdown

Claude finished @manager's task in 1m 10s —— View job


Review: Library server-rendered page (#222) — updated after new commit

  • Diffed the new commit against the previously-reviewed state
  • Verified the fix against EDITOR_TAGS in richText.ts
  • Re-checked whether prior findings still apply
  • Posted summary

What changed since the last review: one new commit, 3f8480f ("fix(library): the server reads a note in the editor's own dialect"), touching only src/lib/library/noteHtml.ts (+4/-12).

This resolves my earlier finding. SIMPLE_TAGS is now ['p', 'br', 'strong', 'em', 's', 'b', 'i', 'strike', 'del'] — exactly EDITOR_TAGS from src/lib/library/richText.ts minus a (which was already handled separately via the checked-address rebuild at noteHtml.ts:55-66). ul/ol/li are gone, strike/del are back. I diffed the two lists by hand and they now match token-for-token. The dialog (toEditorHtml) and this server-rendered book page will no longer disagree on strikethrough/del markup, and the drift I flagged (extra unstyled list tags, missing strike/del) is closed.

The rest of the sanitizer is unchanged: escape-everything-first then allowlist-restore, safeHref still requires https?:// and a parseable URL, and open/close <a> tags are still balanced via the opened counter. No new issues introduced by this change.

Prior finding — still open, unaffected by this commit (docs-only, not re-flagged inline since nothing changed): CLAUDE.md's new "## Library" pointer paragraph still references mcp/library / "the MCP", which doesn't exist anywhere in this repo.

Not run: yarn lint/yarn tsc/yarn check:library — no node_modules in this checkout. The change is a 4-line constant-list edit with an obvious, hand-verifiable correctness argument (list equality against EDITOR_TAGS), so I don't think this gates merge, but flagging per the repo's release-gate rules in case a real run is wanted before staging.
· branch feat/library-server-rendered

@manager

manager commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Review dispositions, commit 3f8480f:

  1. SIMPLE_TAGS vs EDITOR_TAGS: fixed. The allowlist now equals the editor's list less the anchor, which is rebuilt with its address checked: strike and del pass, lists do not. Checked: <strike>/<del> render, <ul> stays escaped, <script> stays escaped.
  2. mcp/library in the CLAUDE.md pointer: disproved as a defect. That directory lands in Library: five tools an agent may hold a library by #221, which merges into dev in the same release as this PR; the pointer is written for the tree both leave behind.

@manager
manager merged commit 1fc947d into dev Sep 10, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant