diff --git a/LIBRARY.md b/LIBRARY.md index 4a4b3c91..c2e0edc3 100644 --- a/LIBRARY.md +++ b/LIBRARY.md @@ -534,13 +534,33 @@ the owner's own view arrives without the shelves blinking out. - A dialog cannot be server-rendered: `Modal` returns null where there is no document, and the browser opens it on arrival. Any new portal follows that rule or it takes the whole response down at an object's address. -- `/library-sitemap.xml` lists every public library and every object on a - public shelf. Strapi's own sitemap plugin does not know these URLs: on - 2026-09-10 not one of them was in it. `public/robots.txt` names both +- `/library-sitemap.xml` lists the libraries offered to search and every object + on a public shelf of one. Strapi's own sitemap plugin does not know these + URLs: on 2026-09-10 not one of them was in it. `public/robots.txt` names both sitemaps, but the file served on keepsimple.io is not this one (it carries a line the repo never had), so a robots change reaches production only through The Order. +## Which library search is offered, and who a library is credited to + +Wolf's decision of 2026-09-12: his library is the one offered to search. +`src/lib/library/credit.ts` holds that list and nothing else decides it. + +- A library named there is listed in `/library-sitemap.xml` and its pages carry + `index, follow`. Every other library is left out of the sitemap and answers + `noindex, nofollow` through `SeoGenerator`'s `noIndex`, which changes the + robots line and no other tag: the page a reader opens from a shared link is + unchanged. Offering one more library to search is one entry in that file. +- The gate reads the address, not the library the server managed to load. A + failed CMS read must never make a library indexable by accident. +- The same file names the owner: `/library/wolf` is signed Wolf Alexanyan on + the page (the panel's Author line, the library card on `/library`, the line + above a book's note) and in the schema.org `author` and `creator`, which + carry the name and the library's own URL. The signature a reader sees and the + credit search records are one statement, never hidden text for crawlers. +- `yarn check:library:seo` covers all of it: the list, the display names, the + Person entry and both robots lines. + ### Library object article design passport - Palette: existing paper tokens. `--white-200` page, `--beige` border, diff --git a/scripts/release/library-seo-check.cjs b/scripts/release/library-seo-check.cjs index 71011852..90fa6fa4 100644 --- a/scripts/release/library-seo-check.cjs +++ b/scripts/release/library-seo-check.cjs @@ -29,6 +29,10 @@ function load(file, mocks = {}) { return load(`src/constants/${id.slice(11)}.ts`, mocks); if (id.startsWith('@lib/')) return load(`src/lib/${id.slice(5)}.ts`, mocks); + // seo.ts reaches into @utils for the media URL resolver; without this + // the whole check died on its first import and the gate could not run. + if (id.startsWith('@utils/')) + return load(`src/utils/${id.slice(7)}.ts`, mocks); if (id.startsWith('.')) return load(path.resolve(path.dirname(file), `${id}.ts`), mocks); return require(id); @@ -99,6 +103,26 @@ async function main() { "Wolf Alexanyan's personal library, collected since 2007. Includes personal notes and precise recommendations.", ); assert(wolf.image.endsWith('/wolf-library-v1.png')); + // Credit and search, the rule Wolf set on 2026-09-12: his library is the one + // offered to search, and every library is signed with its owner's name in the + // same words the page shows. + const { isSearchableLibrary, ownerDisplayName } = load( + 'src/lib/library/credit.ts', + ); + assert(isSearchableLibrary('wolf')); + assert(isSearchableLibrary(' WOLF ')); + for (const name of ['mary38', 'lemongrass', 'alinamarg', '', null]) + assert(!isSearchableLibrary(name)); + assert.equal(ownerDisplayName('wolf'), 'Wolf Alexanyan'); + assert.equal(ownerDisplayName('mary38'), 'mary38'); + assert.equal(ownerDisplayName(null), null); + // Field by field: objects built inside the vm carry that realm's prototype, + // so a strict deep comparison against a literal here never matches. + assert.equal(wolf.schema.author['@type'], 'Person'); + assert.equal(wolf.schema.author.name, 'Wolf Alexanyan'); + assert.equal(wolf.schema.author.url, 'https://keepsimple.io/library/wolf'); + assert.equal(wolf.schema.creator.name, 'Wolf Alexanyan'); + assert.equal(seo.schema.author.name, 'Reader'); const originalDomain = process.env.NEXT_PUBLIC_DOMAIN; process.env.NEXT_PUBLIC_DOMAIN = 'https://staging.keepsimple.io'; assert(librarySeo(entry).image.startsWith('https://staging.keepsimple.io/')); @@ -205,7 +229,9 @@ async function main() { }, }); assert.equal((await api.getPublicLibrarySeo('reader')).title, seo.title); - assert.equal(calls.length, 2); + // The library list, the library itself, then its tags: the tag read joined + // the view when the page began server-rendering its right panel. + assert.deepEqual(calls, ['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/api/libraries', '/api/libraries/2', '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/api/tags']); const Generator = load('src/components/SeoGenerator/SeoGenerator.tsx', { 'next/head': ({ children }) => React.createElement(React.Fragment, null, children), @@ -242,6 +268,34 @@ async function main() { assert(html.includes(seo.image)); assert(!html.includes('Alexanyan')); assert(!html.includes('SECRET')); + assert(html.includes('content="index, follow"')); + // A library that is not offered to search keeps every other tag it had: the + // page is unchanged for a reader holding the link, and only the robots line + // turns it away. + const unlisted = renderToStaticMarkup( + React.createElement(Generator, { + strapiSEO: { + title: seo.title, + pageTitle: seo.title, + description: seo.description, + }, + schemaOverride: seo.schema, + noIndex: true, + largeImage: true, + omitDefaultAuthor: true, + ogTags: { + ogTitle: seo.title, + ogDescription: seo.description, + ogType: 'website', + ogImage: { + data: { attributes: { url: '', staticUrl: seo.image } }, + }, + }, + }), + ); + assert(unlisted.includes('content="noindex, nofollow"')); + assert(unlisted.includes('rel="canonical"')); + assert(unlisted.includes(seo.image)); const legacy = renderToStaticMarkup( React.createElement(Generator, { strapiSEO: { diff --git a/src/components/SeoGenerator/SeoGenerator.tsx b/src/components/SeoGenerator/SeoGenerator.tsx index be3801c7..20f68dde 100644 --- a/src/components/SeoGenerator/SeoGenerator.tsx +++ b/src/components/SeoGenerator/SeoGenerator.tsx @@ -21,6 +21,12 @@ interface SeoGeneratorProps { createdDate?: string; isLongevityPage?: boolean; type?: string; + /** + * Keep this page out of search while it keeps every other tag it would + * normally carry. `forceNoIndex` below replaces the whole head with a + * robots line and a canonical; this one only decides the robots line. + */ + noIndex?: boolean; forceNoIndex?: boolean; canonicalOverride?: string; preloadImages?: string[]; @@ -56,6 +62,7 @@ const SeoGenerator: FC = ({ localizedSlug, isLongevityPage, type, + noIndex, forceNoIndex, canonicalOverride, preloadImages, @@ -209,7 +216,7 @@ const SeoGenerator: FC = ({ ))} - {pathname.includes('/user') ? ( + {noIndex || pathname.includes('/user') ? ( ) : ( - {username || libraryName} + {/* The owner's name where one is known, the address otherwise, so a + tile credits a library the way its own page does. */} + {ownerDisplayName(username) || libraryName} diff --git a/src/components/library/organisms/Sidebar/Sidebar.tsx b/src/components/library/organisms/Sidebar/Sidebar.tsx index 18f307e4..a232242a 100644 --- a/src/components/library/organisms/Sidebar/Sidebar.tsx +++ b/src/components/library/organisms/Sidebar/Sidebar.tsx @@ -17,6 +17,7 @@ import { useAnimatedList } from '@hooks/library/useAnimatedList'; import useLibraryEditing from '@hooks/library/useLibraryEditing'; import { useLockBodyScroll } from '@hooks/library/useLockBodyScroll'; +import { ownerDisplayName } from '@lib/library/credit'; import { libraryPath } from '@lib/library/libraryPath'; import { richTextLength } from '@lib/library/richText'; @@ -145,9 +146,15 @@ export function Sidebar() { // The public owner profile supplies the same identity and photo to every visitor. const slugName = /^\d+$/.test(currentLibraryId) ? '' : currentLibraryId; - const authorName = canEdit - ? accountData?.username || currentOwner?.username || 'Anonymous' - : currentOwner?.username || slugName || 'Anonymous'; + // Signed with the owner's name, not their address: the panel, the page title + // and the schema.org author now read the same words, so the credit a visitor + // sees is the credit search records. + const authorName = + ownerDisplayName( + canEdit + ? accountData?.username || currentOwner?.username + : currentOwner?.username || slugName, + ) || 'Anonymous'; const authorAvatarUrl = resolveStrapiUrl(currentOwner?.avatar) ?? resolveStrapiUrl(currentOwner?.picture) ?? diff --git a/src/lib/library/credit.ts b/src/lib/library/credit.ts new file mode 100644 index 00000000..fc8a2e88 --- /dev/null +++ b/src/lib/library/credit.ts @@ -0,0 +1,48 @@ +/** + * Who a library is credited to, and which library is offered to search. + * + * A username is an address, not a name: `/library/wolf` belongs to Wolf + * Alexanyan and every surface of it says so in the same words, on the page and + * in its schema.org entry, so the credit a visitor reads is the credit search + * records. + * + * Search is a separate decision and Wolf's (2026-09-12): only the libraries + * named here are listed in `library-sitemap.xml` and left indexable, and every + * other library answers `noindex, nofollow` while staying open to anyone + * holding its link. Offering one more library to search is one entry below. + */ + +const DISPLAY_NAMES: Record = { + wolf: 'Wolf Alexanyan', +}; + +/** The libraries offered to search engines. */ +const SEARCHABLE = new Set(Object.keys(DISPLAY_NAMES)); + +const normalize = (username?: string | null) => + username?.trim().toLowerCase() ?? ''; + +/** The owner as a reader should see them named; the username when unknown. */ +export const ownerDisplayName = (username?: string | null): string | null => { + const owner = normalize(username); + if (!owner) return null; + return DISPLAY_NAMES[owner] ?? username?.trim() ?? null; +}; + +/** True when this library is listed in the sitemap and left indexable. */ +export const isSearchableLibrary = (username?: string | null): boolean => + SEARCHABLE.has(normalize(username)); + +/** + * The owner as a schema.org `Person`, carrying the name search should credit. + * `url` is the library itself, the page the name is signed on. + */ +export const ownerPerson = (username?: string | null) => { + const name = ownerDisplayName(username); + if (!name) return null; + return { + '@type': 'Person' as const, + name, + url: `https://keepsimple.io/library/${normalize(username)}`, + }; +}; diff --git a/src/lib/library/seo.ts b/src/lib/library/seo.ts index bf26c3e1..e38be257 100644 --- a/src/lib/library/seo.ts +++ b/src/lib/library/seo.ts @@ -5,6 +5,7 @@ import { DEFAULT_SEO } from '@constants/library/seo.config'; import type { StrapiLibraryEntry } from '@local-types/library/library'; import type { IObject } from '@local-types/library/object'; +import { ownerDisplayName, ownerPerson } from './credit'; import { libraryPath } from './libraryPath'; import { objectSlug } from './objectSlug'; @@ -38,7 +39,7 @@ export function librarySeo(library?: StrapiLibraryEntry) { const attributes = library?.attributes; const username = attributes?.user?.data?.attributes.username; const isWolf = username?.toLowerCase() === 'wolf'; - const displayName = isWolf ? 'Wolf Alexanyan' : username; + const displayName = ownerDisplayName(username); const title = displayName ? isWolf ? "Wolf Alexanyan's Library | Collected since 2007" @@ -85,7 +86,12 @@ export function librarySeo(library?: StrapiLibraryEntry) { description, url, image, - ...(username ? { author: { '@type': 'Person', name: displayName } } : {}), + // The library is credited to its owner by name, the same name the page + // carries in its Author panel, so the signature a reader sees and the one + // a crawler reads are one statement. + ...(username + ? { author: ownerPerson(username), creator: ownerPerson(username) } + : {}), isPartOf: { '@type': 'WebSite', name: 'KeepSimple', @@ -185,7 +191,10 @@ export function objectSeo( ? { review: { '@type': 'Review', - author: { '@type': 'Person', name: owner }, + author: ownerPerson(base.username) ?? { + '@type': 'Person', + name: owner, + }, ...(note ? { reviewBody: clamp(note, 5000) } : {}), ...(typeof rating === 'number' ? { diff --git a/src/pages/library-sitemap.xml.ts b/src/pages/library-sitemap.xml.ts index e559547b..0af86f60 100644 --- a/src/pages/library-sitemap.xml.ts +++ b/src/pages/library-sitemap.xml.ts @@ -3,6 +3,7 @@ import type { GetServerSideProps } from 'next'; import type { StrapiLibrariesResponse } from '@local-types/library/library'; +import { isSearchableLibrary } from '@lib/library/credit'; import { libraryPath } from '@lib/library/libraryPath'; import { objectSlug } from '@lib/library/objectSlug'; @@ -17,6 +18,10 @@ import { objectSlug } from '@lib/library/objectSlug'; * * Public only, and public in the same sense the pages are: a private shelf is * left out, and so is everything standing on it. + * + * Offered, not merely public: since 2026-09-12 only the libraries named in + * `isSearchableLibrary` are listed here, and the rest carry `noindex` on their + * own pages. A library left out is still open to anyone holding its link. */ const ORIGIN = 'https://keepsimple.io'; @@ -65,7 +70,7 @@ export const getServerSideProps: GetServerSideProps = async ({ res }) => { for (const library of data.data) { const username = library.attributes.user?.data?.attributes.username; - if (!username) continue; + if (!username || !isSearchableLibrary(username)) continue; const path = libraryPath(username); const shelves = (library.attributes.singleShelves?.data ?? []).filter( diff --git a/src/pages/library/[username]/[[...object]].tsx b/src/pages/library/[username]/[[...object]].tsx index a74d8826..0b09b586 100644 --- a/src/pages/library/[username]/[[...object]].tsx +++ b/src/pages/library/[username]/[[...object]].tsx @@ -7,6 +7,7 @@ import type { StrapiLibraryEntry } from '@local-types/library/library'; import type { IObject } from '@local-types/library/object'; import type { LibraryTag } from '@local-types/library/tag'; +import { isSearchableLibrary } from '@lib/library/credit'; import { objectIdFromSlug } from '@lib/library/objectSlug'; import { librarySeo, objectSeo } from '@lib/library/seo'; import { readSidebarCollapsedForRequest } from '@lib/library/sidebarPanel'; @@ -41,6 +42,12 @@ type LibraryPageProps = { objectMeta: ReturnType | null; /** The shelf it stands on. */ objectShelf: string | null; + /** + * Whether this library is offered to search. Only the libraries named in + * `isSearchableLibrary` are; the rest stay open to anyone with the link and + * tell a crawler to pass (Wolf, 2026-09-12). + */ + searchable: boolean; }; // Optional catch-all so the library and a single object share one page module: @@ -60,6 +67,7 @@ const LibraryPage: NextPage = ({ object, objectMeta, objectShelf, + searchable, }) => { // At a book's address the page is that book: its own title, its own // description, its own cover and its own schema.org entry. Without this all @@ -78,6 +86,7 @@ const LibraryPage: NextPage = ({