@rathnasgala2/template 2.2.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -58,9 +58,21 @@ import {
58
58
  renderPagination,
59
59
  renderSlot,
60
60
  } from './skeleton.js';
61
+ import {
62
+ createComponents,
63
+ createMediaImage,
64
+ extractH2Headings,
65
+ lazyBodyImages,
66
+ plainText,
67
+ renderImage,
68
+ renderNewsletterPanel,
69
+ } from './components.js';
70
+ import { truncateAtWord } from './seo.js';
71
+ import { createStructuredData } from './structured-data.js';
72
+ import { icon } from './icons.js';
61
73
  import { getMessages } from './messages.js';
62
74
  import { resolveTextDirection } from './text-direction.js';
63
- import { routeSegmentForLabel } from './route-labels.js';
75
+ import { deriveAuthorSlugs, routeSegmentForLabel } from './route-labels.js';
64
76
  import { derivePublicBasePath, joinPublicRoute } from './route.js';
65
77
 
66
78
  /** Listing page size (index and per-year archive pages). Scoped decision:
@@ -132,23 +144,6 @@ function paginate(items, pageSize) {
132
144
  return pages;
133
145
  }
134
146
 
135
- /**
136
- * Resolve an author's display name by ID, failing closed on a dangling
137
- * reference (an adapter defect: `resolvedAuthorIds` is schema-guaranteed to
138
- * resolve).
139
- *
140
- * @param {ReadonlyMap<string, import('../../../types/index.d.ts').AuthorNormalized>} authorsById
141
- * @param {string} authorId
142
- * @returns {import('../../../types/index.d.ts').AuthorNormalized}
143
- */
144
- function requireAuthor(authorsById, authorId) {
145
- const author = authorsById.get(authorId);
146
- if (!author) {
147
- throw new Error(`internal: no author record for id ${authorId}`);
148
- }
149
- return author;
150
- }
151
-
152
147
  /**
153
148
  * @typedef {object} GeneratedPage
154
149
  * @property {'profile' | 'author' | 'article' | 'page' | 'index' | 'tag' | 'series' | 'archive'} kind
@@ -157,13 +152,21 @@ function requireAuthor(authorsById, authorId) {
157
152
  * @property {string} title the page's `<title>` text
158
153
  * @property {string} language the page's BCP-47 language tag
159
154
  * @property {string} [direction] the page's resolved base text direction (assigned by buildGeneratedPages's own return mapping)
160
- * @property {string} bodyHtml the page's complete `<main>` inner HTML,
161
- * starting with exactly one `<h1>`
162
- * @property {string} [breadcrumbHtml] an optional already-rendered breadcrumb
155
+ * @property {string} bodyHtml the page's complete `<main>` inner HTML
156
+ * (breadcrumb included), containing exactly one `<h1>`
163
157
  * @property {string} [description] an optional plain-text
164
158
  * description this page's SEO/Open-Graph/Twitter `<meta>` tags are built
165
159
  * from (never HTML, never the sanitized body — a distinct authored or
166
160
  * publication-level plain-text field)
161
+ * @property {string} [documentTitle] the `<title>` element text
162
+ * (`<Page> | <Publication>`; the home page is
163
+ * `<Publication> – <tagline>`); `title` stays the bare page name used for
164
+ * `og:title` and structured data
165
+ * @property {string} [jsonLd] the page's one embed-safe JSON-LD document
166
+ * (see `internal/structured-data.js`)
167
+ * @property {string} [socialImageAlt] alternative text for the social image
168
+ * @property {{publishedTime: string, modifiedTime?: string, authorUrls: string[], tags: string[]}} [articleMeta]
169
+ * the `article:*` Open Graph facts of an article page
167
170
  * @property {'website' | 'article' | 'profile'} [ogType]
168
171
  * the Open Graph `og:type` this page kind maps to; defaults to `'website'`
169
172
  * when omitted
@@ -255,15 +258,104 @@ function latestContentTimestamp(records) {
255
258
  );
256
259
  }
257
260
 
261
+ /**
262
+ * Group records by derived keys, preserving the input order within each
263
+ * bucket.
264
+ *
265
+ * @param {readonly import('../../../types/index.d.ts').ContentBuildRecord[]} records
266
+ * @param {(record: import('../../../types/index.d.ts').ContentBuildRecord) => readonly string[]} keysOf
267
+ * @returns {Map<string, import('../../../types/index.d.ts').ContentBuildRecord[]>}
268
+ */
269
+ function groupBy(records, keysOf) {
270
+ /** @type {Map<string, import('../../../types/index.d.ts').ContentBuildRecord[]>} */
271
+ const groups = new Map();
272
+ for (const record of records) {
273
+ for (const key of keysOf(record)) {
274
+ const bucket = groups.get(key) ?? [];
275
+ bucket.push(record);
276
+ groups.set(key, bucket);
277
+ }
278
+ }
279
+ return groups;
280
+ }
281
+
282
+ /** Number of articles after the featured one shown as cards on the home page. */
283
+ export const LATEST_CARD_COUNT = 6;
284
+ /** Number of related articles shown in "Keep reading". */
285
+ export const RELATED_COUNT = 3;
286
+ /** Minimum h2 headings an article needs before a contents list renders. */
287
+ export const CONTENTS_MIN_HEADINGS = 2;
288
+ /** Longest generated or truncated `<meta name="description">`. */
289
+ export const DESCRIPTION_MAX = 155;
290
+ /** Longest home-page `<title>` before the tagline is shortened. */
291
+ export const HOME_TITLE_MAX = 60;
292
+ /** Maximum tag pills on the home page. */
293
+ const HOME_TAG_PILL_LIMIT = 12;
294
+
295
+ /**
296
+ * Select the "Keep reading" articles: other published articles ranked by
297
+ * the number of shared tags (most first), then by recency (the input order),
298
+ * at most {@link RELATED_COUNT}.
299
+ *
300
+ * @param {import('../../../types/index.d.ts').ContentBuildRecord} record
301
+ * @param {readonly import('../../../types/index.d.ts').ContentBuildRecord[]} published
302
+ * every published article, newest first
303
+ * @returns {import('../../../types/index.d.ts').ContentBuildRecord[]}
304
+ */
305
+ export function selectRelatedArticles(record, published) {
306
+ const tags = new Set(record.frontmatter.tags);
307
+ return published
308
+ .filter((other) => other.frontmatter.id !== record.frontmatter.id)
309
+ .map((other, order) => ({
310
+ other,
311
+ order,
312
+ shared: other.frontmatter.tags.filter((tag) => tags.has(tag)).length,
313
+ }))
314
+ .sort((a, b) => b.shared - a.shared || a.order - b.order)
315
+ .slice(0, RELATED_COUNT)
316
+ .map((entry) => entry.other);
317
+ }
318
+
319
+ /**
320
+ * The home page `<title>`: `<Publication> – <tagline>`, with the tagline cut
321
+ * at a word boundary (and an ellipsis) so the whole stays within
322
+ * {@link HOME_TITLE_MAX} characters; the publication name alone when there is
323
+ * no tagline or too little room for a meaningful one.
324
+ *
325
+ * @param {string} name the publication title
326
+ * @param {string | undefined} tagline the publication description
327
+ * @returns {string} the home page title
328
+ */
329
+ export function homeDocumentTitle(name, tagline) {
330
+ const separator = ' \u2013 ';
331
+ const line = (tagline ?? '').replace(/\s+/g, ' ').trim();
332
+ if (!line) return name;
333
+ if (Array.from(`${name}${separator}${line}`).length <= HOME_TITLE_MAX) {
334
+ return `${name}${separator}${line}`;
335
+ }
336
+ const room = HOME_TITLE_MAX - Array.from(`${name}${separator}`).length - 1;
337
+ if (room < 12) return name;
338
+ const clipped = Array.from(line).slice(0, room).join('');
339
+ const cut = /\s/.test(clipped) ? clipped.replace(/\s+\S*$/, '') : clipped;
340
+ return `${name}${separator}${cut.replace(/[\s,;:.\u2013-]+$/, '')}\u2026`;
341
+ }
342
+
258
343
  /**
259
344
  * Build every generated page (every kind except `error`) from a validated
260
345
  * `build-input:2.0.0` instance.
261
346
  *
262
347
  * @param {import('../../../types/index.d.ts').NormalizedBuildInput} validatedInput
348
+ * @param {object} [options] rendering options
349
+ * @param {readonly {path: string, mediaType: string}[]} [options.mediaAssets]
350
+ * the media pipeline's finished `assets` list, used to resolve every
351
+ * image reference (hero, avatar) to its emitted URL; an image with no
352
+ * processed derivative renders as the placeholder/monogram fallback
353
+ * @param {Readonly<Record<string, {width: number, height: number}>>} [options.mediaDimensions]
354
+ * the media pipeline's pixel dimensions per derivative path
263
355
  * @returns {GeneratedPage[]} every generated page, in a stable, deterministic
264
356
  * order
265
357
  */
266
- export function buildGeneratedPages(validatedInput) {
358
+ export function buildGeneratedPages(validatedInput, options = {}) {
267
359
  const { publication, authors, content, basePath, baseUrl } = validatedInput;
268
360
  const publicBasePath = derivePublicBasePath(baseUrl, basePath);
269
361
  const messages = getMessages(publication.defaultLanguage);
@@ -274,38 +366,254 @@ export function buildGeneratedPages(validatedInput) {
274
366
  * @returns {string} the `basePath`-joined absolute route
275
367
  */
276
368
  const site = (route) => joinPublicRoute(publicBasePath, route);
369
+ const tagHref = (/** @type {string} */ tag) =>
370
+ site(`/tags/${routeSegmentForLabel(tag)}`);
371
+ const seriesHref = (/** @type {string} */ series) =>
372
+ site(`/series/${routeSegmentForLabel(series)}`);
373
+ const recordHref = (
374
+ /** @type {import('../../../types/index.d.ts').ContentBuildRecord} */ record,
375
+ ) => site(contentRoute(record.frontmatter));
376
+ const authorSlugs = deriveAuthorSlugs(authors);
377
+ const authorRoute = (
378
+ /** @type {import('../../../types/index.d.ts').AuthorNormalized} */ author,
379
+ ) => `/authors/${authorSlugs.get(author.id) ?? author.id}`;
380
+ const authorHref = (
381
+ /** @type {import('../../../types/index.d.ts').AuthorNormalized} */ author,
382
+ ) => site(authorRoute(author));
383
+ const mediaImage = createMediaImage({
384
+ mediaAssets: options.mediaAssets ?? [],
385
+ publicBasePath,
386
+ mediaDimensions: options.mediaDimensions,
387
+ });
388
+ const ui = createComponents({
389
+ messages,
390
+ site,
391
+ mediaImage,
392
+ authorHref,
393
+ authorsById,
394
+ recordHref,
395
+ tagHref,
396
+ });
397
+ const { text, call, chip, byline, card, avatar } = ui;
277
398
 
399
+ const msg = (/** @type {string} */ key, /** @type {string[]} */ ...args) =>
400
+ /** @type {(...a: string[]) => string} */ (messages[key])(...args);
401
+ const publicationTitle = publication.title;
402
+ /**
403
+ * @param {string} name a bare page name
404
+ * @returns {string} `<Page> | <Publication>`
405
+ */
406
+ const documentTitleOf = (name) => `${name} | ${publicationTitle}`;
407
+ const absolute = (/** @type {string} */ joinedRoute) =>
408
+ new URL(joinedRoute, baseUrl).toString();
409
+ const absoluteImage = (
410
+ /** @type {{path: string, sourceDigest: string} | undefined} */ ref,
411
+ ) => {
412
+ const image = mediaImage(ref);
413
+ return image ? { ...image, url: absolute(image.url) } : undefined;
414
+ };
415
+ const sd = createStructuredData({
416
+ publication,
417
+ absolute,
418
+ brandMark: absoluteImage(validatedInput.appearance.brandMark),
419
+ });
420
+ /** The publication-default social image and its alt, when one is set. */
421
+ const defaultSocial = publication.defaultImage
422
+ ? {
423
+ socialImageRef: publication.defaultImage,
424
+ socialImageAlt: publicationTitle,
425
+ }
426
+ : {};
427
+ /**
428
+ * @param {import('../../../types/index.d.ts').ContentFrontmatterNormalized} fm
429
+ * @returns {{socialImageRef?: {path: string, sourceDigest: string}, socialImageAlt?: string}}
430
+ */
431
+ const socialOfContent = (fm) =>
432
+ fm.socialImage
433
+ ? { socialImageRef: fm.socialImage, socialImageAlt: fm.title }
434
+ : fm.hero
435
+ ? {
436
+ socialImageRef: fm.hero.file,
437
+ socialImageAlt:
438
+ fm.hero.role === 'informative' && fm.hero.alt
439
+ ? fm.hero.alt
440
+ : fm.title,
441
+ }
442
+ : defaultSocial;
278
443
  /**
279
444
  * @param {import('../../../types/index.d.ts').ContentBuildRecord} record
280
- * @returns {string} one `<li>` summary
445
+ * @returns {string} the page description: authored, else the body's
446
+ * opening words
281
447
  */
282
- const renderContentSummary = (record) => {
283
- const { frontmatter } = record;
284
- const route = escapeHtml(site(contentRoute(frontmatter)));
285
- const title = escapeHtml(frontmatter.title);
286
- const authorNames = frontmatter.authorIds
287
- .map((id) => requireAuthor(authorsById, id).displayName)
288
- .join(', ');
289
- const byLine = escapeHtml(
290
- /** @type {(displayName: string) => string} */ (messages.byLineLabel)(
291
- authorNames,
292
- ),
293
- );
294
- const time = `<time datetime="${escapeHtml(frontmatter.publishedAt)}">${escapeHtml(frontmatter.publishedAt)}</time>`;
295
- const tags =
296
- frontmatter.tags.length > 0
297
- ? `<ul>${frontmatter.tags
298
- .map(
299
- (tag) =>
300
- `<li><a href="${escapeHtml(site(`/tags/${routeSegmentForLabel(tag)}`))}">${escapeHtml(tag)}</a></li>`,
301
- )
302
- .join('')}</ul>`
303
- : '';
304
- return (
305
- `<li><h2><a href="${route}">${title}</a></h2>` +
306
- `<p>${byLine}</p>${time}${tags}</li>`
448
+ const contentDescription = (record) =>
449
+ record.frontmatter.description ||
450
+ truncateAtWord(plainText(record.body), DESCRIPTION_MAX);
451
+
452
+ const text0 = (/** @type {string} */ key) =>
453
+ /** @type {string} */ (messages[key]);
454
+ const homeStep = {
455
+ label: /** @type {string} */ (messages.homeLinkLabel),
456
+ route: site('/'),
457
+ };
458
+ const crumbs = (
459
+ /** @type {import('./skeleton.js').BreadcrumbStep[][]} */ ...groups
460
+ ) => renderBreadcrumbs({ trail: [homeStep, ...groups.flat()], messages });
461
+
462
+ const publishedArticles = selectPublishedArticles(content);
463
+ const byTag = groupBy(publishedArticles, (r) => r.frontmatter.tags);
464
+ const bySeries = groupBy(
465
+ content.filter((r) => r.frontmatter.status === 'published'),
466
+ (r) => (r.frontmatter.series ? [r.frontmatter.series] : []),
467
+ );
468
+ for (const bucket of bySeries.values()) {
469
+ bucket.sort(
470
+ (a, b) =>
471
+ (a.frontmatter.seriesOrder ?? 0) - (b.frontmatter.seriesOrder ?? 0),
307
472
  );
473
+ }
474
+ const sortedTags = [...byTag.keys()].sort();
475
+ const sortedSeries = [...bySeries.keys()].sort();
476
+ const byYear = groupBy(publishedArticles, (r) => [
477
+ r.frontmatter.publishedAt.slice(0, 4),
478
+ ]);
479
+ const sortedYears = [...byYear.keys()].sort().reverse();
480
+
481
+ /**
482
+ * @param {import('../../../types/index.d.ts').ContentBuildRecord} record
483
+ * @returns {string | undefined} "Part N of M" when the record is a
484
+ * published member of its series
485
+ */
486
+ const partLabelOf = (record) => {
487
+ const series = record.frontmatter.series;
488
+ const members = series ? bySeries.get(series) : undefined;
489
+ const index = members ? members.indexOf(record) : -1;
490
+ return members && index >= 0
491
+ ? /** @type {(a: string, b: string) => string} */ (
492
+ messages.seriesPartLabel
493
+ )(String(index + 1), String(members.length))
494
+ : undefined;
308
495
  };
496
+ /**
497
+ * @param {import('../../../types/index.d.ts').ContentBuildRecord} record
498
+ * @param {'grid' | 'row'} [variant]
499
+ * @param {number} [headingLevel] the title's heading level
500
+ * @returns {string}
501
+ */
502
+ const cardOf = (record, variant = 'grid', headingLevel = 3) =>
503
+ card(record, { partLabel: partLabelOf(record), headingLevel }, variant);
504
+
505
+ /**
506
+ * @param {string} href
507
+ * @param {string} label
508
+ * @param {number | undefined} count
509
+ * @param {string} iconName
510
+ * @param {boolean} [current]
511
+ * @returns {string}
512
+ */
513
+ const pill = (href, label, count, iconName, current = false) =>
514
+ `<li><a class="g-pill" href="${escapeHtml(href)}"${current ? ' aria-current="page"' : ''}>${icon(iconName)}` +
515
+ `<span>${escapeHtml(label)}</span>` +
516
+ (count === undefined ? '' : `<span class="g-pill-count">${count}</span>`) +
517
+ `</a></li>`;
518
+ /**
519
+ * @param {string} [currentTag]
520
+ * @returns {string}
521
+ */
522
+ const tagPills = (currentTag = '') =>
523
+ `<ul class="g-pills">${sortedTags
524
+ .map((tag) =>
525
+ pill(
526
+ tagHref(tag),
527
+ tag,
528
+ byTag.get(tag)?.length,
529
+ 'hash',
530
+ tag === currentTag,
531
+ ),
532
+ )
533
+ .join('')}</ul>`;
534
+
535
+ /**
536
+ * The header block shared by tag, series, archive and author pages and
537
+ * their root listings.
538
+ *
539
+ * @param {object} head
540
+ * @param {string} head.breadcrumbHtml
541
+ * @param {string} head.kicker the label before the count
542
+ * @param {number | undefined} head.count article count
543
+ * @param {string} head.title the page's `<h1>` text
544
+ * @param {string} [head.descriptionHtml] already-rendered intro markup
545
+ * @param {string} [head.markHtml] a leading avatar instead of an icon
546
+ * @param {string} [head.iconName]
547
+ * @param {string} [head.extraHtml] markup after the intro (pills)
548
+ * @returns {string}
549
+ */
550
+ const listingHead = ({
551
+ breadcrumbHtml,
552
+ kicker,
553
+ count,
554
+ title,
555
+ descriptionHtml = '',
556
+ markHtml,
557
+ iconName = 'hash',
558
+ extraHtml = '',
559
+ }) =>
560
+ `<section class="g-wrap g-topic-hero">${breadcrumbHtml}` +
561
+ `<div class="g-topic-head"><span class="g-topic-icon">${markHtml ?? icon(iconName)}</span>` +
562
+ `<div><p class="g-label">${escapeHtml(kicker)}` +
563
+ (count === undefined
564
+ ? ''
565
+ : `<span class="g-dot" aria-hidden="true"></span>${call('articleCountLabel', String(count))}`) +
566
+ `</p><h1>${escapeHtml(title)}</h1></div></div>` +
567
+ descriptionHtml +
568
+ extraHtml +
569
+ `</section>`;
570
+
571
+ /**
572
+ * @param {readonly import('../../../types/index.d.ts').ContentBuildRecord[]} items
573
+ * @param {string} [afterHtml] markup after the list (pagination)
574
+ * @returns {string}
575
+ */
576
+ const rowList = (items, afterHtml = '') =>
577
+ `<section class="g-wrap g-section">` +
578
+ (items.length > 0
579
+ ? `<div class="g-list">${items.map((record) => cardOf(record, 'row', 2)).join('')}</div>`
580
+ : `<p>${text('emptyListingLabel')}</p>`) +
581
+ afterHtml +
582
+ `</section>`;
583
+
584
+ /**
585
+ * One listing page's metadata: bare title, `<title>`, description,
586
+ * social image and JSON-LD.
587
+ *
588
+ * @param {object} spec
589
+ * @param {string} spec.name the bare page name
590
+ * @param {string} spec.description the meta description
591
+ * @param {string} spec.route the un-joined route
592
+ * @param {readonly {label: string, route?: string}[]} [spec.parents]
593
+ * breadcrumb steps between home and this page (joined routes)
594
+ * @returns {Pick<GeneratedPage, 'title' | 'documentTitle' | 'description' | 'ogType' | 'socialImageRef' | 'socialImageAlt' | 'jsonLd'>}
595
+ */
596
+ const listingMeta = ({ name, description, route, parents = [] }) => ({
597
+ title: name,
598
+ documentTitle: documentTitleOf(name),
599
+ description,
600
+ ogType: 'website',
601
+ ...defaultSocial,
602
+ jsonLd: sd.serialize(
603
+ sd.collection({
604
+ name,
605
+ description,
606
+ url: absolute(site(route)),
607
+ language: publication.defaultLanguage,
608
+ trail: [homeStep, ...parents, { label: name, route: site(route) }],
609
+ }),
610
+ ),
611
+ });
612
+
613
+ const newsletterHtml = renderNewsletterPanel({
614
+ newsletter: publication.newsletter,
615
+ messages,
616
+ });
309
617
 
310
618
  /** @type {GeneratedPage[]} */
311
619
  const pages = [];
@@ -315,182 +623,474 @@ export function buildGeneratedPages(validatedInput) {
315
623
  kind: 'profile',
316
624
  route: publication.profile.route,
317
625
  title: publication.title,
626
+ documentTitle: documentTitleOf(text0('aboutPageTitle')),
318
627
  language: publication.defaultLanguage,
319
628
  description: publication.description,
320
629
  ogType: 'website',
321
- socialImageRef: publication.defaultImage,
322
- bodyHtml: `<h1>${escapeHtml(publication.title)}</h1>${publication.profile.body.body}`,
630
+ ...defaultSocial,
631
+ jsonLd: sd.serialize(
632
+ sd.webPage({
633
+ name: publication.title,
634
+ description: publication.description,
635
+ url: absolute(site(publication.profile.route)),
636
+ language: publication.defaultLanguage,
637
+ trail: [
638
+ homeStep,
639
+ {
640
+ label: text0('aboutPageTitle'),
641
+ route: site(publication.profile.route),
642
+ },
643
+ ],
644
+ }),
645
+ ),
646
+ bodyHtml:
647
+ `<article class="g-article"><div class="g-wrap g-article-head">` +
648
+ `<h1>${escapeHtml(publication.title)}</h1></div>` +
649
+ `<div class="g-wrap g-article-grid"><div class="g-prose">${lazyBodyImages(publication.profile.body.body)}</div></div></article>`,
323
650
  });
324
651
  }
325
652
 
326
653
  for (const author of authors) {
327
- const pronouns = author.pronouns
328
- ? `<p>${escapeHtml(author.pronouns)}</p>`
329
- : '';
330
- const biography = author.biography
331
- ? `<p>${escapeHtml(author.biography)}</p>`
332
- : '';
654
+ const authored = publishedArticles.filter((record) =>
655
+ record.frontmatter.authorIds.includes(author.id),
656
+ );
333
657
  const links =
334
658
  author.links.length > 0
335
- ? `<ul>${author.links
659
+ ? `<ul class="g-social">${author.links
336
660
  .map((link) => {
337
661
  const label = escapeHtml(link.label ?? link.uri);
338
- return `<li><a href="${escapeHtml(link.uri)}">${label}</a></li>`;
662
+ return `<li><a class="g-icon-btn" href="${escapeHtml(link.uri)}" aria-label="${label}" title="${label}">${icon('upright')}</a></li>`;
339
663
  })
340
664
  .join('')}</ul>`
341
665
  : '';
342
- const heading = /** @type {(displayName: string) => string} */ (
343
- messages.aboutAuthorHeading
344
- )(author.displayName);
666
+ const intro =
667
+ (author.pronouns
668
+ ? `<p class="g-label">${escapeHtml(author.pronouns)}</p>`
669
+ : '') +
670
+ (author.biography
671
+ ? `<p class="g-dek">${escapeHtml(author.biography)}</p>`
672
+ : '') +
673
+ links;
345
674
  pages.push({
346
675
  kind: 'author',
347
- route: `/authors/${author.id}`,
348
- title: heading,
676
+ route: authorRoute(author),
677
+ title: author.displayName,
678
+ documentTitle: documentTitleOf(author.displayName),
349
679
  language: publication.defaultLanguage,
350
- description: author.biography || undefined,
680
+ description:
681
+ truncateAtWord(author.biography, DESCRIPTION_MAX) ||
682
+ msg('authorPageDescription', author.displayName, publicationTitle),
351
683
  ogType: 'profile',
352
- socialImageRef: author.avatar,
353
- breadcrumbHtml: renderBreadcrumbs({
354
- trail: [
355
- {
356
- label: /** @type {string} */ (messages.homeLinkLabel),
357
- route: site('/'),
358
- },
359
- { label: /** @type {string} */ (messages.authorsSectionLabel) },
360
- { label: author.displayName },
361
- ],
362
- messages,
363
- }),
684
+ ...(author.avatar
685
+ ? {
686
+ socialImageRef: author.avatar,
687
+ socialImageAlt: author.displayName,
688
+ }
689
+ : defaultSocial),
690
+ jsonLd: sd.serialize(
691
+ sd.profile({
692
+ name: author.displayName,
693
+ description: author.biography || undefined,
694
+ url: absolute(authorHref(author)),
695
+ language: publication.defaultLanguage,
696
+ image: absoluteImage(author.avatar),
697
+ sameAs: author.links.map((link) => link.uri),
698
+ }),
699
+ ),
364
700
  bodyHtml:
365
- `<h1>${escapeHtml(heading)}</h1>` + pronouns + biography + links,
701
+ listingHead({
702
+ breadcrumbHtml: crumbs(
703
+ [{ label: /** @type {string} */ (messages.authorsSectionLabel) }],
704
+ [{ label: author.displayName }],
705
+ ),
706
+ kicker: /** @type {string} */ (messages.authorKickerLabel),
707
+ count: authored.length,
708
+ title: author.displayName,
709
+ descriptionHtml: intro,
710
+ markHtml: avatar(author),
711
+ }) + rowList(authored),
366
712
  });
367
713
  }
368
714
 
369
715
  for (const record of content) {
370
716
  const { frontmatter } = record;
371
- const authorNames = frontmatter.authorIds
372
- .map((id) => requireAuthor(authorsById, id).displayName)
373
- .join(', ');
374
- const byLine = `<p>${escapeHtml(
375
- /** @type {(displayName: string) => string} */ (messages.byLineLabel)(
376
- authorNames,
377
- ),
378
- )}</p>`;
379
- const time = `<time datetime="${escapeHtml(frontmatter.publishedAt)}">${escapeHtml(frontmatter.publishedAt)}</time>`;
717
+ const isArticle = frontmatter.kind === 'article';
718
+ const hero = mediaImage(frontmatter.hero?.file);
719
+ const headings = extractH2Headings(record.body);
720
+ const contents =
721
+ headings.length >= CONTENTS_MIN_HEADINGS
722
+ ? `<ol class="g-toc-list">${headings
723
+ .map(
724
+ (heading) =>
725
+ `<li><a href="#${escapeHtml(heading.id)}" data-toc="${escapeHtml(heading.id)}">${escapeHtml(heading.text)}</a></li>`,
726
+ )
727
+ .join('')}</ol>`
728
+ : '';
729
+ const firstTag = frontmatter.tags[0];
730
+ const trail = [
731
+ ...(isArticle && firstTag
732
+ ? [{ label: firstTag, route: tagHref(firstTag) }]
733
+ : []),
734
+ { label: frontmatter.title },
735
+ ];
736
+ const partLabel = partLabelOf(record);
737
+ const series = frontmatter.series;
738
+ const seriesTag = series
739
+ ? `<a class="g-series-tag" href="${escapeHtml(seriesHref(series))}">${icon('book')}` +
740
+ (partLabel
741
+ ? `${escapeHtml(partLabel)}<span class="g-dot" aria-hidden="true"></span>`
742
+ : '') +
743
+ `${escapeHtml(series)}</a>`
744
+ : '';
745
+ const head =
746
+ `<div class="g-wrap g-article-head">${renderBreadcrumbs({ trail: [homeStep, ...trail], messages })}` +
747
+ (isArticle
748
+ ? `<div class="g-article-labels">${firstTag ? chip(firstTag) : ''}${seriesTag}</div>`
749
+ : '') +
750
+ `<h1>${escapeHtml(frontmatter.title)}</h1>` +
751
+ (frontmatter.description
752
+ ? `<p class="g-dek">${escapeHtml(frontmatter.description)}</p>`
753
+ : '') +
754
+ (isArticle
755
+ ? `<div class="g-article-meta">${byline(record, true)}` +
756
+ `<div class="g-share" role="group" aria-label="${text('shareLabel')}" hidden>` +
757
+ `<button class="g-icon-btn" type="button" data-action="copy-link" aria-label="${text('copyLinkLabel')}" title="${text('copyLinkLabel')}">${icon('link')}</button>` +
758
+ `<button class="g-icon-btn" type="button" data-action="bookmark" aria-pressed="false" aria-label="${text('saveForLaterLabel')}" title="${text('saveForLaterLabel')}">${icon('bookmark')}</button>` +
759
+ `</div></div>`
760
+ : '') +
761
+ `</div>`;
762
+ const cover = hero
763
+ ? `<figure class="g-wrap g-article-cover">${renderImage({ image: hero, alt: frontmatter.hero?.role === 'decorative' ? '' : (frontmatter.hero?.alt ?? ''), priority: true })}</figure>`
764
+ : '';
765
+ const tocNav = contents
766
+ ? `<nav class="g-toc" aria-label="${text('onThisPageLabel')}"><p class="g-label">${icon('list')}${text('onThisPageLabel')}</p>${contents}</nav>`
767
+ : '';
768
+ const tocMobile = contents
769
+ ? `<details class="g-toc-mobile"><summary>${icon('list')}${text('onThisPageLabel')}</summary>${contents}</details>`
770
+ : '';
380
771
  const tags =
381
- frontmatter.tags.length > 0
382
- ? `<ul>${frontmatter.tags
772
+ isArticle && frontmatter.tags.length > 0
773
+ ? `<ul class="g-tags" aria-label="${text('tagListLabel')}">${frontmatter.tags
383
774
  .map(
384
775
  (tag) =>
385
- `<li><a href="${escapeHtml(site(`/tags/${routeSegmentForLabel(tag)}`))}">${escapeHtml(tag)}</a></li>`,
776
+ `<li><a class="g-tag" href="${escapeHtml(tagHref(tag))}">${icon('hash')}${escapeHtml(tag)}</a></li>`,
386
777
  )
387
778
  .join('')}</ul>`
388
779
  : '';
389
- const seriesHtml = frontmatter.series
390
- ? `<p><a href="${escapeHtml(site(`/series/${routeSegmentForLabel(frontmatter.series)}`))}">${escapeHtml(frontmatter.series)}</a></p>`
391
- : '';
392
- const breadcrumbTrail =
393
- frontmatter.kind === 'article'
394
- ? [
395
- {
396
- label: /** @type {string} */ (messages.homeLinkLabel),
397
- route: site('/'),
398
- },
399
- {
400
- label: /** @type {string} */ (messages.indexHeading),
401
- route: site('/'),
402
- },
403
- { label: frontmatter.title },
404
- ]
405
- : [
406
- {
407
- label: /** @type {string} */ (messages.homeLinkLabel),
408
- route: site('/'),
409
- },
410
- { label: frontmatter.title },
411
- ];
780
+ const prose =
781
+ `<div class="g-prose">${tocMobile}` +
782
+ renderSlot('article-preamble') +
783
+ lazyBodyImages(record.body) +
784
+ tags +
785
+ renderSlot('article-end') +
786
+ renderSlot('article-footer-ad', { collapsed: true }) +
787
+ `</div>`;
788
+
789
+ let foot = '';
790
+ let after = '';
791
+ if (isArticle) {
792
+ const authorCards = ui
793
+ .recordAuthors(record)
794
+ .map(
795
+ (author) =>
796
+ `<section class="g-author-card" aria-label="${text('aboutTheAuthorLabel')}">${avatar(author)}<div>` +
797
+ `<p class="g-label">${text('writtenByLabel')}</p>` +
798
+ `<p class="g-author-name"><a href="${escapeHtml(authorHref(author))}">${escapeHtml(author.displayName)}</a></p>` +
799
+ (author.biography
800
+ ? `<p class="g-author-bio">${escapeHtml(author.biography)}</p>`
801
+ : '') +
802
+ `</div></section>`,
803
+ )
804
+ .join('');
805
+ const members = series ? bySeries.get(series) : undefined;
806
+ const seriesBox =
807
+ series && members && members.includes(record)
808
+ ? `<section class="g-series-box" aria-label="${escapeHtml(series)}"><p class="g-label">${icon('book')}${escapeHtml(series)}</p><ol class="g-series-list">` +
809
+ members
810
+ .map((member, index) => {
811
+ const here = member === record;
812
+ return (
813
+ `<li${here ? ' aria-current="true"' : ''}><a href="${escapeHtml(recordHref(member))}">` +
814
+ `<span class="g-series-n">${index + 1}</span><span>${escapeHtml(member.frontmatter.title)}</span>` +
815
+ (here
816
+ ? `<span class="g-series-here">${text('youAreHereLabel')}</span>`
817
+ : '') +
818
+ `</a></li>`
819
+ );
820
+ })
821
+ .join('') +
822
+ `</ol></section>`
823
+ : '';
824
+ const position = publishedArticles.indexOf(record);
825
+ const newer = position > 0 ? publishedArticles[position - 1] : undefined;
826
+ const older = position >= 0 ? publishedArticles[position + 1] : undefined;
827
+ const pagerLink = (
828
+ /** @type {import('../../../types/index.d.ts').ContentBuildRecord} */ target,
829
+ /** @type {boolean} */ isOlder,
830
+ ) =>
831
+ `<a class="g-pager-link${isOlder ? ' g-pager-next' : ''}" rel="${isOlder ? 'next' : 'prev'}" href="${escapeHtml(recordHref(target))}">` +
832
+ `<span class="g-label">${isOlder ? `${text('olderArticleLabel')}${icon('arrow')}` : `${icon('back')}${text('newerArticleLabel')}`}</span>` +
833
+ `<span>${escapeHtml(target.frontmatter.title)}</span></a>`;
834
+ const pager =
835
+ newer || older
836
+ ? `<nav class="g-pager" aria-label="${text('moreArticlesNavigationLabel')}">${newer ? pagerLink(newer, false) : ''}${older ? pagerLink(older, true) : ''}</nav>`
837
+ : '';
838
+ foot = `<div class="g-wrap g-article-foot">${authorCards}${seriesBox}${pager}</div>`;
839
+ const related = selectRelatedArticles(record, publishedArticles);
840
+ after =
841
+ related.length > 0
842
+ ? `<section class="g-wrap g-section" aria-labelledby="related-title"><div class="g-section-head">` +
843
+ `<h2 id="related-title">${text('keepReadingHeading')}</h2>` +
844
+ (firstTag
845
+ ? `<a class="g-more" href="${escapeHtml(tagHref(firstTag))}">${call('moreInTagLabel', firstTag)}${icon('arrow')}</a>`
846
+ : '') +
847
+ `</div><div class="g-grid">${related.map((r) => cardOf(r)).join('')}</div></section>`
848
+ : '';
849
+ }
850
+ const ownUrl = absolute(recordHref(record));
851
+ const recordAuthors = ui.recordAuthors(record);
852
+ const articleTrail = [
853
+ homeStep,
854
+ ...trail.slice(0, -1),
855
+ { label: frontmatter.title, route: recordHref(record) },
856
+ ];
857
+ const articleGraph = isArticle
858
+ ? sd.article({
859
+ url: ownUrl,
860
+ headline: frontmatter.title,
861
+ description: contentDescription(record),
862
+ image: absoluteImage(socialOfContent(frontmatter).socialImageRef),
863
+ datePublished: frontmatter.publishedAt,
864
+ dateModified: contentLastModified(frontmatter),
865
+ language: frontmatter.language,
866
+ authors: recordAuthors.map((author) => ({
867
+ name: author.displayName,
868
+ url: absolute(authorHref(author)),
869
+ sameAs: author.links.map((link) => link.uri),
870
+ })),
871
+ tags: frontmatter.tags,
872
+ series: series
873
+ ? { name: series, url: absolute(seriesHref(series)) }
874
+ : undefined,
875
+ trail: articleTrail,
876
+ })
877
+ : sd.webPage({
878
+ name: frontmatter.title,
879
+ description: contentDescription(record),
880
+ url: ownUrl,
881
+ language: frontmatter.language,
882
+ trail: articleTrail,
883
+ dateModified: contentLastModified(frontmatter),
884
+ });
412
885
  pages.push({
413
886
  kind: frontmatter.kind,
414
887
  route: contentRoute(frontmatter),
415
888
  title: frontmatter.title,
889
+ documentTitle: documentTitleOf(frontmatter.title),
416
890
  language: frontmatter.language,
417
- description: frontmatter.description,
418
- ogType: frontmatter.kind === 'article' ? 'article' : 'website',
419
- socialImageRef: frontmatter.socialImage ?? frontmatter.hero?.file,
891
+ description: contentDescription(record),
892
+ ogType: isArticle ? 'article' : 'website',
893
+ ...socialOfContent(frontmatter),
894
+ jsonLd: sd.serialize(articleGraph),
895
+ articleMeta: isArticle
896
+ ? {
897
+ publishedTime: frontmatter.publishedAt,
898
+ modifiedTime: frontmatter.updatedAt,
899
+ authorUrls: recordAuthors.map((author) =>
900
+ absolute(authorHref(author)),
901
+ ),
902
+ tags: [...frontmatter.tags],
903
+ }
904
+ : undefined,
420
905
  robotsContent:
421
906
  frontmatter.status === 'unlisted' ? 'noindex, follow' : undefined,
422
907
  lastModified: contentLastModified(frontmatter),
423
- breadcrumbHtml: renderBreadcrumbs({ trail: breadcrumbTrail, messages }),
424
908
  bodyHtml:
425
- `<article>` +
426
- renderSlot('article-preamble') +
427
- `<h1>${escapeHtml(frontmatter.title)}</h1>` +
428
- byLine +
429
- time +
430
- seriesHtml +
431
- tags +
432
- record.body +
433
- renderSlot('article-end') +
434
- renderSlot('article-footer-ad', { collapsed: true }) +
435
- `</article>`,
909
+ `<article class="g-article">${isArticle ? '<div class="g-progress" aria-hidden="true"></div>' : ''}${head}${cover}` +
910
+ `<div class="g-wrap g-article-grid">${tocNav}${prose}</div>${foot}</article>` +
911
+ after +
912
+ (isArticle ? newsletterHtml : ''),
436
913
  });
437
914
  }
438
915
 
439
- const publishedArticles = selectPublishedArticles(content);
440
-
441
- const indexPages = paginate(publishedArticles, LISTING_PAGE_SIZE);
916
+ // Index: page 1 is the home page (featured, tag pills, latest cards, series
917
+ // card, newsletter); every index page carries a slice of the older
918
+ // articles with pagination.
919
+ const [featured, ...rest] = publishedArticles;
920
+ const latest = rest.slice(0, LATEST_CARD_COUNT);
921
+ const older = rest.slice(LATEST_CARD_COUNT);
922
+ const indexPages = paginate(older, LISTING_PAGE_SIZE);
923
+ const indexRoute = (/** @type {number} */ n) =>
924
+ site(n === 1 ? '/' : `/page/${n}`);
925
+ const seriesName = publishedArticles.find((r) => r.frontmatter.series)
926
+ ?.frontmatter.series;
927
+ const seriesParts = (seriesName ? bySeries.get(seriesName) : undefined) ?? [];
928
+ const homeFeatured = featured
929
+ ? (() => {
930
+ const fm = featured.frontmatter;
931
+ const hero = mediaImage(fm.hero?.file);
932
+ const href = escapeHtml(recordHref(featured));
933
+ return (
934
+ `<section class="g-wrap g-hero" aria-labelledby="hero-title">` +
935
+ `<div class="g-hero-media">` +
936
+ (hero
937
+ ? renderImage({
938
+ image: hero,
939
+ alt: fm.hero?.role === 'decorative' ? '' : (fm.hero?.alt ?? ''),
940
+ priority: true,
941
+ })
942
+ : `<span class="g-card-placeholder">${icon('sparkle')}</span>`) +
943
+ `</div><div class="g-hero-body"><p class="g-eyebrow"><span class="g-badge">${icon('sparkle')}${text('featuredLabel')}</span>` +
944
+ (fm.tags[0] ? chip(fm.tags[0]) : '') +
945
+ `</p><h2 id="hero-title"><a href="${href}">${escapeHtml(fm.title)}</a></h2>` +
946
+ (fm.description
947
+ ? `<p class="g-dek">${escapeHtml(fm.description)}</p>`
948
+ : '') +
949
+ byline(featured) +
950
+ `<a class="g-btn" href="${href}">${text('readEssayLabel')}${icon('arrow')}</a></div></section>`
951
+ );
952
+ })()
953
+ : `<section class="g-wrap g-topic-hero"><h2>${text('indexHeading')}</h2><p>${text('emptyListingLabel')}</p></section>`;
954
+ const homeHeading = `<h1 class="g-sr">${escapeHtml(publicationTitle)}</h1>`;
955
+ const homeTags =
956
+ sortedTags.length > 0
957
+ ? `<section class="g-wrap g-topic-strip" aria-label="${text('browseByTagLabel')}"><ul class="g-pills">${[
958
+ ...sortedTags,
959
+ ]
960
+ .sort(
961
+ (a, b) =>
962
+ (byTag.get(b)?.length ?? 0) - (byTag.get(a)?.length ?? 0) ||
963
+ (a < b ? -1 : 1),
964
+ )
965
+ .slice(0, HOME_TAG_PILL_LIMIT)
966
+ .map((tag) => pill(tagHref(tag), tag, byTag.get(tag)?.length, 'hash'))
967
+ .join('')}</ul></section>`
968
+ : '';
969
+ const homeLatest =
970
+ latest.length > 0
971
+ ? `<section class="g-wrap g-section" aria-labelledby="latest-title"><div class="g-section-head"><h2 id="latest-title">${text('latestHeading')}</h2>` +
972
+ `<a class="g-more" href="${escapeHtml(site('/archive'))}">${text('archiveSectionLabel')}${icon('arrow')}</a></div>` +
973
+ `<div class="g-grid">${latest.map((r) => cardOf(r)).join('')}</div></section>`
974
+ : '';
975
+ const homeSeries =
976
+ seriesName && seriesParts.length > 0
977
+ ? `<section class="g-wrap g-section"><div class="g-series-card">` +
978
+ `<p class="g-label">${icon('book')}${text('seriesCardLabel')}<span class="g-dot" aria-hidden="true"></span>${call('partsCountLabel', String(seriesParts.length))}</p>` +
979
+ `<h2><a href="${escapeHtml(seriesHref(seriesName))}">${escapeHtml(seriesName)}</a></h2>` +
980
+ `<ol class="g-series-list">${seriesParts
981
+ .map(
982
+ (member, index) =>
983
+ `<li><a href="${escapeHtml(recordHref(member))}"><span class="g-series-n">${index + 1}</span><span>${escapeHtml(member.frontmatter.title)}</span></a></li>`,
984
+ )
985
+ .join('')}</ol>` +
986
+ `<a class="g-btn" href="${escapeHtml(recordHref(seriesParts[0]))}">${text('startSeriesLabel')}${icon('arrow')}</a></div></section>`
987
+ : '';
442
988
  indexPages.forEach((items, pageIndex) => {
443
989
  const pageNumber = pageIndex + 1;
444
- const route = pageNumber === 1 ? '/' : `/page/${pageNumber}`;
990
+ const pagination = renderPagination({
991
+ currentPage: pageNumber,
992
+ totalPages: indexPages.length,
993
+ routeForPage: indexRoute,
994
+ messages,
995
+ });
996
+ const moreList =
997
+ items.length > 0
998
+ ? `<section class="g-wrap g-section"><div class="g-section-head"><h2>${text('moreArticlesHeading')}</h2></div>` +
999
+ `<div class="g-list">${items.map((r) => cardOf(r, 'row')).join('')}</div>${pagination}</section>`
1000
+ : '';
1001
+ const bodyHtml =
1002
+ pageNumber === 1
1003
+ ? homeHeading +
1004
+ homeFeatured +
1005
+ homeTags +
1006
+ homeLatest +
1007
+ homeSeries +
1008
+ moreList +
1009
+ newsletterHtml
1010
+ : listingHead({
1011
+ breadcrumbHtml: crumbs([
1012
+ { label: /** @type {string} */ (messages.indexHeading) },
1013
+ ]),
1014
+ kicker: /** @type {(c: string, t: string) => string} */ (
1015
+ messages.pageStatusLabel
1016
+ )(String(pageNumber), String(indexPages.length)),
1017
+ count: undefined,
1018
+ title: /** @type {string} */ (messages.indexHeading),
1019
+ iconName: 'layers',
1020
+ }) + rowList(items, pagination);
1021
+ const indexTitle =
1022
+ pageNumber === 1
1023
+ ? publicationTitle
1024
+ : `${text0('indexHeading')} \u2013 ${msg('pageStatusLabel', String(pageNumber), String(indexPages.length))}`;
1025
+ const indexUrl = absolute(indexRoute(pageNumber));
445
1026
  pages.push({
446
1027
  kind: 'index',
447
- route,
448
- title: /** @type {string} */ (messages.indexHeading),
1028
+ route: pageNumber === 1 ? '/' : `/page/${pageNumber}`,
1029
+ title: indexTitle,
1030
+ documentTitle:
1031
+ pageNumber === 1
1032
+ ? homeDocumentTitle(publicationTitle, publication.description)
1033
+ : documentTitleOf(indexTitle),
449
1034
  language: publication.defaultLanguage,
450
- description: publication.description,
1035
+ description:
1036
+ pageNumber === 1
1037
+ ? publication.description
1038
+ : msg('indexPageDescription', publicationTitle),
451
1039
  ogType: 'website',
452
- socialImageRef: publication.defaultImage,
453
- lastModified: latestContentTimestamp(items),
454
- bodyHtml:
455
- `<h1>${escapeHtml(/** @type {string} */ (messages.indexHeading))}</h1>` +
456
- `<ul>${items.map((record) => renderContentSummary(record)).join('')}</ul>` +
457
- renderPagination({
458
- currentPage: pageNumber,
459
- totalPages: indexPages.length,
460
- routeForPage: (n) => site(n === 1 ? '/' : `/page/${n}`),
461
- messages,
462
- }),
1040
+ ...defaultSocial,
1041
+ jsonLd: sd.serialize(
1042
+ pageNumber === 1
1043
+ ? sd.home({
1044
+ posts: publishedArticles.slice(0, 10).map((record) => ({
1045
+ url: absolute(recordHref(record)),
1046
+ headline: record.frontmatter.title,
1047
+ datePublished: record.frontmatter.publishedAt,
1048
+ })),
1049
+ })
1050
+ : sd.collection({
1051
+ name: indexTitle,
1052
+ description: msg('indexPageDescription', publicationTitle),
1053
+ url: indexUrl,
1054
+ language: publication.defaultLanguage,
1055
+ trail: [
1056
+ homeStep,
1057
+ { label: indexTitle, route: indexRoute(pageNumber) },
1058
+ ],
1059
+ }),
1060
+ ),
1061
+ lastModified: latestContentTimestamp(
1062
+ pageNumber === 1
1063
+ ? publishedArticles.slice(0, 1 + LATEST_CARD_COUNT)
1064
+ : items,
1065
+ ),
1066
+ bodyHtml,
463
1067
  });
464
1068
  });
465
1069
 
466
- /** @type {Map<string, import('../../../types/index.d.ts').ContentBuildRecord[]>} */
467
- const byTag = new Map();
468
- for (const record of publishedArticles) {
469
- for (const tag of record.frontmatter.tags) {
470
- const bucket = byTag.get(tag) ?? [];
471
- bucket.push(record);
472
- byTag.set(tag, bucket);
473
- }
474
- }
475
- const sortedTags = [...byTag.keys()].sort();
476
1070
  if (sortedTags.length > 0) {
477
1071
  pages.push({
478
1072
  kind: 'tag',
479
1073
  route: '/tags',
480
- title: /** @type {string} */ (messages.tagsSectionLabel),
1074
+ ...listingMeta({
1075
+ name: text0('tagsSectionLabel'),
1076
+ description: msg(
1077
+ 'tagsRootDescription',
1078
+ String(sortedTags.length),
1079
+ publicationTitle,
1080
+ ),
1081
+ route: '/tags',
1082
+ }),
481
1083
  language: publication.defaultLanguage,
482
- description: publication.description,
483
- ogType: 'website',
484
- socialImageRef: publication.defaultImage,
485
1084
  lastModified: latestContentTimestamp(publishedArticles),
486
- bodyHtml:
487
- `<h1>${escapeHtml(/** @type {string} */ (messages.tagsSectionLabel))}</h1>` +
488
- `<ul>${sortedTags
489
- .map(
490
- (tag) =>
491
- `<li><a href="${escapeHtml(site(`/tags/${routeSegmentForLabel(tag)}`))}">${escapeHtml(tag)}</a></li>`,
492
- )
493
- .join('')}</ul>`,
1085
+ bodyHtml: listingHead({
1086
+ breadcrumbHtml: crumbs([
1087
+ { label: /** @type {string} */ (messages.tagsSectionLabel) },
1088
+ ]),
1089
+ kicker: /** @type {string} */ (messages.tagKickerLabel),
1090
+ count: undefined,
1091
+ title: /** @type {string} */ (messages.tagsSectionLabel),
1092
+ extraHtml: tagPills(),
1093
+ }),
494
1094
  });
495
1095
  }
496
1096
  for (const tag of sortedTags) {
@@ -498,131 +1098,148 @@ export function buildGeneratedPages(validatedInput) {
498
1098
  /** @type {import('../../../types/index.d.ts').ContentBuildRecord[]} */ (
499
1099
  byTag.get(tag)
500
1100
  );
501
- const heading = `${/** @type {string} */ (messages.tagIndexLabelPrefix)} ${tag}`;
502
1101
  pages.push({
503
1102
  kind: 'tag',
504
1103
  route: `/tags/${routeSegmentForLabel(tag)}`,
505
- title: heading,
1104
+ ...listingMeta({
1105
+ name: msg('tagPageTitle', tag),
1106
+ description: msg(
1107
+ 'tagPageDescription',
1108
+ String(items.length),
1109
+ tag,
1110
+ publicationTitle,
1111
+ ),
1112
+ route: `/tags/${routeSegmentForLabel(tag)}`,
1113
+ parents: [{ label: text0('tagsSectionLabel'), route: site('/tags') }],
1114
+ }),
506
1115
  language: publication.defaultLanguage,
507
- ogType: 'website',
508
- socialImageRef: publication.defaultImage,
509
1116
  lastModified: latestContentTimestamp(items),
510
- breadcrumbHtml: renderBreadcrumbs({
511
- trail: [
512
- {
513
- label: /** @type {string} */ (messages.homeLinkLabel),
514
- route: site('/'),
515
- },
516
- {
517
- label: /** @type {string} */ (messages.tagsSectionLabel),
518
- route: site('/tags'),
519
- },
520
- { label: tag },
521
- ],
522
- messages,
523
- }),
524
1117
  bodyHtml:
525
- `<h1>${escapeHtml(heading)}</h1>` +
526
- `<ul>${items.map((record) => renderContentSummary(record)).join('')}</ul>`,
1118
+ listingHead({
1119
+ breadcrumbHtml: crumbs(
1120
+ [
1121
+ {
1122
+ label: /** @type {string} */ (messages.tagsSectionLabel),
1123
+ route: site('/tags'),
1124
+ },
1125
+ ],
1126
+ [{ label: tag }],
1127
+ ),
1128
+ kicker: /** @type {string} */ (messages.tagKickerLabel),
1129
+ count: items.length,
1130
+ title: tag,
1131
+ extraHtml: tagPills(tag),
1132
+ }) + rowList(items),
527
1133
  });
528
1134
  }
529
1135
 
530
- /** @type {Map<string, import('../../../types/index.d.ts').ContentBuildRecord[]>} */
531
- const bySeries = new Map();
532
- for (const record of content) {
533
- const series = record.frontmatter.series;
534
- if (!series || record.frontmatter.status !== 'published') continue;
535
- const bucket = bySeries.get(series) ?? [];
536
- bucket.push(record);
537
- bySeries.set(series, bucket);
538
- }
539
- const sortedSeries = [...bySeries.keys()].sort();
540
1136
  if (sortedSeries.length > 0) {
541
1137
  pages.push({
542
1138
  kind: 'series',
543
1139
  route: '/series',
544
- title: /** @type {string} */ (messages.seriesSectionLabel),
1140
+ ...listingMeta({
1141
+ name: text0('seriesSectionLabel'),
1142
+ description: msg(
1143
+ 'seriesRootDescription',
1144
+ String(sortedSeries.length),
1145
+ publicationTitle,
1146
+ ),
1147
+ route: '/series',
1148
+ }),
545
1149
  language: publication.defaultLanguage,
546
- description: publication.description,
547
- ogType: 'website',
548
- socialImageRef: publication.defaultImage,
549
1150
  lastModified: latestContentTimestamp([...bySeries.values()].flat()),
550
- bodyHtml:
551
- `<h1>${escapeHtml(/** @type {string} */ (messages.seriesSectionLabel))}</h1>` +
552
- `<ul>${sortedSeries
553
- .map(
554
- (series) =>
555
- `<li><a href="${escapeHtml(site(`/series/${routeSegmentForLabel(series)}`))}">${escapeHtml(series)}</a></li>`,
1151
+ bodyHtml: listingHead({
1152
+ breadcrumbHtml: crumbs([
1153
+ { label: /** @type {string} */ (messages.seriesSectionLabel) },
1154
+ ]),
1155
+ kicker: /** @type {string} */ (messages.seriesKickerLabel),
1156
+ count: undefined,
1157
+ title: /** @type {string} */ (messages.seriesSectionLabel),
1158
+ iconName: 'book',
1159
+ extraHtml: `<ul class="g-pills">${sortedSeries
1160
+ .map((name) =>
1161
+ pill(seriesHref(name), name, bySeries.get(name)?.length, 'book'),
556
1162
  )
557
1163
  .join('')}</ul>`,
1164
+ }),
558
1165
  });
559
1166
  }
560
- for (const series of sortedSeries) {
1167
+ for (const name of sortedSeries) {
561
1168
  const items =
562
1169
  /** @type {import('../../../types/index.d.ts').ContentBuildRecord[]} */ (
563
- bySeries.get(series)
564
- )
565
- .slice()
566
- .sort(
567
- (a, b) =>
568
- (a.frontmatter.seriesOrder ?? 0) - (b.frontmatter.seriesOrder ?? 0),
569
- );
570
- const heading = `${/** @type {string} */ (messages.seriesIndexLabelPrefix)} ${series}`;
1170
+ bySeries.get(name)
1171
+ );
571
1172
  pages.push({
572
1173
  kind: 'series',
573
- route: `/series/${routeSegmentForLabel(series)}`,
574
- title: heading,
575
- language: publication.defaultLanguage,
576
- ogType: 'website',
577
- socialImageRef: publication.defaultImage,
578
- lastModified: latestContentTimestamp(items),
579
- breadcrumbHtml: renderBreadcrumbs({
580
- trail: [
581
- {
582
- label: /** @type {string} */ (messages.homeLinkLabel),
583
- route: site('/'),
584
- },
585
- {
586
- label: /** @type {string} */ (messages.seriesSectionLabel),
587
- route: site('/series'),
588
- },
589
- { label: series },
1174
+ route: `/series/${routeSegmentForLabel(name)}`,
1175
+ ...listingMeta({
1176
+ name,
1177
+ description: msg(
1178
+ 'seriesPageDescription',
1179
+ String(items.length),
1180
+ name,
1181
+ publicationTitle,
1182
+ ),
1183
+ route: `/series/${routeSegmentForLabel(name)}`,
1184
+ parents: [
1185
+ { label: text0('seriesSectionLabel'), route: site('/series') },
590
1186
  ],
591
- messages,
592
1187
  }),
1188
+ language: publication.defaultLanguage,
1189
+ lastModified: latestContentTimestamp(items),
593
1190
  bodyHtml:
594
- `<h1>${escapeHtml(heading)}</h1>` +
595
- `<ol>${items.map((record) => renderContentSummary(record)).join('')}</ol>`,
1191
+ listingHead({
1192
+ breadcrumbHtml: crumbs(
1193
+ [
1194
+ {
1195
+ label: /** @type {string} */ (messages.seriesSectionLabel),
1196
+ route: site('/series'),
1197
+ },
1198
+ ],
1199
+ [{ label: name }],
1200
+ ),
1201
+ kicker: /** @type {string} */ (messages.seriesKickerLabel),
1202
+ count: items.length,
1203
+ title: name,
1204
+ iconName: 'book',
1205
+ }) + rowList(items),
596
1206
  });
597
1207
  }
598
1208
 
599
- /** @type {Map<string, import('../../../types/index.d.ts').ContentBuildRecord[]>} */
600
- const byYear = new Map();
601
- for (const record of publishedArticles) {
602
- const year = record.frontmatter.publishedAt.slice(0, 4);
603
- const bucket = byYear.get(year) ?? [];
604
- bucket.push(record);
605
- byYear.set(year, bucket);
606
- }
607
- const sortedYears = [...byYear.keys()].sort().reverse();
608
1209
  if (sortedYears.length > 0) {
609
1210
  pages.push({
610
1211
  kind: 'archive',
611
1212
  route: '/archive',
612
- title: /** @type {string} */ (messages.archiveSectionLabel),
1213
+ ...listingMeta({
1214
+ name: text0('archiveSectionLabel'),
1215
+ description: msg(
1216
+ 'archiveRootDescription',
1217
+ String(publishedArticles.length),
1218
+ publicationTitle,
1219
+ ),
1220
+ route: '/archive',
1221
+ }),
613
1222
  language: publication.defaultLanguage,
614
- description: publication.description,
615
- ogType: 'website',
616
- socialImageRef: publication.defaultImage,
617
1223
  lastModified: latestContentTimestamp(publishedArticles),
618
- bodyHtml:
619
- `<h1>${escapeHtml(/** @type {string} */ (messages.archiveSectionLabel))}</h1>` +
620
- `<ul>${sortedYears
621
- .map(
622
- (year) =>
623
- `<li><a href="${escapeHtml(site(`/archive/${year}`))}">${escapeHtml(year)}</a></li>`,
1224
+ bodyHtml: listingHead({
1225
+ breadcrumbHtml: crumbs([
1226
+ { label: /** @type {string} */ (messages.archiveSectionLabel) },
1227
+ ]),
1228
+ kicker: /** @type {string} */ (messages.archiveKickerLabel),
1229
+ count: publishedArticles.length,
1230
+ title: /** @type {string} */ (messages.archiveSectionLabel),
1231
+ iconName: 'layers',
1232
+ extraHtml: `<ul class="g-pills">${sortedYears
1233
+ .map((year) =>
1234
+ pill(
1235
+ site(`/archive/${year}`),
1236
+ year,
1237
+ byYear.get(year)?.length,
1238
+ 'layers',
1239
+ ),
624
1240
  )
625
1241
  .join('')}</ul>`,
1242
+ }),
626
1243
  });
627
1244
  }
628
1245
  for (const year of sortedYears) {
@@ -631,45 +1248,56 @@ export function buildGeneratedPages(validatedInput) {
631
1248
  byYear.get(year)
632
1249
  );
633
1250
  const yearPages = paginate(items, LISTING_PAGE_SIZE);
1251
+ const yearRoute = (/** @type {number} */ n) =>
1252
+ n === 1 ? `/archive/${year}` : `/archive/${year}/page/${n}`;
634
1253
  yearPages.forEach((pageItems, pageIndex) => {
635
1254
  const pageNumber = pageIndex + 1;
636
- const route =
637
- pageNumber === 1
638
- ? `/archive/${year}`
639
- : `/archive/${year}/page/${pageNumber}`;
640
- const heading = `${/** @type {string} */ (messages.archiveYearLabelPrefix)} ${year}`;
641
1255
  pages.push({
642
1256
  kind: 'archive',
643
- route,
644
- title: heading,
645
- language: publication.defaultLanguage,
646
- ogType: 'website',
647
- socialImageRef: publication.defaultImage,
648
- lastModified: latestContentTimestamp(pageItems),
649
- breadcrumbHtml: renderBreadcrumbs({
650
- trail: [
651
- {
652
- label: /** @type {string} */ (messages.homeLinkLabel),
653
- route: site('/'),
654
- },
655
- {
656
- label: /** @type {string} */ (messages.archiveSectionLabel),
657
- route: site('/archive'),
658
- },
659
- { label: year },
1257
+ route: yearRoute(pageNumber),
1258
+ ...listingMeta({
1259
+ name:
1260
+ pageNumber === 1
1261
+ ? msg('archivePageTitle', year)
1262
+ : `${msg('archivePageTitle', year)} \u2013 ${msg('pageStatusLabel', String(pageNumber), String(yearPages.length))}`,
1263
+ description: msg(
1264
+ 'archivePageDescription',
1265
+ String(items.length),
1266
+ year,
1267
+ publicationTitle,
1268
+ ),
1269
+ route: yearRoute(pageNumber),
1270
+ parents: [
1271
+ { label: text0('archiveSectionLabel'), route: site('/archive') },
660
1272
  ],
661
- messages,
662
1273
  }),
1274
+ language: publication.defaultLanguage,
1275
+ lastModified: latestContentTimestamp(pageItems),
663
1276
  bodyHtml:
664
- `<h1>${escapeHtml(heading)}</h1>` +
665
- `<ul>${pageItems.map((record) => renderContentSummary(record)).join('')}</ul>` +
666
- renderPagination({
667
- currentPage: pageNumber,
668
- totalPages: yearPages.length,
669
- routeForPage: (n) =>
670
- site(n === 1 ? `/archive/${year}` : `/archive/${year}/page/${n}`),
671
- messages,
672
- }),
1277
+ listingHead({
1278
+ breadcrumbHtml: crumbs(
1279
+ [
1280
+ {
1281
+ label: /** @type {string} */ (messages.archiveSectionLabel),
1282
+ route: site('/archive'),
1283
+ },
1284
+ ],
1285
+ [{ label: year }],
1286
+ ),
1287
+ kicker: /** @type {string} */ (messages.archiveKickerLabel),
1288
+ count: items.length,
1289
+ title: year,
1290
+ iconName: 'layers',
1291
+ }) +
1292
+ rowList(
1293
+ pageItems,
1294
+ renderPagination({
1295
+ currentPage: pageNumber,
1296
+ totalPages: yearPages.length,
1297
+ routeForPage: (n) => site(yearRoute(n)),
1298
+ messages,
1299
+ }),
1300
+ ),
673
1301
  });
674
1302
  });
675
1303
  }
@@ -701,8 +1329,8 @@ export function renderErrorPageBody({ messages, homeRoute }) {
701
1329
  /** @type {string} */ (messages.errorPageReturnHomeLabel),
702
1330
  );
703
1331
  return (
704
- `<h1>${heading}</h1>` +
705
- `<p>${body}</p>` +
706
- `<p><a href="${escapeHtml(homeRoute)}">${returnLabel}</a></p>`
1332
+ `<section class="g-wrap g-topic-hero"><h1>${heading}</h1>` +
1333
+ `<p class="g-dek">${body}</p>` +
1334
+ `<p><a class="g-btn" href="${escapeHtml(homeRoute)}">${returnLabel}${icon('arrow')}</a></p></section>`
707
1335
  );
708
1336
  }