@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.
package/src/core/index.js CHANGED
@@ -99,6 +99,10 @@ import {
99
99
  renderErrorPageBody,
100
100
  } from './internal/page-kinds.js';
101
101
  import { assertProvenance } from './internal/provenance.js';
102
+ import {
103
+ deriveAuthorSlugs,
104
+ routeSegmentForLabel,
105
+ } from './internal/route-labels.js';
102
106
  import {
103
107
  derivePublicBasePath,
104
108
  errorDocumentPath,
@@ -109,7 +113,9 @@ import {
109
113
  renderRedirectDocument,
110
114
  } from './internal/route.js';
111
115
  import { buildSearchIndex } from './internal/search-index.js';
112
- import { resolveSocialImageUrl, twitterCardType } from './internal/seo.js';
116
+ import { resolveSocialImage, twitterCardType } from './internal/seo.js';
117
+ import { buildLlmsDocuments } from './internal/llms.js';
118
+ import { buildRobotsTxt, readAiCrawlerPolicy } from './internal/robots.js';
113
119
  import { buildSitemap } from './internal/sitemap.js';
114
120
  import {
115
121
  defaultThemeStylesheetLinksHtml,
@@ -117,12 +123,12 @@ import {
117
123
  } from './internal/theme-assets.js';
118
124
  import { resolveTextDirection } from './internal/text-direction.js';
119
125
  import {
120
- renderFooter,
121
- renderFooterNavigation,
122
- renderHeader,
126
+ escapeHtml,
123
127
  renderPageBody,
124
128
  renderPrimaryNavigation,
125
129
  } from './internal/skeleton.js';
130
+ import { renderFooter, renderHeader } from './internal/chrome.js';
131
+ import { createComponents, createMediaImage } from './internal/components.js';
126
132
  import {
127
133
  collectIncludedSources,
128
134
  compareUtf8Bytes,
@@ -135,6 +141,13 @@ const SITEMAP_ROUTE = '/sitemap.xml';
135
141
  /** @type {string} the un-joined route the static search index is published
136
142
  * at. */
137
143
  const SEARCH_INDEX_ROUTE = '/search-index.json';
144
+ /** @type {string} the output-root path of `robots.txt` (never `basePath`-
145
+ * joined: only a host-root robots file is honoured by crawlers). */
146
+ const ROBOTS_PATH = 'robots.txt';
147
+ /** @type {string} the un-joined route of the llmstxt.org index. */
148
+ const LLMS_ROUTE = '/llms.txt';
149
+ /** @type {string} the un-joined route of the full-text companion. */
150
+ const LLMS_FULL_ROUTE = '/llms-full.txt';
138
151
 
139
152
  /** @type {string} */
140
153
  const BUILD_INPUT_SCHEMA_ID = 'urn:gala:schema:build-input:2.0.0';
@@ -336,42 +349,116 @@ export async function renderPublication(buildInput, options) {
336
349
  const appearanceControlHtml = renderAppearanceControl({
337
350
  messages: siteMessages,
338
351
  });
339
- const headerHtml = renderHeader({
340
- homeRoute,
341
- publicationName: validatedInput.publication.title,
342
- logo: undefined,
343
- appearanceControlHtml,
352
+
353
+ // Run before page assembly, not after: resolving each page's own images
354
+ // (`og:image`, hero, avatar, brand mark) needs the media pipeline's own
355
+ // finished `assets` list (only it knows which derivative file extension a
356
+ // given source image's `sourceDigest` produced — see `internal/seo.js`).
357
+ // This only moves the in-memory computation earlier; `mediaFiles`' actual
358
+ // bytes are still written to `outputDirectory` only after the route
359
+ // listing below, so `outputDirectory/assets/media/` still can never be
360
+ // mistaken for an Eleventy-rendered HTML route.
361
+ const {
362
+ assets: mediaAssets,
363
+ files: mediaFiles,
364
+ dimensions: mediaDimensions,
365
+ } = await processMedia(validatedInput, {
366
+ sourceDirectory: path.resolve(options.sourceDirectory),
344
367
  });
345
- const footerNavHtml = renderFooterNavigation({
346
- items: validatedInput.navigation.footerItems,
347
- messages: siteMessages,
348
- urlPrefix,
368
+ const mediaImage = createMediaImage({
369
+ mediaAssets,
370
+ publicBasePath,
371
+ mediaDimensions,
349
372
  });
350
- const footerCard = validatedInput.publication.footerCard;
373
+ const brandMark = mediaImage(validatedInput.appearance.brandMark);
374
+ const searchIndexHref = joinBasePathAndRoute(
375
+ publicBasePath,
376
+ SEARCH_INDEX_ROUTE,
377
+ );
378
+ const rssHref = joinBasePathAndRoute(publicBasePath, RSS_FEED_ROUTE);
379
+ const publication = validatedInput.publication;
380
+ const footerCard = publication.footerCard;
351
381
  // Already verified render-policy-conformant HTML by
352
382
  // assertRenderPolicyCompliance's assertPolicyConformantHtml call above —
353
383
  // inserted as-is, never re-parsed as Markdown.
354
384
  const footerProfileHtml = footerCard?.enabled ? footerCard.body.body : '';
385
+ const authorsById = new Map(
386
+ validatedInput.authors.map((author) => [author.id, author]),
387
+ );
388
+ const footerAuthor =
389
+ authorsById.get(publication.contactAuthorId ?? '') ??
390
+ validatedInput.authors[0];
391
+ const authorSlugs = deriveAuthorSlugs(validatedInput.authors);
392
+ const authorHref = (
393
+ /** @type {import('../../types/index.d.ts').AuthorNormalized} */ author,
394
+ ) =>
395
+ joinPublicRoute(
396
+ publicBasePath,
397
+ `/authors/${authorSlugs.get(author.id) ?? author.id}`,
398
+ );
399
+ const footerComponents = createComponents({
400
+ messages: siteMessages,
401
+ site: (route) => joinPublicRoute(publicBasePath, route),
402
+ mediaImage,
403
+ authorHref,
404
+ authorsById,
405
+ recordHref: (record) =>
406
+ joinPublicRoute(publicBasePath, contentRoute(record.frontmatter)),
407
+ tagHref: (tag) =>
408
+ joinPublicRoute(publicBasePath, `/tags/${routeSegmentForLabel(tag)}`),
409
+ });
410
+ const footerAuthorHtml = footerAuthor
411
+ ? `<p class="g-label">${footerComponents.text('writtenByLabel')}</p>` +
412
+ `<div class="g-byline">${footerComponents.avatar(footerAuthor)}<div class="g-byline-text">` +
413
+ `<a href="${escapeHtml(authorHref(footerAuthor))}">${escapeHtml(footerAuthor.displayName)}</a>` +
414
+ `</div></div>`
415
+ : '';
416
+ const latestYear = validatedInput.content
417
+ .map((record) => record.frontmatter.publishedAt.slice(0, 4))
418
+ .sort()
419
+ .at(-1);
355
420
  const footerHtml = renderFooter({
356
- publicationName: validatedInput.publication.title,
357
- footerNavHtml,
421
+ homeRoute,
422
+ publicationName: publication.title,
423
+ description: publication.description,
424
+ brandMark,
425
+ socialLinks: publication.socialLinks,
426
+ rssHref,
427
+ primaryItems: validatedInput.navigation.items,
428
+ footerItems: validatedInput.navigation.footerItems,
429
+ urlPrefix,
430
+ authorHtml: footerAuthorHtml,
358
431
  footerProfileHtml,
359
- copyrightText: undefined,
432
+ copyrightText: `${siteMessages.footerCopyrightPrefix} ${latestYear ? `${latestYear} ` : ''}${publication.title}`,
360
433
  messages: siteMessages,
361
434
  });
362
435
 
363
- // Run before page assembly, not after: resolving each page's own
364
- // `og:image`/`twitter:image` needs the media pipeline's own finished
365
- // `assets` list (only it knows which derivative file extension a given
366
- // source image's `sourceDigest` produced — see `internal/seo.js`). This
367
- // only moves the in-memory computation earlier; `mediaFiles`' actual
368
- // bytes are still written to `outputDirectory` only after the route
369
- // listing below, so `outputDirectory/assets/media/` still can never be
370
- // mistaken for an Eleventy-rendered HTML route.
371
- const { assets: mediaAssets, files: mediaFiles } = await processMedia(
372
- validatedInput,
373
- { sourceDirectory: path.resolve(options.sourceDirectory) },
374
- );
436
+ /**
437
+ * One page's `<header>`, with the primary navigation marking that page's
438
+ * own route current.
439
+ *
440
+ * @param {string | undefined} currentRoute the page's joined route
441
+ * @returns {string}
442
+ */
443
+ const headerFor = (currentRoute) =>
444
+ renderHeader({
445
+ homeRoute,
446
+ publicationName: publication.title,
447
+ tagline: publication.description,
448
+ brandMark,
449
+ navHtml: renderPrimaryNavigation({
450
+ items: validatedInput.navigation.items,
451
+ currentRoute,
452
+ messages: siteMessages,
453
+ urlPrefix,
454
+ }),
455
+ navItems: validatedInput.navigation.items,
456
+ currentRoute,
457
+ urlPrefix,
458
+ searchIndexHref,
459
+ appearanceControlHtml,
460
+ messages: siteMessages,
461
+ });
375
462
 
376
463
  // The site-wide feeds, resolved once so every page's feed-discovery
377
464
  // `<link>` tags and the feed documents themselves agree on the exact same
@@ -410,39 +497,37 @@ export async function renderPublication(buildInput, options) {
410
497
  /** @type {{joinedSource: string, joinedTarget: string, publicTarget: string, filePath: string}[]} */
411
498
  const redirectProjections = [];
412
499
 
413
- const generatedPages = buildGeneratedPages(validatedInput);
500
+ const generatedPages = buildGeneratedPages(validatedInput, {
501
+ mediaAssets,
502
+ mediaDimensions,
503
+ });
414
504
  generatedPages.forEach((page, index) => {
415
505
  const joinedRoute = joinBasePathAndRoute(
416
506
  validatedInput.basePath,
417
507
  page.route,
418
508
  );
419
- const navHtml = renderPrimaryNavigation({
420
- items: validatedInput.navigation.items,
421
- currentRoute: joinedRoute,
422
- messages: siteMessages,
423
- urlPrefix,
424
- });
425
509
  const bodyHtml = renderPageBody({
426
510
  messages: siteMessages,
427
- headerHtml,
428
- navHtml,
511
+ headerHtml: headerFor(joinedRoute),
429
512
  footerHtml,
430
- breadcrumbHtml: page.breadcrumbHtml,
431
513
  mainHtml: page.bodyHtml,
432
514
  });
433
- const ogImage = resolveSocialImageUrl({
515
+ const ogImageInfo = resolveSocialImage({
434
516
  mediaAssets,
517
+ mediaDimensions,
435
518
  reference: page.socialImageRef,
436
519
  baseUrl: validatedInput.baseUrl,
437
520
  basePath: publicBasePath,
438
521
  });
522
+ const ogImage = ogImageInfo?.url;
439
523
  contentPages.push({
440
524
  virtualPath: `pages/${page.kind}-${index}.html`,
441
525
  content: bodyHtml,
442
526
  permalink: projectRouteToFilePath(joinedRoute, routeProfile),
443
527
  layout: 'skeleton.njk',
444
528
  data: {
445
- title: page.title,
529
+ title: page.documentTitle ?? page.title,
530
+ ogTitle: page.title,
446
531
  lang: page.language,
447
532
  dir: page.direction,
448
533
  pageKind: page.kind,
@@ -455,6 +540,14 @@ export async function renderPublication(buildInput, options) {
455
540
  siteName,
456
541
  ogType: page.ogType ?? 'website',
457
542
  ogImage,
543
+ ogImageWidth: ogImageInfo?.width,
544
+ ogImageHeight: ogImageInfo?.height,
545
+ ogImageAlt: ogImage ? page.socialImageAlt : undefined,
546
+ articlePublished: page.articleMeta?.publishedTime,
547
+ articleModified: page.articleMeta?.modifiedTime,
548
+ articleAuthors: page.articleMeta?.authorUrls ?? [],
549
+ articleTags: page.articleMeta?.tags ?? [],
550
+ jsonLd: page.jsonLd,
458
551
  twitterCard: twitterCardType(Boolean(ogImage)),
459
552
  atomFeedUrl: feeds.atomSelfUrl,
460
553
  rssFeedUrl: feeds.rssSelfUrl,
@@ -503,16 +596,9 @@ export async function renderPublication(buildInput, options) {
503
596
  // page kind's own pure body function. Pushed after the guard above so the
504
597
  // guard's own "is there anything authored to render" check is unaffected
505
598
  // by this always-present synthetic page.
506
- const errorNavHtml = renderPrimaryNavigation({
507
- items: validatedInput.navigation.items,
508
- currentRoute: undefined,
509
- messages: siteMessages,
510
- urlPrefix,
511
- });
512
599
  const errorBodyHtml = renderPageBody({
513
600
  messages: siteMessages,
514
- headerHtml,
515
- navHtml: errorNavHtml,
601
+ headerHtml: headerFor(undefined),
516
602
  footerHtml,
517
603
  mainHtml: renderErrorPageBody({ messages: siteMessages, homeRoute }),
518
604
  });
@@ -523,7 +609,8 @@ export async function renderPublication(buildInput, options) {
523
609
  permalink: errorFilePath,
524
610
  layout: 'skeleton.njk',
525
611
  data: {
526
- title: /** @type {string} */ (siteMessages.errorPageHeading),
612
+ title: `${/** @type {string} */ (siteMessages.errorPageHeading)} | ${siteName}`,
613
+ ogTitle: /** @type {string} */ (siteMessages.errorPageHeading),
527
614
  lang: validatedInput.publication.defaultLanguage,
528
615
  dir: resolveTextDirection(validatedInput.publication.defaultLanguage),
529
616
  pageKind: 'error',
@@ -569,7 +656,38 @@ export async function renderPublication(buildInput, options) {
569
656
  baseUrl: validatedInput.baseUrl,
570
657
  });
571
658
  const searchIndexJson = buildSearchIndex(validatedInput);
659
+ const absoluteSiteUrl = (/** @type {string} */ route) =>
660
+ new URL(
661
+ joinPublicRoute(publicBasePath, route),
662
+ validatedInput.baseUrl,
663
+ ).toString();
664
+ const robotsTxt = buildRobotsTxt({
665
+ aiPolicy: readAiCrawlerPolicy(validatedInput.publication),
666
+ sitemapUrl: absoluteSiteUrl(SITEMAP_ROUTE),
667
+ });
668
+ const llmsFilePath = projectFixedAssetPath(
669
+ validatedInput.basePath,
670
+ LLMS_ROUTE,
671
+ );
672
+ const llmsFullFilePath = projectFixedAssetPath(
673
+ validatedInput.basePath,
674
+ LLMS_FULL_ROUTE,
675
+ );
676
+ const { llmsTxt, llmsFullTxt } = buildLlmsDocuments(validatedInput, {
677
+ rss: feeds.rssSelfUrl,
678
+ atom: feeds.atomSelfUrl,
679
+ sitemap: absoluteSiteUrl(SITEMAP_ROUTE),
680
+ searchIndex: absoluteSiteUrl(SEARCH_INDEX_ROUTE),
681
+ full: absoluteSiteUrl(LLMS_FULL_ROUTE),
682
+ });
572
683
  const assetPages = [
684
+ { virtualPath: 'robots.html', content: robotsTxt, permalink: ROBOTS_PATH },
685
+ { virtualPath: 'llms.html', content: llmsTxt, permalink: llmsFilePath },
686
+ {
687
+ virtualPath: 'llms-full.html',
688
+ content: llmsFullTxt,
689
+ permalink: llmsFullFilePath,
690
+ },
573
691
  {
574
692
  virtualPath: 'feeds/atom.html',
575
693
  content: feeds.atomXml,
@@ -623,6 +741,18 @@ export async function renderPublication(buildInput, options) {
623
741
  searchIndexFilePath,
624
742
  { routeClass: 'asset', mediaType: 'application/json; charset=utf-8' },
625
743
  ],
744
+ [
745
+ ROBOTS_PATH,
746
+ { routeClass: 'asset', mediaType: 'text/plain; charset=utf-8' },
747
+ ],
748
+ [
749
+ llmsFilePath,
750
+ { routeClass: 'asset', mediaType: 'text/plain; charset=utf-8' },
751
+ ],
752
+ [
753
+ llmsFullFilePath,
754
+ { routeClass: 'asset', mediaType: 'text/plain; charset=utf-8' },
755
+ ],
626
756
  ]);
627
757
 
628
758
  const outputFilePaths = await listFilesSortedByUtf8Bytes(outputDirectory);