@gjsify/site 0.64.10 → 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.
- package/README.md +85 -19
- package/lib/cli.js +4 -14
- package/lib/client/site.css +283 -3
- package/lib/index.d.ts +5 -0
- package/lib/index.js +5 -0
- package/lib/render/blog.d.ts +62 -0
- package/lib/render/blog.js +240 -0
- package/lib/render/developer.d.ts +30 -0
- package/lib/render/developer.js +285 -0
- package/lib/render/feed.d.ts +25 -0
- package/lib/render/feed.js +97 -0
- package/lib/render/i18n.d.ts +2 -2
- package/lib/render/i18n.js +2 -0
- package/lib/render/icons.d.ts +1 -1
- package/lib/render/icons.js +3 -0
- package/lib/render/image-backend.d.ts +2 -1
- package/lib/render/image-backend.js +2 -2
- package/lib/render/images.d.ts +2 -0
- package/lib/render/images.js +3 -2
- package/lib/render/layout.d.ts +94 -0
- package/lib/render/layout.js +216 -0
- package/lib/render/markdown.d.ts +15 -1
- package/lib/render/markdown.js +245 -20
- package/lib/render/pages.d.ts +21 -8
- package/lib/render/pages.js +99 -263
- package/lib/render/seo.d.ts +34 -1
- package/lib/render/seo.js +50 -4
- package/lib/site-data/blog.d.ts +30 -0
- package/lib/site-data/blog.js +226 -0
- package/lib/site-data/config.d.ts +7 -2
- package/lib/site-data/config.js +118 -23
- package/lib/site-data/developer.d.ts +16 -0
- package/lib/site-data/developer.js +12 -0
- package/lib/site-data/frontmatter.d.ts +7 -0
- package/lib/site-data/frontmatter.js +242 -0
- package/lib/site-data/index.d.ts +4 -0
- package/lib/site-data/index.js +4 -0
- package/lib/site-data/related.d.ts +4 -2
- package/lib/site-data/related.js +8 -3
- package/lib/site-data/resume.d.ts +64 -0
- package/lib/site-data/resume.js +277 -0
- package/lib/site-data/types.d.ts +57 -15
- package/lib/site.d.ts +2 -26
- package/lib/site.js +211 -78
- package/package.json +13 -12
- package/po/de.po +88 -25
- package/po/site.pot +86 -23
- package/lib/render/overview.d.ts +0 -19
- package/lib/render/overview.js +0 -53
package/lib/render/pages.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { epcPayload, formatIban, isBrowserPlatform, PLATFORM_ORDER, pickByLanguage, relatedText, relatedUrl, } from '../site-data/index.js';
|
|
2
|
-
import {
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
.
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
|
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
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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,
|
|
729
|
+
const { data, t } = ctx;
|
|
803
730
|
const meta = pageMeta(ctx, 'support');
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
${
|
|
809
|
-
|
|
810
|
-
|
|
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
|
|
839
|
-
* visiting THIS static page
|
|
840
|
-
*
|
|
841
|
-
*
|
|
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(
|
|
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 =
|
|
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
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
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
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/render/seo.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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;
|