Skip to content
Open
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
26 changes: 23 additions & 3 deletions LIBRARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
56 changes: 55 additions & 1 deletion scripts/release/library-seo-check.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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/'));
Expand Down Expand Up @@ -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, ['/api/libraries', '/api/libraries/2', '/api/tags']);
const Generator = load('src/components/SeoGenerator/SeoGenerator.tsx', {
'next/head': ({ children }) =>
React.createElement(React.Fragment, null, children),
Expand Down Expand Up @@ -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: {
Expand Down
9 changes: 8 additions & 1 deletion src/components/SeoGenerator/SeoGenerator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -56,6 +62,7 @@ const SeoGenerator: FC<SeoGeneratorProps> = ({
localizedSlug,
isLongevityPage,
type,
noIndex,
forceNoIndex,
canonicalOverride,
preloadImages,
Expand Down Expand Up @@ -209,7 +216,7 @@ const SeoGenerator: FC<SeoGeneratorProps> = ({
))}
<meta httpEquiv="X-UA-Compatible" content="ie=edge" />
<meta name="theme-color" content="#1e2023" />
{pathname.includes('/user') ? (
{noIndex || pathname.includes('/user') ? (
<meta name="robots" content={'noindex, nofollow'} />
) : (
<meta
Expand Down
5 changes: 4 additions & 1 deletion src/components/library/organisms/LibraryCard/LibraryCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Image from 'next/image';
import { useRouter } from 'next/router';
import React, { JSX } from 'react';

import { ownerDisplayName } from '@lib/library/credit';
import { libraryPath } from '@lib/library/libraryPath';

import LibraryMark from '@icons/navbar/library.svg';
Expand Down Expand Up @@ -93,7 +94,9 @@ export function LibraryCard(props: LibraryCardProps): JSX.Element {
className={styles.title}
variant={TypographyVariant.SubtitleSecondaryAlt}
>
{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}
</Text>
</div>

Expand Down
13 changes: 10 additions & 3 deletions src/components/library/organisms/Sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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) ??
Expand Down
48 changes: 48 additions & 0 deletions src/lib/library/credit.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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)}`,
};
};
15 changes: 12 additions & 3 deletions src/lib/library/seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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'
? {
Expand Down
7 changes: 6 additions & 1 deletion src/pages/library-sitemap.xml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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';
Expand Down Expand Up @@ -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(
Expand Down
12 changes: 12 additions & 0 deletions src/pages/library/[username]/[[...object]].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -41,6 +42,12 @@ type LibraryPageProps = {
objectMeta: ReturnType<typeof objectSeo> | 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:
Expand All @@ -60,6 +67,7 @@ const LibraryPage: NextPage<LibraryPageProps> = ({
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
Expand All @@ -78,6 +86,7 @@ const LibraryPage: NextPage<LibraryPageProps> = ({
<ShareSelectionProvider>
<SeoGenerator
schemaOverride={meta.schema}
noIndex={!searchable}
largeImage
imageWidth={meta.imageWidth ?? undefined}
imageHeight={meta.imageHeight ?? undefined}
Expand Down Expand Up @@ -182,6 +191,9 @@ export const getServerSideProps: GetServerSideProps<
object: standing?.entry ?? null,
objectShelf: standing?.shelf ?? null,
objectMeta: standing ? objectSeo(standing.entry, seo) : null,
// The address decides it, not the library the server managed to read: a
// library that failed to load must not become indexable by accident.
searchable: isSearchableLibrary(seo.username ?? username),
},
};
};
Loading