@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.
package/src/core/index.js CHANGED
@@ -100,14 +100,22 @@ import {
100
100
  } from './internal/page-kinds.js';
101
101
  import { assertProvenance } from './internal/provenance.js';
102
102
  import {
103
+ deriveAuthorSlugs,
104
+ routeSegmentForLabel,
105
+ } from './internal/route-labels.js';
106
+ import {
107
+ derivePublicBasePath,
103
108
  errorDocumentPath,
109
+ joinPublicRoute,
104
110
  joinBasePathAndRoute,
105
111
  projectFixedAssetPath,
106
112
  projectRouteToFilePath,
107
113
  renderRedirectDocument,
108
114
  } from './internal/route.js';
109
115
  import { buildSearchIndex } from './internal/search-index.js';
110
- 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';
111
119
  import { buildSitemap } from './internal/sitemap.js';
112
120
  import {
113
121
  defaultThemeStylesheetLinksHtml,
@@ -115,12 +123,12 @@ import {
115
123
  } from './internal/theme-assets.js';
116
124
  import { resolveTextDirection } from './internal/text-direction.js';
117
125
  import {
118
- renderFooter,
119
- renderFooterNavigation,
120
- renderHeader,
126
+ escapeHtml,
121
127
  renderPageBody,
122
128
  renderPrimaryNavigation,
123
129
  } from './internal/skeleton.js';
130
+ import { renderFooter, renderHeader } from './internal/chrome.js';
131
+ import { createComponents, createMediaImage } from './internal/components.js';
124
132
  import {
125
133
  collectIncludedSources,
126
134
  compareUtf8Bytes,
@@ -133,6 +141,13 @@ const SITEMAP_ROUTE = '/sitemap.xml';
133
141
  /** @type {string} the un-joined route the static search index is published
134
142
  * at. */
135
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';
136
151
 
137
152
  /** @type {string} */
138
153
  const BUILD_INPUT_SCHEMA_ID = 'urn:gala:schema:build-input:2.0.0';
@@ -322,63 +337,144 @@ export async function renderPublication(buildInput, options) {
322
337
  // that navigation/footer text does not flip language from page to page
323
338
  // (see `internal/messages.js`).
324
339
  const siteMessages = getMessages(validatedInput.publication.defaultLanguage);
325
- const homeRoute = joinBasePathAndRoute(validatedInput.basePath, '/');
340
+ // Every emitted URL carries the `baseUrl` path (for example `/g9/` on a
341
+ // GitHub project site); output file paths keep using `basePath` alone.
342
+ const publicBasePath = derivePublicBasePath(
343
+ validatedInput.baseUrl,
344
+ validatedInput.basePath,
345
+ );
346
+ const urlPrefix = derivePublicBasePath(validatedInput.baseUrl, '/');
347
+ const homeRoute = joinPublicRoute(publicBasePath, '/');
326
348
 
327
349
  const appearanceControlHtml = renderAppearanceControl({
328
350
  messages: siteMessages,
329
351
  });
330
- const headerHtml = renderHeader({
331
- homeRoute,
332
- publicationName: validatedInput.publication.title,
333
- logo: undefined,
334
- 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),
335
367
  });
336
- const footerNavHtml = renderFooterNavigation({
337
- items: validatedInput.navigation.footerItems,
338
- messages: siteMessages,
368
+ const mediaImage = createMediaImage({
369
+ mediaAssets,
370
+ publicBasePath,
371
+ mediaDimensions,
339
372
  });
340
- 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;
341
381
  // Already verified render-policy-conformant HTML by
342
382
  // assertRenderPolicyCompliance's assertPolicyConformantHtml call above —
343
383
  // inserted as-is, never re-parsed as Markdown.
344
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);
345
420
  const footerHtml = renderFooter({
346
- publicationName: validatedInput.publication.title,
347
- 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,
348
431
  footerProfileHtml,
349
- copyrightText: undefined,
432
+ copyrightText: `${siteMessages.footerCopyrightPrefix} ${latestYear ? `${latestYear} ` : ''}${publication.title}`,
350
433
  messages: siteMessages,
351
434
  });
352
435
 
353
- // Run before page assembly, not after: resolving each page's own
354
- // `og:image`/`twitter:image` needs the media pipeline's own finished
355
- // `assets` list (only it knows which derivative file extension a given
356
- // source image's `sourceDigest` produced — see `internal/seo.js`). This
357
- // 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 { assets: mediaAssets, files: mediaFiles } = await processMedia(
362
- validatedInput,
363
- { sourceDirectory: path.resolve(options.sourceDirectory) },
364
- );
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
+ });
365
462
 
366
463
  // The site-wide feeds, resolved once so every page's feed-discovery
367
464
  // `<link>` tags and the feed documents themselves agree on the exact same
368
465
  // self URLs.
369
466
  const feeds = buildFeeds(validatedInput);
370
467
  const siteName = validatedInput.publication.title;
371
- const appearanceScriptHrefValue = appearanceBootstrapScriptHref(
372
- validatedInput.basePath,
373
- );
468
+ const appearanceScriptHrefValue =
469
+ appearanceBootstrapScriptHref(publicBasePath);
374
470
  // TPL-H3/TPL-M7: the template-owned gala-base stylesheet's own basePath-
375
471
  // joined href, computed the same way as the appearance script href above
376
472
  // so it can never drift from the physical file/manifest path written
377
473
  // below.
378
- const baseStylesheetHrefValue = `/${projectFixedAssetPath(
379
- validatedInput.basePath,
474
+ const baseStylesheetHrefValue = joinBasePathAndRoute(
475
+ publicBasePath,
380
476
  `/${GALA_BASE_STYLESHEET_PATH}`,
381
- )}`;
477
+ );
382
478
 
383
479
  // The selected theme package's copied stylesheets/passive assets and the
384
480
  // ordered `<link>` markup every generated page's `<head>` inserts.
@@ -389,58 +485,69 @@ export async function renderPublication(buildInput, options) {
389
485
  ? await loadThemeAssets({
390
486
  themeDirectory: path.resolve(options.themeDirectory),
391
487
  basePath: validatedInput.basePath,
488
+ publicBasePath,
392
489
  })
393
490
  : undefined;
394
491
  const themeStylesheetLinksHtml = themeAssetsResult
395
492
  ? themeAssetsResult.linkTagsHtml
396
- : defaultThemeStylesheetLinksHtml(validatedInput.basePath);
493
+ : defaultThemeStylesheetLinksHtml(publicBasePath);
397
494
 
398
495
  /** @type {{virtualPath: string, content: string, permalink: string, layout?: string, data?: Record<string, unknown>}[]} */
399
496
  const contentPages = [];
400
- /** @type {{joinedSource: string, joinedTarget: string, filePath: string}[]} */
497
+ /** @type {{joinedSource: string, joinedTarget: string, publicTarget: string, filePath: string}[]} */
401
498
  const redirectProjections = [];
402
499
 
403
- const generatedPages = buildGeneratedPages(validatedInput);
500
+ const generatedPages = buildGeneratedPages(validatedInput, {
501
+ mediaAssets,
502
+ mediaDimensions,
503
+ });
404
504
  generatedPages.forEach((page, index) => {
405
505
  const joinedRoute = joinBasePathAndRoute(
406
506
  validatedInput.basePath,
407
507
  page.route,
408
508
  );
409
- const navHtml = renderPrimaryNavigation({
410
- items: validatedInput.navigation.items,
411
- currentRoute: joinedRoute,
412
- messages: siteMessages,
413
- });
414
509
  const bodyHtml = renderPageBody({
415
510
  messages: siteMessages,
416
- headerHtml,
417
- navHtml,
511
+ headerHtml: headerFor(joinedRoute),
418
512
  footerHtml,
419
- breadcrumbHtml: page.breadcrumbHtml,
420
513
  mainHtml: page.bodyHtml,
421
514
  });
422
- const ogImage = resolveSocialImageUrl({
515
+ const ogImageInfo = resolveSocialImage({
423
516
  mediaAssets,
517
+ mediaDimensions,
424
518
  reference: page.socialImageRef,
425
519
  baseUrl: validatedInput.baseUrl,
426
- basePath: validatedInput.basePath,
520
+ basePath: publicBasePath,
427
521
  });
522
+ const ogImage = ogImageInfo?.url;
428
523
  contentPages.push({
429
524
  virtualPath: `pages/${page.kind}-${index}.html`,
430
525
  content: bodyHtml,
431
526
  permalink: projectRouteToFilePath(joinedRoute, routeProfile),
432
527
  layout: 'skeleton.njk',
433
528
  data: {
434
- title: page.title,
529
+ title: page.documentTitle ?? page.title,
530
+ ogTitle: page.title,
435
531
  lang: page.language,
436
532
  dir: page.direction,
437
533
  pageKind: page.kind,
438
534
  description: page.description,
439
535
  robotsContent: page.robotsContent,
440
- canonicalUrl: new URL(joinedRoute, validatedInput.baseUrl).toString(),
536
+ canonicalUrl: new URL(
537
+ joinPublicRoute(publicBasePath, page.route),
538
+ validatedInput.baseUrl,
539
+ ).toString(),
441
540
  siteName,
442
541
  ogType: page.ogType ?? 'website',
443
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,
444
551
  twitterCard: twitterCardType(Boolean(ogImage)),
445
552
  atomFeedUrl: feeds.atomSelfUrl,
446
553
  rssFeedUrl: feeds.rssSelfUrl,
@@ -464,6 +571,10 @@ export async function renderPublication(buildInput, options) {
464
571
  redirectProjections.push({
465
572
  joinedSource,
466
573
  joinedTarget: joined,
574
+ publicTarget: joinPublicRoute(
575
+ publicBasePath,
576
+ contentRoute(record.frontmatter),
577
+ ),
467
578
  filePath: projectRouteToFilePath(joinedSource, routeProfile),
468
579
  });
469
580
  }
@@ -471,7 +582,7 @@ export async function renderPublication(buildInput, options) {
471
582
 
472
583
  const redirectPages = redirectProjections.map((projection, index) => ({
473
584
  virtualPath: `redirects/${index}.html`,
474
- content: renderRedirectDocument(projection.joinedTarget),
585
+ content: renderRedirectDocument(projection.publicTarget),
475
586
  permalink: projection.filePath,
476
587
  }));
477
588
 
@@ -485,15 +596,9 @@ export async function renderPublication(buildInput, options) {
485
596
  // page kind's own pure body function. Pushed after the guard above so the
486
597
  // guard's own "is there anything authored to render" check is unaffected
487
598
  // by this always-present synthetic page.
488
- const errorNavHtml = renderPrimaryNavigation({
489
- items: validatedInput.navigation.items,
490
- currentRoute: undefined,
491
- messages: siteMessages,
492
- });
493
599
  const errorBodyHtml = renderPageBody({
494
600
  messages: siteMessages,
495
- headerHtml,
496
- navHtml: errorNavHtml,
601
+ headerHtml: headerFor(undefined),
497
602
  footerHtml,
498
603
  mainHtml: renderErrorPageBody({ messages: siteMessages, homeRoute }),
499
604
  });
@@ -504,7 +609,8 @@ export async function renderPublication(buildInput, options) {
504
609
  permalink: errorFilePath,
505
610
  layout: 'skeleton.njk',
506
611
  data: {
507
- title: /** @type {string} */ (siteMessages.errorPageHeading),
612
+ title: `${/** @type {string} */ (siteMessages.errorPageHeading)} | ${siteName}`,
613
+ ogTitle: /** @type {string} */ (siteMessages.errorPageHeading),
508
614
  lang: validatedInput.publication.defaultLanguage,
509
615
  dir: resolveTextDirection(validatedInput.publication.defaultLanguage),
510
616
  pageKind: 'error',
@@ -546,11 +652,42 @@ export async function renderPublication(buildInput, options) {
546
652
  );
547
653
  const sitemapXml = buildSitemap({
548
654
  generatedPages,
549
- basePath: validatedInput.basePath,
655
+ basePath: publicBasePath,
550
656
  baseUrl: validatedInput.baseUrl,
551
657
  });
552
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
+ });
553
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
+ },
554
691
  {
555
692
  virtualPath: 'feeds/atom.html',
556
693
  content: feeds.atomXml,
@@ -604,6 +741,18 @@ export async function renderPublication(buildInput, options) {
604
741
  searchIndexFilePath,
605
742
  { routeClass: 'asset', mediaType: 'application/json; charset=utf-8' },
606
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
+ ],
607
756
  ]);
608
757
 
609
758
  const outputFilePaths = await listFilesSortedByUtf8Bytes(outputDirectory);