feat(library): add static pagination and page-one redirects

This commit is contained in:
DeCentN2Madness committed 2026-10-09 20:00:26 -07:00
1 parent b20c253383
commit 6f5ac3c0d2
18 files changed
+895 -45

No files matched your search

+4 -1
View File
@@ -3,7 +3,10 @@ import fs from 'node:fs/promises';
const redirects = [
{ from: '/aba', to: '/aba-therapy', status: 301, reason: 'Brief legacy alias to preserved URL' },
{ from: '/autismevaluations', to: '/autism-evaluations', status: 301, reason: 'Brief legacy alias to preserved URL' },
{ from: '/learnersocialclub', to: '/learner-social-club', status: 301, reason: 'Brief legacy alias to preserved URL' }
{ from: '/learnersocialclub', to: '/learner-social-club', status: 301, reason: 'Brief legacy alias to preserved URL' },
{ from: '/library/page/1', to: '/library', status: 301, reason: 'Library first-page alias' },
{ from: '/es/library/page/1', to: '/es/library', status: 301, reason: 'Spanish Library first-page alias' },
{ from: '/ar/library/page/1', to: '/ar/library', status: 301, reason: 'Arabic Library first-page alias' }
];
await fs.writeFile(new URL('../src/data/redirects.json', import.meta.url), `${JSON.stringify(redirects, null, 2)}\n`);
await fs.writeFile(new URL('../reports/redirect-map.csv', import.meta.url),
+173 -4
View File
@@ -9,6 +9,7 @@ import fg from 'fast-glob';
import matter from 'gray-matter';
import { load } from 'cheerio';
import { createLibraryCatalog, extractMarkdownHeadings } from '../src/utils/library-catalog.ts';
import { createLibraryPaginationPublications, libraryPageRoute, paginateLibraryItems } from '../src/utils/library-pagination.ts';
import { boundedSearchOffset, normalizeLibrarySearchText, parseLegacyLibrarySearchState, searchLibrary } from '../src/utils/library-search.ts';
import { createPublicationManifest, renderSitemap, robotsFor } from '../src/utils/publication-policy.ts';
import { createLibrarySearchPublication } from '../src/utils/search-publication.ts';
@@ -35,6 +36,8 @@ async function authorEntries(root = project) {
const entries = await contentEntries();
const manifest = createPublicationManifest(entries, site);
const catalog = createLibraryCatalog(manifest, await authorEntries());
const paginationPublications = createLibraryPaginationPublications(catalog, manifest, site);
const routeManifest = [...manifest, ...paginationPublications].sort((left, right) => left.route.localeCompare(right.route, 'en'));
function record(overrides = {}) {
return {
@@ -63,6 +66,72 @@ test('catalog ordering is date descending with route ascending as the stable tie
}
});
test('pagination handles empty, boundary and exact-multiple catalogs without empty numbered pages', () => {
for (const [count, totalPages, pageSizes] of [
[0, 0, [0]], [1, 1, [1]], [9, 1, [9]], [10, 1, [10]],
[11, 2, [10, 1]], [20, 2, [10, 10]]
]) {
const items = Array.from({ length: count }, (_, index) => index);
const root = paginateLibraryItems(items, 1);
assert.equal(root.totalItems, count);
assert.equal(root.totalPages, totalPages);
assert.equal(root.items.length, pageSizes[0]);
for (let page = 2; page <= totalPages; page++) {
assert.equal(paginateLibraryItems(items, page).items.length, pageSizes[page - 1]);
}
assert.equal(paginateLibraryItems(items, Math.max(2, totalPages + 1)), null);
}
for (const page of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1]) {
assert.equal(paginateLibraryItems([1], page), null);
}
assert.equal(paginateLibraryItems([1], 1, 0), null);
});
test('current catalogs generate only five numbered Library routes with expected slices', () => {
assert.deepEqual(paginationPublications.map(({ route }) => route), [
'/es/library/page/2', '/library/page/2', '/library/page/3', '/library/page/4', '/library/page/5'
]);
assert.deepEqual(Object.fromEntries(['en', 'es', 'ar'].map((locale) => {
const root = paginateLibraryItems(catalog.byLocale[locale], 1);
return [locale, { total: root.totalItems, pages: root.totalPages }];
})), {
en: { total: 46, pages: 5 }, es: { total: 12, pages: 2 }, ar: { total: 7, pages: 1 }
});
assert.deepEqual([1, 2, 3, 4, 5].map((page) => paginateLibraryItems(catalog.byLocale.en, page).items.length), [10, 10, 10, 10, 6]);
assert.deepEqual([1, 2].map((page) => paginateLibraryItems(catalog.byLocale.es, page).items.length), [10, 2]);
assert.equal(paginateLibraryItems(catalog.byLocale.ar, 1).items.length, 7);
for (const publication of paginationPublications) {
assert.equal(publication.canonical, site + publication.route);
assert.equal(publication.sitemapEligible, true);
assert.deepEqual(publication.translations, []);
}
});
test('generated page inventory follows catalog growth and removal without manual routes', () => {
const english = catalog.byLocale.en;
const withFiveMore = [...english, ...english.slice(0, 5)];
const grown = { ...catalog, byLocale: { ...catalog.byLocale, en: withFiveMore } };
assert.ok(createLibraryPaginationPublications(grown, manifest, site).some(({ route }) => route === '/library/page/6'));
const reduced = { ...catalog, byLocale: { ...catalog.byLocale, en: english.slice(0, 40) } };
assert.deepEqual(createLibraryPaginationPublications(reduced, manifest, site)
.filter(({ libraryPage }) => libraryPage.locale === 'en').map(({ route }) => route), [
'/library/page/2', '/library/page/3', '/library/page/4'
]);
});
test('page routes reject invalid numbers and generated descriptors reject reserved-family collisions', () => {
assert.equal(libraryPageRoute('en', 1), '/library');
assert.equal(libraryPageRoute('es', 2), '/es/library/page/2');
for (const page of [0, -1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 1]) {
assert.throws(() => libraryPageRoute('en', page), /Invalid Library page number/);
}
const root = manifest.find((page) => page.route === '/library');
assert.throws(() => createLibraryPaginationPublications(catalog, [
...manifest,
{ ...root, route: '/library/page/2', url: `${site}/library/page/2`, canonical: `${site}/library/page/2` }
], site), /Reserved Library pagination route collision/);
});
test('catalog fixtures include new eligible posts automatically and exclude drafts', () => {
const author = [{ id: 'en/author', data: { lang: 'en', slug: 'author', name: 'Example Author' } }];
const make = (slug, draft = false) => ({
@@ -73,6 +142,7 @@ test('catalog fixtures include new eligible posts automatically and exclude draf
const fixture = createLibraryCatalog(fixtureManifest, author);
assert.deepEqual(fixture.all.map(({ route }) => route), ['/library/new-post']);
assert.deepEqual(fixture.all[0].search.headings, ['Searchable heading']);
assert.equal(paginateLibraryItems(fixture.byLocale.en, 1).totalItems, 1);
});
test('catalog rejects duplicate routes and missing or ambiguous locale authors', () => {
@@ -211,8 +281,12 @@ test('build emits one deterministic, minimal UTF-8 index per locale', () => {
}
});
test('Library pages retain every server-rendered article and expose progressive search controls', () => {
for (const [route, locale, count] of [['/library', 'en', 46], ['/es/library', 'es', 12], ['/ar/library', 'ar', 7]]) {
test('Library roots and numbered pages render exact slices with progressive search controls', () => {
for (const [route, locale, count] of [
['/library', 'en', 10], ['/library/page/2', 'en', 10], ['/library/page/3', 'en', 10],
['/library/page/4', 'en', 10], ['/library/page/5', 'en', 6],
['/es/library', 'es', 10], ['/es/library/page/2', 'es', 2], ['/ar/library', 'ar', 7]
]) {
const $ = load(artifacts.get('staging').html.get(route));
assert.equal($('.blog-list .blog-card').length, count, route);
assert.equal($('[data-library-search][hidden]').length, 1, route);
@@ -220,14 +294,91 @@ test('Library pages retain every server-rendered article and expose progressive
assert.match($('noscript').html(), /library-search__noscript/, route);
assert.equal($('html').attr('lang'), locale, route);
assert.equal($('html').attr('dir'), locale === 'ar' ? 'rtl' : 'ltr', route);
assert.equal($('main h1').length, 1, route);
assert.ok($('#library-archive-' + locale + ' .blog-list').length, route);
}
});
test('generated Library routes contain every eligible article exactly once and omit invalid pages', () => {
const html = artifacts.get('staging').html;
const routeGroups = {
en: ['/library', '/library/page/2', '/library/page/3', '/library/page/4', '/library/page/5'],
es: ['/es/library', '/es/library/page/2'],
ar: ['/ar/library']
};
for (const [locale, routes] of Object.entries(routeGroups)) {
const links = routes.flatMap((route) => {
const $ = load(html.get(route));
return $('.blog-list .blog-card h3 a').map((_, link) => $(link).attr('href')).get();
});
assert.equal(links.length, catalog.byLocale[locale].length, locale);
assert.equal(new Set(links).size, links.length, locale);
assert.deepEqual(links, catalog.byLocale[locale].map(({ route }) => route), locale);
}
for (const route of [
'/library/page/1', '/library/page/6', '/library/page/0', '/library/page/02', '/library/page/not-a-number',
'/es/library/page/1', '/es/library/page/3', '/ar/library/page/1', '/ar/library/page/2'
]) assert.ok(!html.has(route), route);
});
test('pagination navigation uses roots for page 1, valid boundaries, aria-current and localized labels', () => {
const html = artifacts.get('staging').html;
for (const [route, current, previous, next] of [
['/library', '1', null, '/library/page/2'],
['/library/page/3', '3', '/library/page/2', '/library/page/4'],
['/library/page/5', '5', '/library/page/4', null],
['/es/library', '1', null, '/es/library/page/2'],
['/es/library/page/2', '2', '/es/library', null]
]) {
const $ = load(html.get(route));
const pager = $('.library-pagination');
assert.equal(pager.length, 1, route);
assert.equal(pager.find('[aria-current="page"]').text(), current, route);
assert.equal(pager.find('a[rel="prev"]').attr('href'), previous ?? undefined, route);
assert.equal(pager.find('a[rel="next"]').attr('href'), next ?? undefined, route);
assert.equal(pager.find('a[href$="/page/1"]').length, 0, route);
assert.ok(pager.attr('aria-label'), route);
}
assert.equal(load(html.get('/ar/library'))('.library-pagination').length, 0);
assert.match(load(html.get('/es/library'))('.library-pagination').attr('aria-label'), /biblioteca/i);
assert.equal(load(html.get('/ar/library'))('html').attr('dir'), 'rtl');
});
test('numbered pages keep full-locale search indexes and hide cards plus pager through one archive boundary', () => {
for (const [route, locale, expectedIndexCount] of [
['/library/page/3', 'en', 46], ['/es/library/page/2', 'es', 12], ['/ar/library', 'ar', 7]
]) {
const $ = load(artifacts.get('staging').html.get(route));
const search = $('[data-library-search]');
assert.equal(search.attr('data-archive-id'), `library-archive-${locale}`, route);
assert.equal($(`#library-archive-${locale} .blog-list`).length, 1, route);
assert.equal($(`#library-archive-${locale} .library-pagination`).length, locale === 'ar' ? 0 : 1, route);
assert.equal(artifacts.get('staging').indexes[locale].length, expectedIndexCount, route);
}
});
test('page-1 redirect registry preserves service aliases and defines only three Library aliases', async () => {
const redirects = JSON.parse(await readFile(path.join(project, 'src/data/redirects.json'), 'utf8'));
assert.deepEqual(redirects.slice(0, 3).map(({ from, to, status }) => ({ from, to, status })), [
{ from: '/aba', to: '/aba-therapy', status: 301 },
{ from: '/autismevaluations', to: '/autism-evaluations', status: 301 },
{ from: '/learnersocialclub', to: '/learner-social-club', status: 301 }
]);
assert.deepEqual(redirects.slice(3).map(({ from, to, status }) => ({ from, to, status })), [
{ from: '/library/page/1', to: '/library', status: 301 },
{ from: '/es/library/page/1', to: '/es/library', status: 301 },
{ from: '/ar/library/page/1', to: '/ar/library', status: 301 }
]);
assert.equal(new Set(redirects.map(({ from }) => from)).size, redirects.length);
const rewrites = await readFile(path.join(project, 'reports/nginx-rewrites.conf'), 'utf8');
for (const { from, to } of redirects.slice(3)) assert.ok(rewrites.includes(`rewrite ^${from}$ ${to} permanent;`));
});
test('/search is a noindex static Library-only compatibility route in both build modes', () => {
const publication = createLibrarySearchPublication(site);
for (const mode of ['staging', 'indexing']) {
const { html, sitemap } = artifacts.get(mode);
assert.equal(html.size, manifest.filter((item) => item.eligible).length + 1);
assert.equal(html.size, routeManifest.filter((item) => item.eligible).length + 1);
const $ = load(html.get('/search'));
assert.equal($('link[rel="canonical"]').attr('href'), `${site}/search`);
assert.equal($('meta[name="robots"]').attr('content'), robotsFor(publication, mode === 'indexing'));
@@ -236,7 +387,25 @@ test('/search is a noindex static Library-only compatibility route in both build
assert.equal($('main h1').length, 1);
assert.match($('main').text(), /Library articles/);
assert.ok(!sitemap.includes(`${site}/search`));
assert.equal(sitemap, renderSitemap(manifest, mode === 'indexing'));
assert.equal(sitemap, renderSitemap(routeManifest, mode === 'indexing'));
}
});
test('numbered pages are self-canonical, production-indexable, sitemap-listed and never gain hreflang', () => {
for (const mode of ['staging', 'indexing']) {
const { html, sitemap } = artifacts.get(mode);
for (const publication of paginationPublications) {
const $ = load(html.get(publication.route));
assert.equal($('link[rel="canonical"]').attr('href'), publication.url, publication.route);
assert.equal($('meta[name="robots"]').attr('content'), robotsFor(publication, mode === 'indexing'), publication.route);
assert.equal($('link[hreflang]').length, 0, publication.route);
assert.equal(sitemap.includes(`<loc>${publication.url}</loc>`), mode === 'indexing', publication.route);
assert.match($('title').text(), /(?:Page|Página) \d+$/u, publication.route);
}
}
for (const locale of ['en', 'es', 'ar']) {
const root = libraryPageRoute(locale, 1);
assert.equal(load(artifacts.get('indexing').html.get(root))('link[hreflang]').length > 0, true, root);
}
});
+16 -2
View File
@@ -9,6 +9,8 @@ import fg from 'fast-glob';
import matter from 'gray-matter';
import { load } from 'cheerio';
import { createPublicationManifest, hreflangLinksFor, normalizeRoute, publicationFor, renderSitemap, robotsFor, sitemapUrls, validateTranslationGraph } from '../src/utils/publication-policy.ts';
import { createLibraryCatalog } from '../src/utils/library-catalog.ts';
import { createLibraryPaginationPublications } from '../src/utils/library-pagination.ts';
import { createLibrarySearchPublication } from '../src/utils/search-publication.ts';
const project = fileURLToPath(new URL('../', import.meta.url));
@@ -24,6 +26,13 @@ async function readEntries(root) {
}));
}
async function readAuthors(root) {
return Promise.all((await fg('src/content/authors/**/*.{md,mdx}', { cwd: root })).sort().map(async (file) => ({
id: file.replace(/^src\/content\/authors\//, '').replace(/\.mdx?$/, ''),
data: matter(await readFile(path.join(root, file), 'utf8')).data
})));
}
function assertCanonical(html, expected) {
const $ = load(html);
const tags = $('head link[rel="canonical"]');
@@ -120,8 +129,13 @@ test('generated-head acceptance checks reject missing, duplicate and invalid can
const sourceEntries = await readEntries(project);
const actualManifest = createPublicationManifest(sourceEntries, site);
const sourceAuthors = await readAuthors(project);
const sitePublications = (manifest) => [
...manifest,
...createLibraryPaginationPublications(createLibraryCatalog(manifest, sourceAuthors), manifest, site)
].sort((left, right) => left.route.localeCompare(right.route, 'en'));
const generatedPublications = (manifest) => [
...manifest.filter((page) => page.eligible),
...sitePublications(manifest).filter((page) => page.eligible),
createLibrarySearchPublication(site)
];
@@ -355,7 +369,7 @@ for (const mode of ['staging', 'indexing']) {
test(`${mode} build: sitemap exactly matches eligible local indexable self-canonical routes`, () => {
const { sitemap, html, robots } = artifacts.get(mode);
assert.equal(sitemap, renderSitemap(fixtureManifest, mode === 'indexing'));
assert.equal(sitemap, renderSitemap(sitePublications(fixtureManifest), mode === 'indexing'));
const $ = load(sitemap, { xml: true });
const urls = $('loc').map((_, node) => $(node).text()).get();
assert.equal(new Set(urls).size, urls.length);
+31 -9
View File
@@ -9,6 +9,8 @@ import fg from 'fast-glob';
import matter from 'gray-matter';
import { load } from 'cheerio';
import { createPublicationManifest, hreflangLinksFor, renderSitemap, robotsFor } from '../src/utils/publication-policy.ts';
import { createLibraryCatalog } from '../src/utils/library-catalog.ts';
import { createLibraryPaginationPublications } from '../src/utils/library-pagination.ts';
import { createLibrarySearchPublication } from '../src/utils/search-publication.ts';
import { socialImageUrl } from '../src/utils/seo.ts';
import { blogPostingSchema, faqItemsFromSourceHtml, faqPageSchema, serializeJsonLd } from '../src/utils/structured-data.ts';
@@ -23,6 +25,7 @@ const prefix = (lang) => lang === 'en' ? '' : `/${lang}`;
const artifacts = new Map();
let temporary;
let manifest;
let routeManifest;
let runner;
async function writeEntry(collection, lang, slug, data = {}) {
@@ -58,6 +61,23 @@ const meta = ($, key) => $(`head meta[${key.startsWith('og:') ? 'property' : 'na
const jsonLd = ($) => $('script[type="application/ld+json"]').map((_, node) => JSON.parse($(node).text())).get();
const schemaOf = ($, type) => jsonLd($).filter((schema) => schema['@type'] === type);
const text = (value) => value.replace(/\s+/g, ' ').trim();
const libraryCardTitle = (articleRoute, mode = 'staging') => {
for (const [route, html] of artifacts.get(mode).html) {
if (!/^\/(?:es\/|ar\/)?library(?:\/page\/\d+)?$/.test(route)) continue;
const title = load(html)(`.blog-card h3 a[href="${articleRoute}"]`).text();
if (title) return title;
}
return '';
};
const libraryCardDate = (articleRoute, mode = 'staging') => {
for (const [route, html] of artifacts.get(mode).html) {
if (!/^\/(?:es\/|ar\/)?library(?:\/page\/\d+)?$/.test(route)) continue;
const $ = load(html);
const card = $('.blog-card').filter((_, element) => $(element).find('h3 a').attr('href') === articleRoute);
if (card.length) return card.find('time').text();
}
return '';
};
async function writeFaqFixture(slug, content, flags = {}) {
await writeFile(path.join(temporary, 'src/content/pages/en', `${slug}.mdx`), matter.stringify(
@@ -102,6 +122,12 @@ before(async () => {
return { id: parts.join('/').replace(/\.mdx?$/, ''), collection, data: matter(await readFile(path.join(temporary, file), 'utf8')).data };
}));
manifest = createPublicationManifest(entries, site);
const authors = await Promise.all((await fg('src/content/authors/**/*.{md,mdx}', { cwd: temporary })).map(async (file) => ({
id: file.replace(/^src\/content\/authors\//, '').replace(/\.mdx?$/, ''),
data: matter(await readFile(path.join(temporary, file), 'utf8')).data
})));
routeManifest = [...manifest, ...createLibraryPaginationPublications(createLibraryCatalog(manifest, authors), manifest, site)]
.sort((left, right) => left.route.localeCompare(right.route, 'en'));
// A preview Astro.site must not leak into social assets or publication metadata.
runner = `import {build} from ${JSON.stringify(pathToFileURL(path.join(project, 'node_modules/astro/dist/index.js')).href)};
await build({root:${JSON.stringify(temporary)},configFile:${JSON.stringify(path.relative(temporary, path.join(project, 'astro.config.mjs')))},site:'https://preview.example.invalid',logLevel:'silent',vite:{cacheDir:${JSON.stringify(path.join(temporary, '.vite'))}}});`;
@@ -163,8 +189,7 @@ test('explicit article H1 changes only the heading; SEO and card titles retain t
assert.equal($('main h1').children().length, 0, 'Overrides are text, not injected markup');
for (const tag of ['og:title', 'twitter:title']) assert.equal(meta($, tag).attr('content'), `SEO title (${lang})`);
assert.equal($('head title').text(), `SEO title (${lang})`);
const index = page(`${prefix(lang)}/library`);
assert.equal(index(`.blog-card h3 a[href="${route}"]`).text(), `SEO title (${lang})`);
assert.equal(libraryCardTitle(route), `SEO title (${lang})`);
}
});
@@ -179,8 +204,7 @@ test('production-supported English and Spanish article headings retain their ori
assert.equal($('main h1').text(), heading);
assert.notEqual(heading, record.title);
assert.equal($('head title').text(), record.title);
const index = page(`${prefix(lang)}/library`);
assert.equal(index(`.blog-card h3 a[href="${route}"]`).text(), record.title);
assert.equal(libraryCardTitle(route), record.title);
}
});
@@ -250,10 +274,10 @@ for (const mode of ['staging', 'indexing']) {
test(`${mode}: existing publication policy, metadata, language and navigation are unchanged`, () => {
const { html, sitemap } = artifacts.get(mode);
assert.deepEqual([...html.keys()].sort(), [
...manifest.filter((item) => item.eligible).map((item) => item.route),
...routeManifest.filter((item) => item.eligible).map((item) => item.route),
createLibrarySearchPublication(site).route
].sort());
assert.equal(sitemap, renderSitemap(manifest, mode === 'indexing'));
assert.equal(sitemap, renderSitemap(routeManifest, mode === 'indexing'));
for (const publication of manifest.filter((item) => item.eligible)) {
const { route, entry, canonical } = publication;
const $ = page(route, mode);
@@ -330,9 +354,7 @@ test('article and card calendar dates match declared dates even on a negative-of
const byline = article('.article-byline time');
assert.equal(byline.attr('datetime'), date.toISOString());
assert.equal(byline.text(), date.toLocaleDateString(data.lang, { day: '2-digit', month: 'long', year: 'numeric', timeZone: 'UTC' }));
const index = page(`${prefix(data.lang)}/library`);
const card = index('.blog-card').filter((_, el) => index(el).find('h3 a').attr('href') === publication.route);
assert.equal(card.find('time').text(), date.toLocaleDateString(data.lang, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' }));
assert.equal(libraryCardDate(publication.route), date.toLocaleDateString(data.lang, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' }));
}
});