@gjsify/site 0.64.11 → 0.64.12-next.279

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.
Files changed (49) hide show
  1. package/README.md +85 -19
  2. package/lib/cli.js +4 -14
  3. package/lib/client/site.css +283 -3
  4. package/lib/index.d.ts +5 -0
  5. package/lib/index.js +5 -0
  6. package/lib/render/blog.d.ts +62 -0
  7. package/lib/render/blog.js +240 -0
  8. package/lib/render/developer.d.ts +30 -0
  9. package/lib/render/developer.js +285 -0
  10. package/lib/render/feed.d.ts +25 -0
  11. package/lib/render/feed.js +97 -0
  12. package/lib/render/i18n.d.ts +2 -2
  13. package/lib/render/i18n.js +2 -0
  14. package/lib/render/icons.d.ts +1 -1
  15. package/lib/render/icons.js +3 -0
  16. package/lib/render/image-backend.d.ts +2 -1
  17. package/lib/render/image-backend.js +2 -2
  18. package/lib/render/images.d.ts +2 -0
  19. package/lib/render/images.js +3 -2
  20. package/lib/render/layout.d.ts +94 -0
  21. package/lib/render/layout.js +216 -0
  22. package/lib/render/markdown.d.ts +15 -1
  23. package/lib/render/markdown.js +245 -20
  24. package/lib/render/pages.d.ts +21 -8
  25. package/lib/render/pages.js +99 -263
  26. package/lib/render/seo.d.ts +34 -1
  27. package/lib/render/seo.js +50 -4
  28. package/lib/site-data/blog.d.ts +30 -0
  29. package/lib/site-data/blog.js +226 -0
  30. package/lib/site-data/config.d.ts +7 -2
  31. package/lib/site-data/config.js +118 -23
  32. package/lib/site-data/developer.d.ts +16 -0
  33. package/lib/site-data/developer.js +12 -0
  34. package/lib/site-data/frontmatter.d.ts +7 -0
  35. package/lib/site-data/frontmatter.js +242 -0
  36. package/lib/site-data/index.d.ts +4 -0
  37. package/lib/site-data/index.js +4 -0
  38. package/lib/site-data/related.d.ts +4 -2
  39. package/lib/site-data/related.js +8 -3
  40. package/lib/site-data/resume.d.ts +64 -0
  41. package/lib/site-data/resume.js +277 -0
  42. package/lib/site-data/types.d.ts +57 -15
  43. package/lib/site.d.ts +2 -26
  44. package/lib/site.js +211 -78
  45. package/package.json +13 -12
  46. package/po/de.po +88 -25
  47. package/po/site.pot +86 -23
  48. package/lib/render/overview.d.ts +0 -19
  49. package/lib/render/overview.js +0 -53
@@ -1,11 +1,11 @@
1
1
  import { epcPayload, formatIban, isBrowserPlatform, PLATFORM_ORDER, pickByLanguage, relatedText, relatedUrl, } from '../site-data/index.js';
2
- import { readableText } from './color.js';
2
+ import { blogFeedUrl, blogIndexUrl } from './blog.js';
3
3
  import { attrs, esc, fmt, plain } from './html.js';
4
4
  import { icon } from './icons.js';
5
+ import { absoluteUrl, documentPage, githubUrl, rootPage, simplePage, sitePageUrl, } from './layout.js';
5
6
  import { renderMarkdown } from './markdown.js';
6
- import { OG_HEIGHT, OG_WIDTH } from './og-card.js';
7
7
  import { qrSvg } from './qr.js';
8
- import { metaDescription, ogLocale, pageTitle, softwareApplicationJsonLd } from './seo.js';
8
+ import { metaDescription, pageTitle, softwareApplicationJsonLd } from './seo.js';
9
9
  const FORMAT_TYPES = { avif: 'image/avif', webp: 'image/webp', png: 'image/png' };
10
10
  const LINKS = [
11
11
  ['homepage', (t) => t('Website'), 'web'],
@@ -34,7 +34,7 @@ export function pageUrl(data, lang) {
34
34
  return `${data.config.base}${lang}/`;
35
35
  }
36
36
  export function absolute(data, rootRelative) {
37
- return new URL(rootRelative.replace(/^\//, ''), `${new URL(data.config.url).origin}/`).href;
37
+ return absoluteUrl(data.config, rootRelative);
38
38
  }
39
39
  /**
40
40
  * A link into this site's own `/<lang>/…` pages, moved to the language being rendered; any other
@@ -63,9 +63,6 @@ export function legalNoticeUrl(data, lang) {
63
63
  export function supportReference(data) {
64
64
  return `Support ${data.app.name.source}`;
65
65
  }
66
- export function languageName(code) {
67
- return new Intl.DisplayNames([code], { type: 'language' }).of(code) ?? code;
68
- }
69
66
  function blocks(content, lang) {
70
67
  return content
71
68
  .map((block) => block.type === 'paragraph'
@@ -487,7 +484,8 @@ ${items.join('\n')}
487
484
  </ul>
488
485
  </section>`;
489
486
  }
490
- function relatedCard(app, lang) {
487
+ /** One app as a card: icon, name, summary. Also the app grid of a developer site. */
488
+ export function relatedCard(app, lang) {
491
489
  const summary = relatedText(app.summary, lang);
492
490
  const mark = app.icon
493
491
  ? `<img class="related-icon" src="${esc(app.icon)}" alt="" width="48" height="48" loading="lazy">`
@@ -564,47 +562,6 @@ function tags(ctx) {
564
562
  ].join('\n');
565
563
  return body.trim() ? `<section class="tags-section">\n${body}\n</section>` : '';
566
564
  }
567
- /** Which address the current view's own page lives at, in another language. */
568
- function viewUrl(ctx, code) {
569
- const { data, view } = ctx;
570
- if (view === 'support')
571
- return supportUrl(data, code);
572
- if (view === 'privacy')
573
- return privacyUrl(data, code);
574
- if (view === 'legal-notice')
575
- return legalNoticeUrl(data, code);
576
- return pageUrl(data, code);
577
- }
578
- function languageSwitch(ctx, className, style) {
579
- const { data, lang, t } = ctx;
580
- const items = data.config.languages.map((code) => {
581
- const label = style === 'code' ? code.toUpperCase() : languageName(code);
582
- return code === lang
583
- ? `<li><span aria-current="true">${esc(label)}</span></li>`
584
- : `<li><a href="${esc(viewUrl(ctx, code))}" hreflang="${esc(code)}" lang="${esc(code)}">${esc(label)}</a></li>`;
585
- });
586
- return `<nav class="${className}" aria-label="${esc(t('Language'))}"><ul>${items.join('')}</ul></nav>`;
587
- }
588
- function repositoryUrl(data) {
589
- return data.app.urls.vcsBrowser ?? (data.config.repository || undefined);
590
- }
591
- function topBar(ctx) {
592
- const { data, lang, t } = ctx;
593
- const repo = repositoryUrl(data);
594
- const onGitHub = repo ? /(^|\.)github\.com$/.test(new URL(repo).host) : false;
595
- return `<div class="topbar">
596
- <div class="bar-inner">
597
- <a class="bar-brand" href="${esc(pageUrl(data, lang))}">
598
- ${ctx.iconUrl ? `<img src="${esc(ctx.iconUrl)}" alt="" width="24" height="24">` : ''}
599
- <span>${esc(localized(data.app.name, lang))}</span>
600
- </a>
601
- <div class="bar-end">
602
- ${languageSwitch(ctx, 'bar-languages', 'code')}
603
- ${repo ? `<a class="bar-link" href="${esc(repo)}">${icon('code')}<span>${onGitHub ? 'GitHub' : esc(t('Source code'))}</span></a>` : ''}
604
- </div>
605
- </div>
606
- </div>`;
607
- }
608
565
  /**
609
566
  * Everything above the text: the icon and name, the install button, the facts row and the
610
567
  * screenshots. One element, so the app's brand colour is behind all of it rather than behind the
@@ -628,36 +585,6 @@ ${badges(ctx)}
628
585
  ${screenshotBand(ctx)}
629
586
  </header>`;
630
587
  }
631
- function footer(ctx) {
632
- const { data, lang, t } = ctx;
633
- const name = localized(data.app.name, lang);
634
- const repo = repositoryUrl(data);
635
- // The privacy and legal-notice links are derived, never configured: a consumer with a
636
- // `gjsify.site.privacy`/`legalNotice` block gets the link on every page without also listing
637
- // it in `legal`, which would be a second place for the two to disagree.
638
- const legal = [
639
- ...(data.config.legalNotice
640
- ? [`<li><a href="${esc(legalNoticeUrl(data, lang))}">${esc(t('Legal Notice'))}</a></li>`]
641
- : []),
642
- ...(data.config.privacy ? [`<li><a href="${esc(privacyUrl(data, lang))}">${esc(t('Privacy'))}</a></li>`] : []),
643
- ...data.config.legal.map((link) => `<li><a href="${esc(link.url)}">${esc(link.label)}</a></li>`),
644
- ].join('');
645
- const licenses = [
646
- data.app.license ? fmt(t('{app} is licensed under {license}.'), { app: name, license: data.app.license }) : '',
647
- data.app.contentLicense
648
- ? fmt(t('This page reproduces text licensed under {license}.'), { license: data.app.contentLicense })
649
- : '',
650
- ].filter(Boolean);
651
- return `<footer class="footer">
652
- <div class="footer-inner">
653
- ${languageSwitch(ctx, 'footer-languages', 'name')}
654
- ${repo ? `<p class="footer-source"><a href="${esc(repo)}">${icon('code')}<span>${esc(t('Source code'))}</span></a></p>` : ''}
655
- ${licenses.length > 0 ? `<p class="footer-license">${licenses.join(' ')}</p>` : ''}
656
- <p class="footer-made"><a href="https://github.com/gjsify/gjsify">${esc(plain(t('Made with {project}'), { project: 'gjsify' }))}</a></p>
657
- ${legal ? `<ul class="footer-legal">${legal}</ul>` : ''}
658
- </div>
659
- </footer>`;
660
- }
661
588
  /** The title and description of one page; also what the build checks for weak ones. */
662
589
  export function pageMeta(ctx, view = 'app') {
663
590
  const { data, lang, t } = ctx;
@@ -717,67 +644,59 @@ function structuredData(ctx, title, description) {
717
644
  languages: data.config.languages,
718
645
  });
719
646
  }
720
- function head(ctx) {
721
- const { data, lang } = ctx;
722
- const view = ctx.view ?? 'app';
723
- const viewPath = (code) => viewUrl({ data, view }, code);
724
- const { name, title, description } = pageMeta(ctx, view);
725
- const url = absolute(data, viewPath(lang));
726
- const ogImage = ctx.ogImage ? absolute(data, ctx.ogImage) : undefined;
727
- const { light, dark } = data.app.branding;
728
- // An app without `<branding>` keeps the stylesheet's own palette: the custom property is left
729
- // unset so its `var()` fallback wins, rather than being set to a stand-in colour here.
730
- const brandVars = [
731
- light ? `--brand-light:${light};--band-fg-light:${readableText(light)}` : '',
732
- dark ? `--brand-dark:${dark};--band-fg-dark:${readableText(dark)}` : '',
733
- ]
734
- .filter(Boolean)
735
- .join(';');
736
- const alternates = data.config.languages.map((code) => `<link rel="alternate" hreflang="${esc(code)}" href="${esc(absolute(data, viewPath(code)))}">`);
737
- const meta = [
738
- ['og:type', 'website'],
739
- ['og:title', title],
740
- ['og:description', description],
741
- ['og:url', url],
742
- ['og:site_name', name],
743
- ['og:locale', ogLocale(lang)],
744
- ...data.config.languages
745
- .filter((code) => code !== lang)
746
- .map((code) => ['og:locale:alternate', ogLocale(code)]),
747
- ];
748
- if (ogImage) {
749
- meta.push(['og:image', ogImage], ['og:image:width', String(OG_WIDTH)], ['og:image:height', String(OG_HEIGHT)], ['og:image:alt', name]);
750
- }
751
- return `<meta charset="utf-8">
752
- <meta name="viewport" content="width=device-width, initial-scale=1">
753
- <title>${esc(title)}</title>
754
- <meta name="description" content="${esc(description)}">
755
- <link rel="canonical" href="${esc(url)}">
756
- ${alternates.join('\n')}
757
- <link rel="alternate" hreflang="x-default" href="${esc(absolute(data, viewPath(data.config.defaultLanguage)))}">
758
- ${meta.map(([property, content]) => `<meta property="${property}" content="${esc(content)}">`).join('\n')}
759
- <meta name="twitter:card" content="${ogImage ? 'summary_large_image' : 'summary'}">
760
- <meta name="twitter:title" content="${esc(title)}">
761
- <meta name="twitter:description" content="${esc(description)}">
762
- ${ogImage ? `<meta name="twitter:image" content="${esc(ogImage)}">` : ''}
763
- <meta name="theme-color" content="${esc(light ?? '#ffffff')}" media="(prefers-color-scheme: light)">
764
- <meta name="theme-color" content="${esc(dark ?? '#1e1e1e')}" media="(prefers-color-scheme: dark)">
765
- ${ctx.iconUrl ? `<link rel="icon" type="image/svg+xml" href="${esc(ctx.iconUrl)}">` : ''}
766
- ${view === 'app' ? structuredData(ctx, title, description) : ''}
767
- ${brandVars ? `<style>:root{${brandVars}}</style>` : ''}
768
- ${ctx.assets.css.map((href) => `<link rel="stylesheet" href="${esc(href)}">`).join('\n')}
769
- <script type="module" src="${esc(ctx.assets.js)}"></script>`;
647
+ const VIEW_PATHS = {
648
+ app: '',
649
+ support: 'support/',
650
+ privacy: 'privacy/',
651
+ 'legal-notice': 'legal-notice/',
652
+ };
653
+ function repositoryUrl(data) {
654
+ return data.app.urls.vcsBrowser ?? githubUrl(data.config.repository);
655
+ }
656
+ /** The frame of every page of an app site in one language. */
657
+ export function appShell(ctx) {
658
+ const { data, lang, t } = ctx;
659
+ const name = localized(data.app.name, lang);
660
+ const { config } = data;
661
+ return {
662
+ config,
663
+ lang,
664
+ t,
665
+ assets: ctx.assets,
666
+ name,
667
+ home: pageUrl(data, lang),
668
+ iconUrl: ctx.iconUrl,
669
+ branding: data.app.branding,
670
+ repository: repositoryUrl(data),
671
+ licenses: [
672
+ data.app.license
673
+ ? plain(t('{app} is licensed under {license}.'), { app: name, license: data.app.license })
674
+ : '',
675
+ data.app.contentLicense
676
+ ? plain(t('This page reproduces text licensed under {license}.'), { license: data.app.contentLicense })
677
+ : '',
678
+ ].filter(Boolean),
679
+ nav: config.blog
680
+ ? [{ href: blogIndexUrl(config, lang), label: t('Blog'), glyph: 'blog', current: ctx.current === 'blog' }]
681
+ : [],
682
+ feeds: config.blog ? [{ title: `${name} – ${t('Blog')}`, href: blogFeedUrl(config, lang) }] : [],
683
+ displayFontUrl: ctx.displayFontUrl,
684
+ };
685
+ }
686
+ function appPageMeta(ctx, view) {
687
+ const { data } = ctx;
688
+ const { title, description } = pageMeta(ctx, view);
689
+ return {
690
+ title,
691
+ description,
692
+ urls: Object.fromEntries(data.config.languages.map((code) => [code, sitePageUrl(data.config, code, VIEW_PATHS[view])])),
693
+ ogImage: ctx.ogImage,
694
+ jsonLd: view === 'app' ? structuredData(ctx, title, description) : undefined,
695
+ };
770
696
  }
771
697
  export function renderPage(ctx) {
772
698
  const { data, lang } = ctx;
773
- return `<!doctype html>
774
- <html lang="${esc(lang)}">
775
- <head>
776
- ${head(ctx)}
777
- </head>
778
- <body>
779
- ${topBar(ctx)}
780
- ${masthead(ctx)}
699
+ return documentPage(appShell(ctx), appPageMeta(ctx, 'app'), `${masthead(ctx)}
781
700
  <main>
782
701
  <div class="column">
783
702
  <section class="about">
@@ -785,66 +704,48 @@ ${masthead(ctx)}
785
704
  ${blocks(data.app.description, lang)}
786
705
  </section>
787
706
  ${releases(ctx)}
707
+ ${ctx.latestPosts ?? ''}
788
708
  ${support(ctx)}
789
709
  ${links(ctx)}
790
710
  ${relatedSection(ctx)}
791
711
  ${tags(ctx)}
792
712
  </div>
793
- </main>
794
- ${footer(ctx)}
795
- </body>
796
- </html>
797
- `;
713
+ </main>`);
714
+ }
715
+ /** The support entries as cards; shared with the developer site, whose reference is the person's. */
716
+ export function supportList(entries, t, reference) {
717
+ return `<app-support>
718
+ <ul class="card-list">
719
+ ${entries.map((entry) => supportItem(entry, t, reference)).join('\n')}
720
+ </ul>
721
+ </app-support>`;
722
+ }
723
+ function backToApp(ctx, name) {
724
+ return { href: pageUrl(ctx.data, ctx.lang), label: fmt(ctx.t('Back to {app}'), { app: name }) };
798
725
  }
799
726
  /** `/<lang>/support/`: the ways to support the app, as plain markup that needs no script. */
800
727
  export function renderSupportPage(page) {
801
728
  const ctx = { ...page, view: 'support' };
802
- const { data, lang, t } = ctx;
729
+ const { data, t } = ctx;
803
730
  const meta = pageMeta(ctx, 'support');
804
- const reference = supportReference(data);
805
- return `<!doctype html>
806
- <html lang="${esc(lang)}">
807
- <head>
808
- ${head(ctx)}
809
- </head>
810
- <body>
811
- ${topBar(ctx)}
812
- <header class="masthead">
813
- <div class="hero-inner">
814
- ${ctx.iconUrl ? `<div class="hero-icon" style="--icon-url:url(${esc(ctx.iconUrl)})"><img src="${esc(ctx.iconUrl)}" alt="" width="128" height="128"></div>` : ''}
815
- <div class="hero-text">
816
- <h1>${esc(t('Support'))}</h1>
817
- <p class="hero-by">${esc(meta.name)}</p>
818
- </div>
819
- </div>
820
- </header>
821
- <main>
822
- <div class="column">
823
- <p class="support-intro">${esc(meta.description)}</p>
824
- <app-support>
825
- <ul class="card-list">
826
- ${data.config.support.map((entry) => supportItem(entry, t, reference)).join('\n')}
827
- </ul>
828
- </app-support>
829
- <p class="support-back"><a href="${esc(pageUrl(data, lang))}">${fmt(t('Back to {app}'), { app: meta.name })}</a></p>
830
- </div>
831
- </main>
832
- ${footer(ctx)}
833
- </body>
834
- </html>
835
- `;
731
+ return simplePage(appShell(ctx), appPageMeta(ctx, 'support'), {
732
+ heading: t('Support'),
733
+ byline: meta.name,
734
+ body: `<p class="support-intro">${esc(meta.description)}</p>
735
+ ${supportList(data.config.support, t, supportReference(data))}`,
736
+ back: backToApp(ctx, meta.name),
737
+ });
836
738
  }
837
739
  /**
838
- * The paragraph every privacy page opens with, regardless of what the app does. It is about
839
- * visiting THIS static page, not about the app — this package adds no cookie, no analytics and no
840
- * third-party resource of its own, so that much is true for every consumer. `hostingPolicyUrl` is
841
- * the one fact that varies by deployment and is not guessed; left out, nothing about hosting is said.
740
+ * The paragraph every privacy page opens with, regardless of what the site is about. It is about
741
+ * visiting THIS static page — this package adds no cookie, no analytics and no third-party resource
742
+ * of its own, so that much is true for every consumer. `hostingPolicyUrl` is the one fact that
743
+ * varies by deployment and is not guessed; left out, nothing about hosting is said.
842
744
  */
843
- function hostingNotice(ctx) {
844
- const { data, t } = ctx;
745
+ export function hostingNotice(config, t) {
845
746
  const intro = t('This page is a static file: no cookie, no analytics, nothing from a third party.');
846
747
  const logs = t('Whoever serves it may keep ordinary web-server access logs of the request.');
847
- const link = data.config.privacy?.hostingPolicyUrl;
748
+ const link = config.privacy?.hostingPolicyUrl;
848
749
  return `<p class="privacy-hosting">${esc(intro)} ${esc(logs)}${link ? ` <a href="${esc(link)}">${esc(t("See the hosting provider's notice"))}</a>` : ''}</p>`;
849
750
  }
850
751
  /**
@@ -854,35 +755,15 @@ function hostingNotice(ctx) {
854
755
  */
855
756
  export function renderPrivacyPage(page, statement) {
856
757
  const ctx = { ...page, view: 'privacy' };
857
- const { data, lang, t } = ctx;
858
758
  const meta = pageMeta(ctx, 'privacy');
859
- return `<!doctype html>
860
- <html lang="${esc(lang)}">
861
- <head>
862
- ${head(ctx)}
863
- </head>
864
- <body>
865
- ${topBar(ctx)}
866
- <header class="masthead">
867
- <div class="hero-inner">
868
- ${ctx.iconUrl ? `<div class="hero-icon" style="--icon-url:url(${esc(ctx.iconUrl)})"><img src="${esc(ctx.iconUrl)}" alt="" width="128" height="128"></div>` : ''}
869
- <div class="hero-text">
870
- <h1>${esc(t('Privacy'))}</h1>
871
- <p class="hero-by">${esc(meta.name)}</p>
872
- </div>
873
- </div>
874
- </header>
875
- <main>
876
- <div class="column privacy-statement">
877
- ${hostingNotice(ctx)}
878
- ${renderMarkdown(statement)}
879
- <p class="support-back"><a href="${esc(pageUrl(data, lang))}">${fmt(t('Back to {app}'), { app: meta.name })}</a></p>
880
- </div>
881
- </main>
882
- ${footer(ctx)}
883
- </body>
884
- </html>
885
- `;
759
+ return simplePage(appShell(ctx), appPageMeta(ctx, 'privacy'), {
760
+ heading: ctx.t('Privacy'),
761
+ byline: meta.name,
762
+ columnClass: 'privacy-statement',
763
+ body: `${hostingNotice(ctx.data.config, ctx.t)}
764
+ ${renderMarkdown(statement)}`,
765
+ back: backToApp(ctx, meta.name),
766
+ });
886
767
  }
887
768
  /**
888
769
  * `/<lang>/legal-notice/`: the app's own Legal Notice / Impressum statement, read verbatim from
@@ -893,61 +774,16 @@ ${footer(ctx)}
893
774
  */
894
775
  export function renderLegalNoticePage(page, statement) {
895
776
  const ctx = { ...page, view: 'legal-notice' };
896
- const { data, lang, t } = ctx;
897
777
  const meta = pageMeta(ctx, 'legal-notice');
898
- return `<!doctype html>
899
- <html lang="${esc(lang)}">
900
- <head>
901
- ${head(ctx)}
902
- </head>
903
- <body>
904
- ${topBar(ctx)}
905
- <header class="masthead">
906
- <div class="hero-inner">
907
- ${ctx.iconUrl ? `<div class="hero-icon" style="--icon-url:url(${esc(ctx.iconUrl)})"><img src="${esc(ctx.iconUrl)}" alt="" width="128" height="128"></div>` : ''}
908
- <div class="hero-text">
909
- <h1>${esc(t('Legal Notice'))}</h1>
910
- <p class="hero-by">${esc(meta.name)}</p>
911
- </div>
912
- </div>
913
- </header>
914
- <main>
915
- <div class="column privacy-statement">
916
- ${renderMarkdown(statement)}
917
- <p class="support-back"><a href="${esc(pageUrl(data, lang))}">${fmt(t('Back to {app}'), { app: meta.name })}</a></p>
918
- </div>
919
- </main>
920
- ${footer(ctx)}
921
- </body>
922
- </html>
923
- `;
778
+ return simplePage(appShell(ctx), appPageMeta(ctx, 'legal-notice'), {
779
+ heading: ctx.t('Legal Notice'),
780
+ byline: meta.name,
781
+ columnClass: 'privacy-statement',
782
+ body: renderMarkdown(statement),
783
+ back: backToApp(ctx, meta.name),
784
+ });
924
785
  }
925
786
  /** The root page: a script picks the visitor's language, the link list is what everyone else (and every crawler) sees. */
926
787
  export function renderRoot(data, assets, iconUrl) {
927
- const { config } = data;
928
- const name = data.app.name.source;
929
- const json = (value) => JSON.stringify(value).replace(/</g, '\\u003c');
930
- const choose = `var l=${json(config.languages)},d=${json(config.defaultLanguage)},p=(navigator.languages||[navigator.language]).map(function(x){return String(x).toLowerCase()}),c=d;for(var i=0;i<p.length;i++){var m=l.filter(function(x){return p[i]===x.toLowerCase()||p[i].split("-")[0]===x.toLowerCase()})[0];if(m){c=m;break}}location.replace(${json(config.base)}+c+"/")`;
931
- const alternates = config.languages.map((code) => `<link rel="alternate" hreflang="${esc(code)}" href="${esc(absolute(data, pageUrl(data, code)))}">`);
932
- return `<!doctype html>
933
- <html lang="${esc(config.defaultLanguage)}">
934
- <head>
935
- <meta charset="utf-8">
936
- <meta name="viewport" content="width=device-width, initial-scale=1">
937
- <title>${esc(name)}</title>
938
- <link rel="canonical" href="${esc(absolute(data, pageUrl(data, config.defaultLanguage)))}">
939
- ${alternates.join('\n')}
940
- <link rel="alternate" hreflang="x-default" href="${esc(absolute(data, pageUrl(data, config.defaultLanguage)))}">
941
- ${iconUrl ? `<link rel="icon" type="image/svg+xml" href="${esc(iconUrl)}">` : ''}
942
- ${assets.css.map((href) => `<link rel="stylesheet" href="${esc(href)}">`).join('\n')}
943
- <script>${choose}</script>
944
- </head>
945
- <body class="root">
946
- <main>
947
- <h1>${esc(name)}</h1>
948
- <ul class="languages-list">${config.languages.map((code) => `<li><a href="${esc(pageUrl(data, code))}" hreflang="${esc(code)}" lang="${esc(code)}">${esc(languageName(code))}</a></li>`).join('')}</ul>
949
- </main>
950
- </body>
951
- </html>
952
- `;
788
+ return rootPage(data.config, data.app.name.source, assets, iconUrl);
953
789
  }
@@ -1,3 +1,4 @@
1
+ import type { SiteRelatedAppConfig } from '../site-data/index.js';
1
2
  /** `de` → `de_DE`, `pt-BR` → `pt_BR`; a language with no known default territory is passed through. */
2
3
  export declare function ogLocale(lang: string): string;
3
4
  /** Search results cut a title near 60 characters and a description near 160. */
@@ -34,8 +35,40 @@ export interface SoftwareApplicationInput {
34
35
  }
35
36
  /** schema.org `SoftwareApplication` as a `<script type="application/ld+json">`. Nothing is invented: absent facts are left out. */
36
37
  export declare function softwareApplicationJsonLd(input: SoftwareApplicationInput): string;
38
+ export interface PersonInput {
39
+ name: string;
40
+ url: string;
41
+ jobTitle?: string;
42
+ description?: string;
43
+ image?: string;
44
+ email?: string;
45
+ /** Profiles elsewhere: GitHub, Mastodon, … */
46
+ sameAs: readonly string[];
47
+ }
48
+ /** `ProfilePage` about a `Person`: what a search engine reads from a developer page. */
49
+ export declare function personJsonLd(input: PersonInput): string;
50
+ export interface BlogPostingInput {
51
+ title: string;
52
+ description: string;
53
+ url: string;
54
+ /** ISO dates. */
55
+ published: string;
56
+ updated?: string;
57
+ author: string;
58
+ authorUrl?: string;
59
+ image?: string;
60
+ lang: string;
61
+ tags: readonly string[];
62
+ }
63
+ export declare function blogPostingJsonLd(input: BlogPostingInput): string;
37
64
  /** One logical page, as its absolute address per language. */
38
65
  export type SitemapPage = Readonly<Record<string, string>>;
39
66
  /** `sitemap.xml` with a self-including `xhtml:link` alternate set per page, and `x-default` on the default language. */
40
67
  export declare function sitemapXml(pages: readonly SitemapPage[], defaultLanguage: string): string;
41
- export declare function robotsTxt(sitemapUrl: string): string;
68
+ /**
69
+ * Crawlers read `robots.txt` at the host root only, so the sitemaps of apps served below this
70
+ * site on the same host would go unannounced. A site address (not an inline entry) is a site of
71
+ * this package, which always writes `sitemap.xml` next to its `app.json`.
72
+ */
73
+ export declare function neighbourSitemaps(siteUrl: string, apps: readonly SiteRelatedAppConfig[]): string[];
74
+ export declare function robotsTxt(sitemapUrls: readonly string[]): string;
package/lib/render/seo.js CHANGED
@@ -87,8 +87,43 @@ export function softwareApplicationJsonLd(input) {
87
87
  sameAs: input.repository ? [input.repository] : undefined,
88
88
  inLanguage: input.languages,
89
89
  };
90
- const json = JSON.stringify(data).replace(/</g, '\\u003c');
91
- return `<script type="application/ld+json">${json}</script>`;
90
+ return jsonLdScript(data);
91
+ }
92
+ function jsonLdScript(data) {
93
+ return `<script type="application/ld+json">${JSON.stringify(data).replace(/</g, '\\u003c')}</script>`;
94
+ }
95
+ /** `ProfilePage` about a `Person`: what a search engine reads from a developer page. */
96
+ export function personJsonLd(input) {
97
+ return jsonLdScript({
98
+ '@context': 'https://schema.org',
99
+ '@type': 'ProfilePage',
100
+ mainEntity: {
101
+ '@type': 'Person',
102
+ name: input.name,
103
+ url: input.url,
104
+ jobTitle: input.jobTitle || undefined,
105
+ description: input.description || undefined,
106
+ image: input.image,
107
+ email: input.email ? `mailto:${input.email}` : undefined,
108
+ sameAs: input.sameAs.length > 0 ? input.sameAs : undefined,
109
+ },
110
+ });
111
+ }
112
+ export function blogPostingJsonLd(input) {
113
+ return jsonLdScript({
114
+ '@context': 'https://schema.org',
115
+ '@type': 'BlogPosting',
116
+ headline: input.title,
117
+ description: input.description,
118
+ url: input.url,
119
+ mainEntityOfPage: input.url,
120
+ datePublished: input.published,
121
+ dateModified: input.updated ?? input.published,
122
+ author: { '@type': 'Person', name: input.author, url: input.authorUrl },
123
+ image: input.image,
124
+ inLanguage: input.lang,
125
+ keywords: input.tags.length > 0 ? input.tags.join(', ') : undefined,
126
+ });
92
127
  }
93
128
  /** `sitemap.xml` with a self-including `xhtml:link` alternate set per page, and `x-default` on the default language. */
94
129
  export function sitemapXml(pages, defaultLanguage) {
@@ -108,6 +143,17 @@ ${entries.join('\n')}
108
143
  </urlset>
109
144
  `;
110
145
  }
111
- export function robotsTxt(sitemapUrl) {
112
- return `User-agent: *\nAllow: /\n\nSitemap: ${sitemapUrl}\n`;
146
+ /**
147
+ * Crawlers read `robots.txt` at the host root only, so the sitemaps of apps served below this
148
+ * site on the same host would go unannounced. A site address (not an inline entry) is a site of
149
+ * this package, which always writes `sitemap.xml` next to its `app.json`.
150
+ */
151
+ export function neighbourSitemaps(siteUrl, apps) {
152
+ const origin = new URL(siteUrl).origin;
153
+ return apps
154
+ .filter((app) => app.name === undefined && new URL(app.url).origin === origin)
155
+ .map((app) => `${app.url}sitemap.xml`);
156
+ }
157
+ export function robotsTxt(sitemapUrls) {
158
+ return `User-agent: *\nAllow: /\n\n${sitemapUrls.map((url) => `Sitemap: ${url}\n`).join('')}`;
113
159
  }
@@ -0,0 +1,30 @@
1
+ export interface BlogPost {
2
+ slug: string;
3
+ lang: string;
4
+ title: string;
5
+ /** As written: YYYY-MM-DD or a full ISO 8601 date-time. */
6
+ date: string;
7
+ updated?: string;
8
+ summary?: string;
9
+ tags: string[];
10
+ draft: boolean;
11
+ /** Markdown body without frontmatter. */
12
+ body: string;
13
+ /** Absolute path of the .md file. */
14
+ file: string;
15
+ }
16
+ export interface LoadBlogOptions {
17
+ dir: string;
18
+ languages: readonly string[];
19
+ drafts: boolean;
20
+ }
21
+ /** Every post under `dir` (recursive), newest first (ties: slug ascending, then lang). */
22
+ export declare function loadBlogPosts(options: LoadBlogOptions): Promise<BlogPost[]>;
23
+ /** Posts with the same slug in the other languages. */
24
+ export declare function translationsOf(posts: readonly BlogPost[], post: BlogPost): BlogPost[];
25
+ /** Paths relative to dir (POSIX separators) of every non-.md file under dir, recursive, sorted. Skips dotfiles/dot-dirs. */
26
+ export declare function blogAssetFiles(dir: string): Promise<string[]>;
27
+ /** URL-safe tag slug: lowercase, NFKD with diacritics removed, runs of non [a-z0-9] → '-', trimmed of '-'. Throws if the result is empty. */
28
+ export declare function tagSlug(tag: string): string;
29
+ /** Milliseconds since epoch for sorting/feeds; a date-only value means 00:00 UTC. */
30
+ export declare function postTime(date: string): number;