@rathnasgala2/template 2.1.0 → 3.0.0

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,10 +58,22 @@ 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';
64
- import { joinBasePathAndRoute } from './route.js';
75
+ import { deriveAuthorSlugs, routeSegmentForLabel } from './route-labels.js';
76
+ import { derivePublicBasePath, joinPublicRoute } from './route.js';
65
77
 
66
78
  /** Listing page size (index and per-year archive pages). Scoped decision:
67
79
  * this renderer has no externally fixed page size; 10 is a deterministic,
@@ -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,16 +258,106 @@ 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) {
267
- const { publication, authors, content, basePath } = validatedInput;
358
+ export function buildGeneratedPages(validatedInput, options = {}) {
359
+ const { publication, authors, content, basePath, baseUrl } = validatedInput;
360
+ const publicBasePath = derivePublicBasePath(baseUrl, basePath);
268
361
  const messages = getMessages(publication.defaultLanguage);
269
362
  const authorsById = new Map(authors.map((author) => [author.id, author]));
270
363
 
@@ -272,39 +365,255 @@ export function buildGeneratedPages(validatedInput) {
272
365
  * @param {string} route an un-joined route
273
366
  * @returns {string} the `basePath`-joined absolute route
274
367
  */
275
- const site = (route) => joinBasePathAndRoute(basePath, route);
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;
276
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;
277
443
  /**
278
444
  * @param {import('../../../types/index.d.ts').ContentBuildRecord} record
279
- * @returns {string} one `<li>` summary
445
+ * @returns {string} the page description: authored, else the body's
446
+ * opening words
280
447
  */
281
- const renderContentSummary = (record) => {
282
- const { frontmatter } = record;
283
- const route = escapeHtml(site(contentRoute(frontmatter)));
284
- const title = escapeHtml(frontmatter.title);
285
- const authorNames = frontmatter.authorIds
286
- .map((id) => requireAuthor(authorsById, id).displayName)
287
- .join(', ');
288
- const byLine = escapeHtml(
289
- /** @type {(displayName: string) => string} */ (messages.byLineLabel)(
290
- authorNames,
291
- ),
292
- );
293
- const time = `<time datetime="${escapeHtml(frontmatter.publishedAt)}">${escapeHtml(frontmatter.publishedAt)}</time>`;
294
- const tags =
295
- frontmatter.tags.length > 0
296
- ? `<ul>${frontmatter.tags
297
- .map(
298
- (tag) =>
299
- `<li><a href="${escapeHtml(site(`/tags/${routeSegmentForLabel(tag)}`))}">${escapeHtml(tag)}</a></li>`,
300
- )
301
- .join('')}</ul>`
302
- : '';
303
- return (
304
- `<li><h2><a href="${route}">${title}</a></h2>` +
305
- `<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),
306
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;
307
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: /** @type {{newsletter?: unknown}} */ (publication).newsletter,
615
+ messages,
616
+ });
308
617
 
309
618
  /** @type {GeneratedPage[]} */
310
619
  const pages = [];
@@ -314,182 +623,474 @@ export function buildGeneratedPages(validatedInput) {
314
623
  kind: 'profile',
315
624
  route: publication.profile.route,
316
625
  title: publication.title,
626
+ documentTitle: documentTitleOf(text0('aboutPageTitle')),
317
627
  language: publication.defaultLanguage,
318
628
  description: publication.description,
319
629
  ogType: 'website',
320
- socialImageRef: publication.defaultImage,
321
- 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>`,
322
650
  });
323
651
  }
324
652
 
325
653
  for (const author of authors) {
326
- const pronouns = author.pronouns
327
- ? `<p>${escapeHtml(author.pronouns)}</p>`
328
- : '';
329
- const biography = author.biography
330
- ? `<p>${escapeHtml(author.biography)}</p>`
331
- : '';
654
+ const authored = publishedArticles.filter((record) =>
655
+ record.frontmatter.authorIds.includes(author.id),
656
+ );
332
657
  const links =
333
658
  author.links.length > 0
334
- ? `<ul>${author.links
659
+ ? `<ul class="g-social">${author.links
335
660
  .map((link) => {
336
661
  const label = escapeHtml(link.label ?? link.uri);
337
- 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>`;
338
663
  })
339
664
  .join('')}</ul>`
340
665
  : '';
341
- const heading = /** @type {(displayName: string) => string} */ (
342
- messages.aboutAuthorHeading
343
- )(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;
344
674
  pages.push({
345
675
  kind: 'author',
346
- route: `/authors/${author.id}`,
347
- title: heading,
676
+ route: authorRoute(author),
677
+ title: author.displayName,
678
+ documentTitle: documentTitleOf(author.displayName),
348
679
  language: publication.defaultLanguage,
349
- description: author.biography || undefined,
680
+ description:
681
+ truncateAtWord(author.biography, DESCRIPTION_MAX) ||
682
+ msg('authorPageDescription', author.displayName, publicationTitle),
350
683
  ogType: 'profile',
351
- socialImageRef: author.avatar,
352
- breadcrumbHtml: renderBreadcrumbs({
353
- trail: [
354
- {
355
- label: /** @type {string} */ (messages.homeLinkLabel),
356
- route: site('/'),
357
- },
358
- { label: /** @type {string} */ (messages.authorsSectionLabel) },
359
- { label: author.displayName },
360
- ],
361
- messages,
362
- }),
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
+ ),
363
700
  bodyHtml:
364
- `<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),
365
712
  });
366
713
  }
367
714
 
368
715
  for (const record of content) {
369
716
  const { frontmatter } = record;
370
- const authorNames = frontmatter.authorIds
371
- .map((id) => requireAuthor(authorsById, id).displayName)
372
- .join(', ');
373
- const byLine = `<p>${escapeHtml(
374
- /** @type {(displayName: string) => string} */ (messages.byLineLabel)(
375
- authorNames,
376
- ),
377
- )}</p>`;
378
- 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
+ : '';
379
771
  const tags =
380
- frontmatter.tags.length > 0
381
- ? `<ul>${frontmatter.tags
772
+ isArticle && frontmatter.tags.length > 0
773
+ ? `<ul class="g-tags" aria-label="${text('tagListLabel')}">${frontmatter.tags
382
774
  .map(
383
775
  (tag) =>
384
- `<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>`,
385
777
  )
386
778
  .join('')}</ul>`
387
779
  : '';
388
- const seriesHtml = frontmatter.series
389
- ? `<p><a href="${escapeHtml(site(`/series/${routeSegmentForLabel(frontmatter.series)}`))}">${escapeHtml(frontmatter.series)}</a></p>`
390
- : '';
391
- const breadcrumbTrail =
392
- frontmatter.kind === 'article'
393
- ? [
394
- {
395
- label: /** @type {string} */ (messages.homeLinkLabel),
396
- route: site('/'),
397
- },
398
- {
399
- label: /** @type {string} */ (messages.indexHeading),
400
- route: site('/'),
401
- },
402
- { label: frontmatter.title },
403
- ]
404
- : [
405
- {
406
- label: /** @type {string} */ (messages.homeLinkLabel),
407
- route: site('/'),
408
- },
409
- { label: frontmatter.title },
410
- ];
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
+ });
411
885
  pages.push({
412
886
  kind: frontmatter.kind,
413
887
  route: contentRoute(frontmatter),
414
888
  title: frontmatter.title,
889
+ documentTitle: documentTitleOf(frontmatter.title),
415
890
  language: frontmatter.language,
416
- description: frontmatter.description,
417
- ogType: frontmatter.kind === 'article' ? 'article' : 'website',
418
- 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,
419
905
  robotsContent:
420
906
  frontmatter.status === 'unlisted' ? 'noindex, follow' : undefined,
421
907
  lastModified: contentLastModified(frontmatter),
422
- breadcrumbHtml: renderBreadcrumbs({ trail: breadcrumbTrail, messages }),
423
908
  bodyHtml:
424
- `<article>` +
425
- renderSlot('article-preamble') +
426
- `<h1>${escapeHtml(frontmatter.title)}</h1>` +
427
- byLine +
428
- time +
429
- seriesHtml +
430
- tags +
431
- record.body +
432
- renderSlot('article-end') +
433
- renderSlot('article-footer-ad', { collapsed: true }) +
434
- `</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 : ''),
435
913
  });
436
914
  }
437
915
 
438
- const publishedArticles = selectPublishedArticles(content);
439
-
440
- 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
+ : '';
441
988
  indexPages.forEach((items, pageIndex) => {
442
989
  const pageNumber = pageIndex + 1;
443
- 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));
444
1026
  pages.push({
445
1027
  kind: 'index',
446
- route,
447
- 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),
448
1034
  language: publication.defaultLanguage,
449
- description: publication.description,
1035
+ description:
1036
+ pageNumber === 1
1037
+ ? publication.description
1038
+ : msg('indexPageDescription', publicationTitle),
450
1039
  ogType: 'website',
451
- socialImageRef: publication.defaultImage,
452
- lastModified: latestContentTimestamp(items),
453
- bodyHtml:
454
- `<h1>${escapeHtml(/** @type {string} */ (messages.indexHeading))}</h1>` +
455
- `<ul>${items.map((record) => renderContentSummary(record)).join('')}</ul>` +
456
- renderPagination({
457
- currentPage: pageNumber,
458
- totalPages: indexPages.length,
459
- routeForPage: (n) => site(n === 1 ? '/' : `/page/${n}`),
460
- messages,
461
- }),
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,
462
1067
  });
463
1068
  });
464
1069
 
465
- /** @type {Map<string, import('../../../types/index.d.ts').ContentBuildRecord[]>} */
466
- const byTag = new Map();
467
- for (const record of publishedArticles) {
468
- for (const tag of record.frontmatter.tags) {
469
- const bucket = byTag.get(tag) ?? [];
470
- bucket.push(record);
471
- byTag.set(tag, bucket);
472
- }
473
- }
474
- const sortedTags = [...byTag.keys()].sort();
475
1070
  if (sortedTags.length > 0) {
476
1071
  pages.push({
477
1072
  kind: 'tag',
478
1073
  route: '/tags',
479
- 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
+ }),
480
1083
  language: publication.defaultLanguage,
481
- description: publication.description,
482
- ogType: 'website',
483
- socialImageRef: publication.defaultImage,
484
1084
  lastModified: latestContentTimestamp(publishedArticles),
485
- bodyHtml:
486
- `<h1>${escapeHtml(/** @type {string} */ (messages.tagsSectionLabel))}</h1>` +
487
- `<ul>${sortedTags
488
- .map(
489
- (tag) =>
490
- `<li><a href="${escapeHtml(site(`/tags/${routeSegmentForLabel(tag)}`))}">${escapeHtml(tag)}</a></li>`,
491
- )
492
- .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
+ }),
493
1094
  });
494
1095
  }
495
1096
  for (const tag of sortedTags) {
@@ -497,131 +1098,148 @@ export function buildGeneratedPages(validatedInput) {
497
1098
  /** @type {import('../../../types/index.d.ts').ContentBuildRecord[]} */ (
498
1099
  byTag.get(tag)
499
1100
  );
500
- const heading = `${/** @type {string} */ (messages.tagIndexLabelPrefix)} ${tag}`;
501
1101
  pages.push({
502
1102
  kind: 'tag',
503
1103
  route: `/tags/${routeSegmentForLabel(tag)}`,
504
- 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
+ }),
505
1115
  language: publication.defaultLanguage,
506
- ogType: 'website',
507
- socialImageRef: publication.defaultImage,
508
1116
  lastModified: latestContentTimestamp(items),
509
- breadcrumbHtml: renderBreadcrumbs({
510
- trail: [
511
- {
512
- label: /** @type {string} */ (messages.homeLinkLabel),
513
- route: site('/'),
514
- },
515
- {
516
- label: /** @type {string} */ (messages.tagsSectionLabel),
517
- route: site('/tags'),
518
- },
519
- { label: tag },
520
- ],
521
- messages,
522
- }),
523
1117
  bodyHtml:
524
- `<h1>${escapeHtml(heading)}</h1>` +
525
- `<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),
526
1133
  });
527
1134
  }
528
1135
 
529
- /** @type {Map<string, import('../../../types/index.d.ts').ContentBuildRecord[]>} */
530
- const bySeries = new Map();
531
- for (const record of content) {
532
- const series = record.frontmatter.series;
533
- if (!series || record.frontmatter.status !== 'published') continue;
534
- const bucket = bySeries.get(series) ?? [];
535
- bucket.push(record);
536
- bySeries.set(series, bucket);
537
- }
538
- const sortedSeries = [...bySeries.keys()].sort();
539
1136
  if (sortedSeries.length > 0) {
540
1137
  pages.push({
541
1138
  kind: 'series',
542
1139
  route: '/series',
543
- 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
+ }),
544
1149
  language: publication.defaultLanguage,
545
- description: publication.description,
546
- ogType: 'website',
547
- socialImageRef: publication.defaultImage,
548
1150
  lastModified: latestContentTimestamp([...bySeries.values()].flat()),
549
- bodyHtml:
550
- `<h1>${escapeHtml(/** @type {string} */ (messages.seriesSectionLabel))}</h1>` +
551
- `<ul>${sortedSeries
552
- .map(
553
- (series) =>
554
- `<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'),
555
1162
  )
556
1163
  .join('')}</ul>`,
1164
+ }),
557
1165
  });
558
1166
  }
559
- for (const series of sortedSeries) {
1167
+ for (const name of sortedSeries) {
560
1168
  const items =
561
1169
  /** @type {import('../../../types/index.d.ts').ContentBuildRecord[]} */ (
562
- bySeries.get(series)
563
- )
564
- .slice()
565
- .sort(
566
- (a, b) =>
567
- (a.frontmatter.seriesOrder ?? 0) - (b.frontmatter.seriesOrder ?? 0),
568
- );
569
- const heading = `${/** @type {string} */ (messages.seriesIndexLabelPrefix)} ${series}`;
1170
+ bySeries.get(name)
1171
+ );
570
1172
  pages.push({
571
1173
  kind: 'series',
572
- route: `/series/${routeSegmentForLabel(series)}`,
573
- title: heading,
574
- language: publication.defaultLanguage,
575
- ogType: 'website',
576
- socialImageRef: publication.defaultImage,
577
- lastModified: latestContentTimestamp(items),
578
- breadcrumbHtml: renderBreadcrumbs({
579
- trail: [
580
- {
581
- label: /** @type {string} */ (messages.homeLinkLabel),
582
- route: site('/'),
583
- },
584
- {
585
- label: /** @type {string} */ (messages.seriesSectionLabel),
586
- route: site('/series'),
587
- },
588
- { 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') },
589
1186
  ],
590
- messages,
591
1187
  }),
1188
+ language: publication.defaultLanguage,
1189
+ lastModified: latestContentTimestamp(items),
592
1190
  bodyHtml:
593
- `<h1>${escapeHtml(heading)}</h1>` +
594
- `<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),
595
1206
  });
596
1207
  }
597
1208
 
598
- /** @type {Map<string, import('../../../types/index.d.ts').ContentBuildRecord[]>} */
599
- const byYear = new Map();
600
- for (const record of publishedArticles) {
601
- const year = record.frontmatter.publishedAt.slice(0, 4);
602
- const bucket = byYear.get(year) ?? [];
603
- bucket.push(record);
604
- byYear.set(year, bucket);
605
- }
606
- const sortedYears = [...byYear.keys()].sort().reverse();
607
1209
  if (sortedYears.length > 0) {
608
1210
  pages.push({
609
1211
  kind: 'archive',
610
1212
  route: '/archive',
611
- 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
+ }),
612
1222
  language: publication.defaultLanguage,
613
- description: publication.description,
614
- ogType: 'website',
615
- socialImageRef: publication.defaultImage,
616
1223
  lastModified: latestContentTimestamp(publishedArticles),
617
- bodyHtml:
618
- `<h1>${escapeHtml(/** @type {string} */ (messages.archiveSectionLabel))}</h1>` +
619
- `<ul>${sortedYears
620
- .map(
621
- (year) =>
622
- `<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
+ ),
623
1240
  )
624
1241
  .join('')}</ul>`,
1242
+ }),
625
1243
  });
626
1244
  }
627
1245
  for (const year of sortedYears) {
@@ -630,45 +1248,56 @@ export function buildGeneratedPages(validatedInput) {
630
1248
  byYear.get(year)
631
1249
  );
632
1250
  const yearPages = paginate(items, LISTING_PAGE_SIZE);
1251
+ const yearRoute = (/** @type {number} */ n) =>
1252
+ n === 1 ? `/archive/${year}` : `/archive/${year}/page/${n}`;
633
1253
  yearPages.forEach((pageItems, pageIndex) => {
634
1254
  const pageNumber = pageIndex + 1;
635
- const route =
636
- pageNumber === 1
637
- ? `/archive/${year}`
638
- : `/archive/${year}/page/${pageNumber}`;
639
- const heading = `${/** @type {string} */ (messages.archiveYearLabelPrefix)} ${year}`;
640
1255
  pages.push({
641
1256
  kind: 'archive',
642
- route,
643
- title: heading,
644
- language: publication.defaultLanguage,
645
- ogType: 'website',
646
- socialImageRef: publication.defaultImage,
647
- lastModified: latestContentTimestamp(pageItems),
648
- breadcrumbHtml: renderBreadcrumbs({
649
- trail: [
650
- {
651
- label: /** @type {string} */ (messages.homeLinkLabel),
652
- route: site('/'),
653
- },
654
- {
655
- label: /** @type {string} */ (messages.archiveSectionLabel),
656
- route: site('/archive'),
657
- },
658
- { 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') },
659
1272
  ],
660
- messages,
661
1273
  }),
1274
+ language: publication.defaultLanguage,
1275
+ lastModified: latestContentTimestamp(pageItems),
662
1276
  bodyHtml:
663
- `<h1>${escapeHtml(heading)}</h1>` +
664
- `<ul>${pageItems.map((record) => renderContentSummary(record)).join('')}</ul>` +
665
- renderPagination({
666
- currentPage: pageNumber,
667
- totalPages: yearPages.length,
668
- routeForPage: (n) =>
669
- site(n === 1 ? `/archive/${year}` : `/archive/${year}/page/${n}`),
670
- messages,
671
- }),
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
+ ),
672
1301
  });
673
1302
  });
674
1303
  }
@@ -700,8 +1329,8 @@ export function renderErrorPageBody({ messages, homeRoute }) {
700
1329
  /** @type {string} */ (messages.errorPageReturnHomeLabel),
701
1330
  );
702
1331
  return (
703
- `<h1>${heading}</h1>` +
704
- `<p>${body}</p>` +
705
- `<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>`
706
1335
  );
707
1336
  }